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:
@@ -871,6 +871,7 @@ func RigChecks() []Check {
|
||||
NewRigIsGitRepoCheck(),
|
||||
NewGitExcludeConfiguredCheck(),
|
||||
NewHooksPathConfiguredCheck(),
|
||||
NewSparseCheckoutCheck(),
|
||||
NewWitnessExistsCheck(),
|
||||
NewRefineryExistsCheck(),
|
||||
NewMayorCloneExistsCheck(),
|
||||
|
||||
119
internal/doctor/sparse_checkout_check.go
Normal file
119
internal/doctor/sparse_checkout_check.go
Normal file
@@ -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
|
||||
}
|
||||
347
internal/doctor/sparse_checkout_check_test.go
Normal file
347
internal/doctor/sparse_checkout_check_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user