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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user