From 6519582de6217d1fa9bcc12902af18829872f71c Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 14:41:14 -0400 Subject: [PATCH] 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 --- internal/workspace/git.go | 56 ++++++++++++++ internal/workspace/git_test.go | 135 +++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/workspace/git.go create mode 100644 internal/workspace/git_test.go diff --git a/internal/workspace/git.go b/internal/workspace/git.go new file mode 100644 index 0000000..dade819 --- /dev/null +++ b/internal/workspace/git.go @@ -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 +} diff --git a/internal/workspace/git_test.go b/internal/workspace/git_test.go new file mode 100644 index 0000000..41730a3 --- /dev/null +++ b/internal/workspace/git_test.go @@ -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) + } + } +}