From 81a7d042397d6c088a4a8c26fe3256369c8d87dd Mon Sep 17 00:00:00 2001 From: julianknutsen Date: Tue, 6 Jan 2026 15:05:19 -0800 Subject: [PATCH] Add sparse checkout to exclude Claude context files from source repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excludes all Claude Code context files to prevent source repo instructions from interfering with Gas Town agent configuration: - .claude/ : settings, rules, agents, commands - CLAUDE.md : primary context file - CLAUDE.local.md: personal context file - .mcp.json : MCP server configuration Legacy configurations (only excluding .claude/) are detected and upgraded by gt doctor --fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/doctor/sparse_checkout_check.go | 21 +- internal/doctor/sparse_checkout_check_test.go | 306 ++++++++++++++++++ internal/git/git.go | 54 +++- 3 files changed, 370 insertions(+), 11 deletions(-) diff --git a/internal/doctor/sparse_checkout_check.go b/internal/doctor/sparse_checkout_check.go index 21a79fdb..fcf85a3d 100644 --- a/internal/doctor/sparse_checkout_check.go +++ b/internal/doctor/sparse_checkout_check.go @@ -4,13 +4,15 @@ import ( "fmt" "os" "path/filepath" + "strings" "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. +// to exclude Claude Code context files from source repos. This ensures source repo settings +// and instructions don't override Gas Town agent configuration. +// Excluded files: .claude/, CLAUDE.md, CLAUDE.local.md, .mcp.json type SparseCheckoutCheck struct { FixableCheck rigPath string @@ -23,7 +25,7 @@ func NewSparseCheckoutCheck() *SparseCheckoutCheck { FixableCheck: FixableCheck{ BaseCheck: BaseCheck{ CheckName: "sparse-checkout", - CheckDescription: "Verify sparse checkout is configured to exclude .claude/", + CheckDescription: "Verify sparse checkout excludes Claude context files (.claude/, CLAUDE.md, etc.)", }, }, } @@ -84,7 +86,7 @@ func (c *SparseCheckoutCheck) Run(ctx *CheckContext) *CheckResult { return &CheckResult{ Name: c.Name(), Status: StatusOK, - Message: "All repos have sparse checkout configured to exclude .claude/", + Message: "All repos have sparse checkout configured to exclude Claude context files", } } @@ -107,13 +109,22 @@ func (c *SparseCheckoutCheck) Run(ctx *CheckContext) *CheckResult { } } -// Fix configures sparse checkout for affected repos to exclude .claude/. +// Fix configures sparse checkout for affected repos to exclude Claude context files. 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) } + + // Check if any excluded files remain (untracked or modified files won't be removed by git read-tree) + if remaining := git.CheckExcludedFilesExist(repoPath); len(remaining) > 0 { + relPath, _ := filepath.Rel(c.rigPath, repoPath) + return fmt.Errorf("sparse checkout configured for %s but these files still exist: %s\n"+ + "These files are untracked or modified and were not removed by git.\n"+ + "Please manually remove or revert these files in %s", + relPath, strings.Join(remaining, ", "), repoPath) + } } return nil } diff --git a/internal/doctor/sparse_checkout_check_test.go b/internal/doctor/sparse_checkout_check_test.go index db2498f7..a98e232c 100644 --- a/internal/doctor/sparse_checkout_check_test.go +++ b/internal/doctor/sparse_checkout_check_test.go @@ -345,3 +345,309 @@ func TestSparseCheckoutCheck_NonGitDirSkipped(t *testing.T) { t.Errorf("expected StatusOK when no git repos, got %v", result.Status) } } + +func TestSparseCheckoutCheck_VerifiesAllPatterns(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Configure sparse checkout using our function + if err := git.ConfigureSparseCheckout(mayorRig); err != nil { + t.Fatalf("ConfigureSparseCheckout failed: %v", err) + } + + // Read the sparse-checkout file and verify all patterns are present + sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout") + content, err := os.ReadFile(sparseFile) + if err != nil { + t.Fatalf("Failed to read sparse-checkout file: %v", err) + } + + contentStr := string(content) + + // Verify all required patterns are present + requiredPatterns := []string{ + "!/.claude/", // Settings, rules, agents, commands + "!/CLAUDE.md", // Primary context file + "!/CLAUDE.local.md", // Personal context file + "!/.mcp.json", // MCP server configuration + } + + for _, pattern := range requiredPatterns { + if !strings.Contains(contentStr, pattern) { + t.Errorf("sparse-checkout file missing pattern %q, got:\n%s", pattern, contentStr) + } + } +} + +func TestSparseCheckoutCheck_LegacyPatternNotSufficient(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Manually configure sparse checkout with only legacy .claude/ pattern (missing CLAUDE.md) + cmd := exec.Command("git", "config", "core.sparseCheckout", "true") + cmd.Dir = mayorRig + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config failed: %v\n%s", err, out) + } + + sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout") + if err := os.MkdirAll(filepath.Dir(sparseFile), 0755); err != nil { + t.Fatal(err) + } + // Only include legacy pattern, missing CLAUDE.md + if err := os.WriteFile(sparseFile, []byte("/*\n!.claude/\n"), 0644); err != nil { + t.Fatal(err) + } + + check := NewSparseCheckoutCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + // Should fail because CLAUDE.md pattern is missing + if result.Status != StatusError { + t.Errorf("expected StatusError for legacy-only pattern, got %v", result.Status) + } +} + +func TestSparseCheckoutCheck_FixUpgradesLegacyPatterns(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo with legacy sparse checkout (only .claude/) + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + cmd := exec.Command("git", "config", "core.sparseCheckout", "true") + cmd.Dir = mayorRig + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config failed: %v\n%s", err, out) + } + + sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout") + if err := os.MkdirAll(filepath.Dir(sparseFile), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sparseFile, []byte("/*\n!.claude/\n"), 0644); err != nil { + t.Fatal(err) + } + + 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 all patterns are now present + content, err := os.ReadFile(sparseFile) + if err != nil { + t.Fatalf("Failed to read sparse-checkout file: %v", err) + } + + contentStr := string(content) + requiredPatterns := []string{"!/.claude/", "!/CLAUDE.md", "!/CLAUDE.local.md", "!/.mcp.json"} + for _, pattern := range requiredPatterns { + if !strings.Contains(contentStr, pattern) { + t.Errorf("after fix, sparse-checkout file missing pattern %q", pattern) + } + } + + // Verify check now passes + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestSparseCheckoutCheck_FixFailsWithUntrackedCLAUDEMD(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Create untracked CLAUDE.md (not added to git) + claudeFile := filepath.Join(mayorRig, "CLAUDE.md") + if err := os.WriteFile(claudeFile, []byte("# Untracked context\n"), 0644); err != nil { + t.Fatal(err) + } + + 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) + } + + // Fix should fail because CLAUDE.md is untracked and won't be removed + err := check.Fix(ctx) + if err == nil { + t.Fatal("expected Fix to return error for untracked CLAUDE.md, but it succeeded") + } + + // Verify error message is helpful + if !strings.Contains(err.Error(), "CLAUDE.md") { + t.Errorf("expected error to mention CLAUDE.md, got: %v", err) + } + if !strings.Contains(err.Error(), "untracked or modified") { + t.Errorf("expected error to explain files are untracked/modified, got: %v", err) + } + if !strings.Contains(err.Error(), "manually remove") { + t.Errorf("expected error to mention manual removal, got: %v", err) + } +} + +func TestSparseCheckoutCheck_FixFailsWithUntrackedClaudeDir(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Create untracked .claude/ directory (not added to git) + claudeDir := filepath.Join(mayorRig, ".claude") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + 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) + } + + // Fix should fail because .claude/ is untracked and won't be removed + err := check.Fix(ctx) + if err == nil { + t.Fatal("expected Fix to return error for untracked .claude/, but it succeeded") + } + + // Verify error message mentions .claude + if !strings.Contains(err.Error(), ".claude") { + t.Errorf("expected error to mention .claude, got: %v", err) + } +} + +func TestSparseCheckoutCheck_FixFailsWithModifiedCLAUDEMD(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Add and commit CLAUDE.md to the repo + claudeFile := filepath.Join(mayorRig, "CLAUDE.md") + if err := os.WriteFile(claudeFile, []byte("# Original context\n"), 0644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "add", "CLAUDE.md") + cmd.Dir = mayorRig + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add failed: %v\n%s", err, out) + } + cmd = exec.Command("git", "commit", "-m", "Add CLAUDE.md") + cmd.Dir = mayorRig + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %v\n%s", err, out) + } + + // Now modify CLAUDE.md without committing (making it "dirty") + if err := os.WriteFile(claudeFile, []byte("# Modified context - local changes\n"), 0644); err != nil { + t.Fatal(err) + } + + 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) + } + + // Fix should fail because CLAUDE.md is modified and git won't remove it + err := check.Fix(ctx) + if err == nil { + t.Fatal("expected Fix to return error for modified CLAUDE.md, but it succeeded") + } + + // Verify error message is helpful + if !strings.Contains(err.Error(), "CLAUDE.md") { + t.Errorf("expected error to mention CLAUDE.md, got: %v", err) + } +} + +func TestSparseCheckoutCheck_FixFailsWithMultipleProblems(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create git repo without sparse checkout + mayorRig := filepath.Join(rigDir, "mayor", "rig") + initGitRepo(t, mayorRig) + + // Create multiple untracked context files + if err := os.WriteFile(filepath.Join(mayorRig, "CLAUDE.md"), []byte("# Context\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mayorRig, ".mcp.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + 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) + } + + // Fix should fail and list multiple files + err := check.Fix(ctx) + if err == nil { + t.Fatal("expected Fix to return error for multiple untracked files, but it succeeded") + } + + // Verify error mentions both files + errStr := err.Error() + if !strings.Contains(errStr, "CLAUDE.md") { + t.Errorf("expected error to mention CLAUDE.md, got: %v", err) + } + if !strings.Contains(errStr, ".mcp.json") { + t.Errorf("expected error to mention .mcp.json, got: %v", err) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 64bf98c2..544918e7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -635,12 +635,19 @@ func ConfigureSparseCheckout(repoPath string) error { // Write patterns directly to sparse-checkout file // (git sparse-checkout set --stdin escapes the ! character incorrectly) + // Exclude all Claude Code context files to prevent source repo instructions + // from interfering with Gas Town agent context: + // - .claude/ : settings, rules, agents, commands + // - CLAUDE.md : primary context file + // - CLAUDE.local.md : personal context file + // - .mcp.json : MCP server configuration 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 { + sparsePatterns := "/*\n!/.claude/\n!/CLAUDE.md\n!/CLAUDE.local.md\n!/.mcp.json\n" + if err := os.WriteFile(sparseFile, []byte(sparsePatterns), 0644); err != nil { return fmt.Errorf("writing sparse-checkout: %w", err) } @@ -662,10 +669,33 @@ func ConfigureSparseCheckout(repoPath string) error { return nil } +// ExcludedContextFiles lists all Claude context files that should be excluded by sparse checkout. +var ExcludedContextFiles = []string{ + ".claude", + "CLAUDE.md", + "CLAUDE.local.md", + ".mcp.json", +} + +// CheckExcludedFilesExist checks if any Claude context files still exist in the repo +// after sparse checkout was configured. These files should have been removed by +// git read-tree, but may remain if they were untracked or modified. +// Returns a list of files that still exist and should be manually removed. +func CheckExcludedFilesExist(repoPath string) []string { + var remaining []string + for _, file := range ExcludedContextFiles { + path := filepath.Join(repoPath, file) + if _, err := os.Stat(path); err == nil { + remaining = append(remaining, file) + } + } + return remaining +} + // IsSparseCheckoutConfigured checks if sparse checkout is enabled and configured -// to exclude .claude/ for a given repo/worktree. +// to exclude Claude Code context files for a given repo/worktree. // Returns true only if both core.sparseCheckout is true AND the sparse-checkout -// file contains the !.claude/ exclusion pattern. +// file contains all required exclusion patterns. func IsSparseCheckoutConfigured(repoPath string) bool { // Check if core.sparseCheckout is true cmd := exec.Command("git", "-C", repoPath, "config", "core.sparseCheckout") @@ -685,15 +715,27 @@ func IsSparseCheckoutConfigured(repoPath string) bool { gitDir = filepath.Join(repoPath, gitDir) } - // Check if sparse-checkout file exists and excludes .claude/ + // Check if sparse-checkout file exists and excludes Claude context files 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/") + // Check for all required exclusion patterns + contentStr := string(content) + requiredPatterns := []string{ + "!/.claude/", // or legacy "!.claude/" + "!/CLAUDE.md", // or legacy without leading slash + } + for _, pattern := range requiredPatterns { + // Accept both with and without leading slash for backwards compatibility + legacyPattern := strings.TrimPrefix(pattern, "/") + if !strings.Contains(contentStr, pattern) && !strings.Contains(contentStr, legacyPattern) { + return false + } + } + return true } // WorktreeRemove removes a worktree.