Add sparse checkout to exclude source repo .claude/ directories

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 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-06 01:27:21 -08:00
committed by julianknutsen
parent 03fef16748
commit 4799cb086f
4 changed files with 583 additions and 12 deletions

View File

@@ -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.