From 4799cb086f91e027a373aa06a8dcd443b1c29b2e Mon Sep 17 00:00:00 2001 From: mayor Date: Tue, 6 Jan 2026 01:27:21 -0800 Subject: [PATCH] Add sparse checkout to exclude source repo .claude/ directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When cloning or creating worktrees from repos that have their own .claude/ directory, those settings would override Gas Town's agent settings. This adds sparse checkout configuration to automatically exclude .claude/ from all clones and worktrees. Changes: - Add ConfigureSparseCheckout() to git.go, called from all Clone/WorktreeAdd methods - Add IsSparseCheckoutConfigured() to detect if sparse checkout is properly set up - Add doctor check to verify sparse checkout config (checks config, not symptoms) - Doctor --fix will configure sparse checkout for repos missing it 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/doctor/rig_check.go | 1 + internal/doctor/sparse_checkout_check.go | 119 ++++++ internal/doctor/sparse_checkout_check_test.go | 347 ++++++++++++++++++ internal/git/git.go | 128 ++++++- 4 files changed, 583 insertions(+), 12 deletions(-) create mode 100644 internal/doctor/sparse_checkout_check.go create mode 100644 internal/doctor/sparse_checkout_check_test.go diff --git a/internal/doctor/rig_check.go b/internal/doctor/rig_check.go index 021267f2..04f88bca 100644 --- a/internal/doctor/rig_check.go +++ b/internal/doctor/rig_check.go @@ -871,6 +871,7 @@ func RigChecks() []Check { NewRigIsGitRepoCheck(), NewGitExcludeConfiguredCheck(), NewHooksPathConfiguredCheck(), + NewSparseCheckoutCheck(), NewWitnessExistsCheck(), NewRefineryExistsCheck(), NewMayorCloneExistsCheck(), diff --git a/internal/doctor/sparse_checkout_check.go b/internal/doctor/sparse_checkout_check.go new file mode 100644 index 00000000..21a79fdb --- /dev/null +++ b/internal/doctor/sparse_checkout_check.go @@ -0,0 +1,119 @@ +package doctor + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/steveyegge/gastown/internal/git" +) + +// SparseCheckoutCheck verifies that git clones/worktrees have sparse checkout configured +// to exclude .claude/ from source repos. This ensures source repo settings don't override +// Gas Town agent settings. +type SparseCheckoutCheck struct { + FixableCheck + rigPath string + affectedRepos []string // repos missing sparse checkout configuration +} + +// NewSparseCheckoutCheck creates a new sparse checkout check. +func NewSparseCheckoutCheck() *SparseCheckoutCheck { + return &SparseCheckoutCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "sparse-checkout", + CheckDescription: "Verify sparse checkout is configured to exclude .claude/", + }, + }, + } +} + +// Run checks if sparse checkout is configured for all git repos in the rig. +func (c *SparseCheckoutCheck) Run(ctx *CheckContext) *CheckResult { + c.rigPath = ctx.RigPath() + if c.rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + c.affectedRepos = nil + + // Check all git repo locations + repoPaths := []string{ + filepath.Join(c.rigPath, "mayor", "rig"), + filepath.Join(c.rigPath, "refinery", "rig"), + } + + // Add crew clones + crewDir := filepath.Join(c.rigPath, "crew") + if entries, err := os.ReadDir(crewDir); err == nil { + for _, entry := range entries { + if entry.IsDir() && entry.Name() != "README.md" { + repoPaths = append(repoPaths, filepath.Join(crewDir, entry.Name())) + } + } + } + + // Add polecat worktrees + polecatDir := filepath.Join(c.rigPath, "polecats") + if entries, err := os.ReadDir(polecatDir); err == nil { + for _, entry := range entries { + if entry.IsDir() { + repoPaths = append(repoPaths, filepath.Join(polecatDir, entry.Name())) + } + } + } + + for _, repoPath := range repoPaths { + // Skip if not a git repo + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + continue + } + + // Check if sparse checkout is configured (not just if .claude/ exists) + if !git.IsSparseCheckoutConfigured(repoPath) { + c.affectedRepos = append(c.affectedRepos, repoPath) + } + } + + if len(c.affectedRepos) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "All repos have sparse checkout configured to exclude .claude/", + } + } + + // Build details with relative paths + var details []string + for _, repoPath := range c.affectedRepos { + relPath, _ := filepath.Rel(c.rigPath, repoPath) + if relPath == "" { + relPath = repoPath + } + details = append(details, relPath) + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("%d repo(s) missing sparse checkout configuration", len(c.affectedRepos)), + Details: details, + FixHint: "Run 'gt doctor --fix' to configure sparse checkout", + } +} + +// Fix configures sparse checkout for affected repos to exclude .claude/. +func (c *SparseCheckoutCheck) Fix(ctx *CheckContext) error { + for _, repoPath := range c.affectedRepos { + if err := git.ConfigureSparseCheckout(repoPath); err != nil { + relPath, _ := filepath.Rel(c.rigPath, repoPath) + return fmt.Errorf("failed to configure sparse checkout for %s: %w", relPath, err) + } + } + return nil +} diff --git a/internal/doctor/sparse_checkout_check_test.go b/internal/doctor/sparse_checkout_check_test.go new file mode 100644 index 00000000..db2498f7 --- /dev/null +++ b/internal/doctor/sparse_checkout_check_test.go @@ -0,0 +1,347 @@ +package doctor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/steveyegge/gastown/internal/git" +) + +func TestNewSparseCheckoutCheck(t *testing.T) { + check := NewSparseCheckoutCheck() + + if check.Name() != "sparse-checkout" { + t.Errorf("expected name 'sparse-checkout', got %q", check.Name()) + } + + if !check.CanFix() { + t.Error("expected CanFix to return true") + } +} + +func TestSparseCheckoutCheck_NoRigSpecified(t *testing.T) { + tmpDir := t.TempDir() + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: ""} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError when no rig specified, got %v", result.Status) + } + if !strings.Contains(result.Message, "No rig specified") { + t.Errorf("expected message about no rig, got %q", result.Message) + } +} + +func TestSparseCheckoutCheck_NoGitRepos(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatal(err) + } + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + // No git repos found = StatusOK (nothing to check) + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no git repos, got %v", result.Status) + } +} + +// initGitRepo creates a minimal git repo with an initial commit. +func initGitRepo(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + + // git init + cmd := exec.Command("git", "init") + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init failed: %v\n%s", err, out) + } + + // Configure user for commits + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config email failed: %v\n%s", err, out) + } + cmd = exec.Command("git", "config", "user.name", "Test") + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config name failed: %v\n%s", err, out) + } + + // Create initial commit + readmePath := filepath.Join(path, "README.md") + if err := os.WriteFile(readmePath, []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %v\n%s", err, out) + } + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %v\n%s", err, out) + } +} + +func TestSparseCheckoutCheck_MayorRigMissingSparseCheckout(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create mayor/rig as a git repo without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) + } + if !strings.Contains(result.Message, "1 repo(s) missing") { + t.Errorf("expected message about missing config, got %q", result.Message) + } + if len(result.Details) != 1 || !strings.Contains(result.Details[0], "mayor/rig") { + t.Errorf("expected details to contain mayor/rig, got %v", result.Details) + } +} + +func TestSparseCheckoutCheck_MayorRigConfigured(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create mayor/rig as a git repo with sparse checkout configured + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + if err := git.ConfigureSparseCheckout(mayorRig); err != nil { + t.Fatalf("ConfigureSparseCheckout failed: %v", err) + } + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK when sparse checkout configured, got %v", result.Status) + } +} + +func TestSparseCheckoutCheck_CrewMissingSparseCheckout(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create crew/agent1 as a git repo without sparse checkout + crewAgent := filepath.Join(rigDir, "crew", "agent1") + initGitRepo(t, crewAgent) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) + } + if len(result.Details) != 1 || !strings.Contains(result.Details[0], "crew/agent1") { + t.Errorf("expected details to contain crew/agent1, got %v", result.Details) + } +} + +func TestSparseCheckoutCheck_PolecatMissingSparseCheckout(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create polecats/pc1 as a git repo without sparse checkout + polecat := filepath.Join(rigDir, "polecats", "pc1") + initGitRepo(t, polecat) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) + } + if len(result.Details) != 1 || !strings.Contains(result.Details[0], "polecats/pc1") { + t.Errorf("expected details to contain polecats/pc1, got %v", result.Details) + } +} + +func TestSparseCheckoutCheck_MultipleReposMissing(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create multiple git repos without sparse checkout + initGitRepo(t, filepath.Join(rigDir, "mayor", "rig")) + initGitRepo(t, filepath.Join(rigDir, "crew", "agent1")) + initGitRepo(t, filepath.Join(rigDir, "polecats", "pc1")) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) + } + if !strings.Contains(result.Message, "3 repo(s) missing") { + t.Errorf("expected message about 3 missing repos, got %q", result.Message) + } + if len(result.Details) != 3 { + t.Errorf("expected 3 details, got %d", len(result.Details)) + } +} + +func TestSparseCheckoutCheck_MixedConfigured(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create mayor/rig with sparse checkout configured + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + if err := git.ConfigureSparseCheckout(mayorRig); err != nil { + t.Fatalf("ConfigureSparseCheckout failed: %v", err) + } + + // Create crew/agent1 WITHOUT sparse checkout + crewAgent := filepath.Join(rigDir, "crew", "agent1") + initGitRepo(t, crewAgent) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) + } + if !strings.Contains(result.Message, "1 repo(s) missing") { + t.Errorf("expected message about 1 missing repo, got %q", result.Message) + } + if len(result.Details) != 1 || !strings.Contains(result.Details[0], "crew/agent1") { + t.Errorf("expected details to contain only crew/agent1, got %v", result.Details) + } +} + +func TestSparseCheckoutCheck_Fix(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repos without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + crewAgent := filepath.Join(rigDir, "crew", "agent1") + initGitRepo(t, crewAgent) + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Verify fix is needed + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify sparse checkout is now configured + if !git.IsSparseCheckoutConfigured(mayorRig) { + t.Error("expected sparse checkout to be configured for mayor/rig") + } + if !git.IsSparseCheckoutConfigured(crewAgent) { + t.Error("expected sparse checkout to be configured for crew/agent1") + } + + // Verify check now passes + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestSparseCheckoutCheck_FixNoOp(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo with sparse checkout already configured + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + if err := git.ConfigureSparseCheckout(mayorRig); err != nil { + t.Fatalf("ConfigureSparseCheckout failed: %v", err) + } + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Run check to populate state + result := check.Run(ctx) + if result.Status != StatusOK { + t.Fatalf("expected StatusOK, got %v", result.Status) + } + + // Fix should be a no-op (no affected repos) + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Still OK + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after no-op fix, got %v", result.Status) + } +} + +func TestSparseCheckoutCheck_NonGitDirSkipped(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create non-git directories (should be skipped) + if err := os.MkdirAll(filepath.Join(rigDir, "mayor", "rig"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(rigDir, "crew", "agent1"), 0755); err != nil { + t.Fatal(err) + } + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + // Non-git dirs are skipped, so StatusOK + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no git repos, got %v", result.Status) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index dd630299..5501cf85 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -105,7 +105,11 @@ func (g *Git) Clone(url, dest string) error { return g.wrapError(err, stderr.String(), []string{"clone", url}) } // Configure hooks path for Gas Town clones - return configureHooksPath(dest) + if err := configureHooksPath(dest); err != nil { + return err + } + // Configure sparse checkout to exclude .claude/ from source repo + return ConfigureSparseCheckout(dest) } // CloneWithReference clones a repository using a local repo as an object reference. @@ -118,7 +122,11 @@ func (g *Git) CloneWithReference(url, dest, reference string) error { return g.wrapError(err, stderr.String(), []string{"clone", "--reference-if-able", url}) } // Configure hooks path for Gas Town clones - return configureHooksPath(dest) + if err := configureHooksPath(dest); err != nil { + return err + } + // Configure sparse checkout to exclude .claude/ from source repo + return ConfigureSparseCheckout(dest) } // CloneBare clones a repository as a bare repo (no working directory). @@ -553,35 +561,131 @@ func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) { // WorktreeAdd creates a new worktree at the given path with a new branch. // The new branch is created from the current HEAD. +// Sparse checkout is enabled to exclude .claude/ from source repos. func (g *Git) WorktreeAdd(path, branch string) error { - _, err := g.run("worktree", "add", "-b", branch, path) - return err + if _, err := g.run("worktree", "add", "-b", branch, path); err != nil { + return err + } + return ConfigureSparseCheckout(path) } // WorktreeAddFromRef creates a new worktree at the given path with a new branch // starting from the specified ref (e.g., "origin/main"). +// Sparse checkout is enabled to exclude .claude/ from source repos. func (g *Git) WorktreeAddFromRef(path, branch, startPoint string) error { - _, err := g.run("worktree", "add", "-b", branch, path, startPoint) - return err + if _, err := g.run("worktree", "add", "-b", branch, path, startPoint); err != nil { + return err + } + return ConfigureSparseCheckout(path) } // WorktreeAddDetached creates a new worktree at the given path with a detached HEAD. +// Sparse checkout is enabled to exclude .claude/ from source repos. func (g *Git) WorktreeAddDetached(path, ref string) error { - _, err := g.run("worktree", "add", "--detach", path, ref) - return err + if _, err := g.run("worktree", "add", "--detach", path, ref); err != nil { + return err + } + return ConfigureSparseCheckout(path) } // WorktreeAddExisting creates a new worktree at the given path for an existing branch. +// Sparse checkout is enabled to exclude .claude/ from source repos. func (g *Git) WorktreeAddExisting(path, branch string) error { - _, err := g.run("worktree", "add", path, branch) - return err + if _, err := g.run("worktree", "add", path, branch); err != nil { + return err + } + return ConfigureSparseCheckout(path) } // WorktreeAddExistingForce creates a new worktree even if the branch is already checked out elsewhere. // This is useful for cross-rig worktrees where multiple clones need to be on main. +// Sparse checkout is enabled to exclude .claude/ from source repos. func (g *Git) WorktreeAddExistingForce(path, branch string) error { - _, err := g.run("worktree", "add", "--force", path, branch) - return err + if _, err := g.run("worktree", "add", "--force", path, branch); err != nil { + return err + } + return ConfigureSparseCheckout(path) +} + +// ConfigureSparseCheckout sets up sparse checkout for a clone or worktree to exclude .claude/. +// This ensures source repo settings don't override Gas Town agent settings. +// Exported for use by doctor checks. +func ConfigureSparseCheckout(repoPath string) error { + // Enable sparse checkout + cmd := exec.Command("git", "-C", repoPath, "config", "core.sparseCheckout", "true") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("enabling sparse checkout: %s", strings.TrimSpace(stderr.String())) + } + + // Get git dir for this repo/worktree + cmd = exec.Command("git", "-C", repoPath, "rev-parse", "--git-dir") + var stdout bytes.Buffer + cmd.Stdout = &stdout + stderr.Reset() + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("getting git dir: %s", strings.TrimSpace(stderr.String())) + } + gitDir := strings.TrimSpace(stdout.String()) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + + // Write patterns directly to sparse-checkout file + // (git sparse-checkout set --stdin escapes the ! character incorrectly) + infoDir := filepath.Join(gitDir, "info") + if err := os.MkdirAll(infoDir, 0755); err != nil { + return fmt.Errorf("creating info dir: %w", err) + } + sparseFile := filepath.Join(infoDir, "sparse-checkout") + if err := os.WriteFile(sparseFile, []byte("/*\n!.claude/\n"), 0644); err != nil { + return fmt.Errorf("writing sparse-checkout: %w", err) + } + + // Reapply to remove excluded files + cmd = exec.Command("git", "-C", repoPath, "read-tree", "-mu", "HEAD") + stderr.Reset() + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("applying sparse checkout: %s", strings.TrimSpace(stderr.String())) + } + return nil +} + +// IsSparseCheckoutConfigured checks if sparse checkout is enabled and configured +// to exclude .claude/ for a given repo/worktree. +// Returns true only if both core.sparseCheckout is true AND the sparse-checkout +// file contains the !.claude/ exclusion pattern. +func IsSparseCheckoutConfigured(repoPath string) bool { + // Check if core.sparseCheckout is true + cmd := exec.Command("git", "-C", repoPath, "config", "core.sparseCheckout") + output, err := cmd.Output() + if err != nil || strings.TrimSpace(string(output)) != "true" { + return false + } + + // Get git dir for this repo/worktree + cmd = exec.Command("git", "-C", repoPath, "rev-parse", "--git-dir") + output, err = cmd.Output() + if err != nil { + return false + } + gitDir := strings.TrimSpace(string(output)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + + // Check if sparse-checkout file exists and excludes .claude/ + sparseFile := filepath.Join(gitDir, "info", "sparse-checkout") + content, err := os.ReadFile(sparseFile) + if err != nil { + return false + } + + // Check for our exclusion pattern + return strings.Contains(string(content), "!.claude/") } // WorktreeRemove removes a worktree.