feat(v0.3): add EnsureGitignore + RunGitInit helpers

EnsureGitignore writes a minimal .gitignore (.ctask/ +
logs/sessions.log) iff one does not already exist. This is the
file-system half of the v0.3 seed-wins rule for .gitignore: if
either the general or project seed copied a .gitignore into the
workspace, EnsureGitignore must be a no-op.

GitAvailable + RunGitInit wrap exec.LookPath("git") and `git init`
respectively, so the caller in cmd/new.go can decide whether to
print the informational note when git is missing.

Tests cover:
  - missing -> created with the minimal body
  - present -> preserved verbatim
  - integration: general seed .gitignore preserved end-to-end
  - integration: project seed .gitignore preserved end-to-end
  - integration: no seed -> minimal body created
This commit is contained in:
2026-04-10 14:41:14 -04:00
parent 8cda541f2c
commit 6519582de6
2 changed files with 191 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
package workspace
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// minimalGitignore is the .gitignore body ctask creates for new project workspaces
// when the user has not supplied one via a seed directory.
//
// Kept narrow on purpose: ctask only owns its own metadata and session log.
// Anything else (build artifacts, IDE files, language ecosystems) is the user's
// responsibility and is expected to come from a seed.
const minimalGitignore = `.ctask/
logs/sessions.log
`
// EnsureGitignore creates a minimal .gitignore in wsDir if (and only if) one
// does not already exist. If the file already exists (because the seed provided
// it, or because the user created it), it is left untouched.
//
// This is the file-system half of the v0.3 seed-wins rule for .gitignore.
// The caller is responsible for invoking this AFTER the seed copy step.
func EnsureGitignore(wsDir string) error {
path := filepath.Join(wsDir, ".gitignore")
if _, err := os.Stat(path); err == nil {
return nil // already present (e.g. from a seed) -- do not overwrite
} else if !os.IsNotExist(err) {
return err
}
return os.WriteFile(path, []byte(minimalGitignore), 0644)
}
// GitAvailable reports whether the `git` executable is on PATH.
func GitAvailable() bool {
_, err := exec.LookPath("git")
return err == nil
}
// RunGitInit runs `git init` in wsDir. Returns nil if git is unavailable
// (callers are responsible for printing the informational note in that case
// based on GitAvailable). Returns the error from git itself if init fails.
func RunGitInit(wsDir string) error {
if !GitAvailable() {
return nil
}
cmd := exec.Command("git", "init")
cmd.Dir = wsDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git init failed: %w (output: %s)", err, string(out))
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package workspace
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureGitignoreCreatesWhenMissing(t *testing.T) {
dir := t.TempDir()
if err := EnsureGitignore(dir); err != nil {
t.Fatalf("EnsureGitignore: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
if err != nil {
t.Fatalf("read .gitignore: %v", err)
}
content := string(body)
for _, want := range []string{".ctask/", "logs/sessions.log"} {
if !strings.Contains(content, want) {
t.Errorf(".gitignore missing %q", want)
}
}
}
func TestEnsureGitignorePreservesExisting(t *testing.T) {
dir := t.TempDir()
original := "# user gitignore\nnode_modules/\n"
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(original), 0644); err != nil {
t.Fatalf("write seed gitignore: %v", err)
}
if err := EnsureGitignore(dir); err != nil {
t.Fatalf("EnsureGitignore: %v", err)
}
body, _ := os.ReadFile(filepath.Join(dir, ".gitignore"))
if string(body) != original {
t.Errorf(".gitignore should be preserved verbatim, got %q", string(body))
}
}
// Integration: a .gitignore placed by the GENERAL seed survives the full
// Create + EnsureGitignore flow for a project workspace.
func TestCreateProjectPreservesGeneralSeedGitignore(t *testing.T) {
root := t.TempDir()
general := t.TempDir()
original := "# general seed\nbuild/\n"
if err := os.WriteFile(filepath.Join(general, ".gitignore"), []byte(original), 0644); err != nil {
t.Fatalf("write general .gitignore: %v", err)
}
ws, err := Create(CreateOpts{
Root: root,
Title: "gen gi proj",
Category: "projects",
Mode: "local",
Agent: "claude",
IsProject: true,
SeedDir: general,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := EnsureGitignore(ws.Path); err != nil {
t.Fatalf("EnsureGitignore: %v", err)
}
body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore"))
if string(body) != original {
t.Errorf("general seed .gitignore not preserved: got %q", string(body))
}
}
// Integration: a .gitignore placed by the PROJECT seed survives the full
// Create + EnsureGitignore flow.
func TestCreateProjectPreservesProjectSeedGitignore(t *testing.T) {
root := t.TempDir()
project := t.TempDir()
original := "# project seed\ndist/\n"
if err := os.WriteFile(filepath.Join(project, ".gitignore"), []byte(original), 0644); err != nil {
t.Fatalf("write project .gitignore: %v", err)
}
ws, err := Create(CreateOpts{
Root: root,
Title: "proj gi proj",
Category: "projects",
Mode: "local",
Agent: "claude",
IsProject: true,
ProjectSeedDir: project,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := EnsureGitignore(ws.Path); err != nil {
t.Fatalf("EnsureGitignore: %v", err)
}
body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore"))
if string(body) != original {
t.Errorf("project seed .gitignore not preserved: got %q", string(body))
}
}
// Integration: with no seed-provided .gitignore, EnsureGitignore creates the
// minimal one.
func TestCreateProjectCreatesMinimalGitignoreWhenNoSeed(t *testing.T) {
root := t.TempDir()
ws, err := Create(CreateOpts{
Root: root,
Title: "no seed gi",
Category: "projects",
Mode: "local",
Agent: "claude",
IsProject: true,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := EnsureGitignore(ws.Path); err != nil {
t.Fatalf("EnsureGitignore: %v", err)
}
body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore"))
content := string(body)
for _, want := range []string{".ctask/", "logs/sessions.log"} {
if !strings.Contains(content, want) {
t.Errorf("minimal .gitignore missing %q, got: %q", want, content)
}
}
}