Files
gastown/internal/git/git.go
picard 4ed3e983f6
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Successful in 18s
CI / Test (push) Failing after 1m19s
CI / Lint (push) Successful in 18s
CI / Integration Tests (push) Successful in 1m2s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Enable parallel checkout for faster worktree creation
Add EnableParallelCheckout() helper to git package and call it when
creating bare repos. This configures checkout.workers=0 (auto-detect
cores) and checkout.thresholdForParallelism=100.

Expected 2-8x speedup on SSD for large repos (e.g., java rig with 85k files).
Based on research in hq-dqc7t (APFS CoW optimization).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:28:58 -08:00

1326 lines
41 KiB
Go

// Package git provides a wrapper for git operations via subprocess.
package git
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GitError contains raw output from a git command for agent observation.
// ZFC: Callers observe the raw output and decide what to do.
// The error interface methods provide human-readable messages, but agents
// should use Stdout/Stderr for programmatic observation.
type GitError struct {
Command string // The git command that failed (e.g., "merge", "push")
Args []string
Stdout string // Raw stdout output
Stderr string // Raw stderr output
Err error // Underlying error (e.g., exit code)
}
func (e *GitError) Error() string {
if e.Stderr != "" {
return fmt.Sprintf("git %s: %s", e.Command, e.Stderr)
}
return fmt.Sprintf("git %s: %v", e.Command, e.Err)
}
func (e *GitError) Unwrap() error {
return e.Err
}
// moveDir moves a directory from src to dest. It first tries os.Rename for
// efficiency, but falls back to copy+delete if src and dest are on different
// filesystems (which causes EXDEV error on rename).
func moveDir(src, dest string) error {
// Try rename first - works if same filesystem
if err := os.Rename(src, dest); err == nil {
return nil
}
// Rename failed, try copy+delete as fallback for cross-filesystem moves
if err := copyDir(src, dest); err != nil {
return fmt.Errorf("copying directory: %w", err)
}
if err := os.RemoveAll(src); err != nil {
return fmt.Errorf("removing source after copy: %w", err)
}
return nil
}
// copyDir recursively copies a directory from src to dest.
func copyDir(src, dest string) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dest, srcInfo.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, destPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, destPath); err != nil {
return err
}
}
}
return nil
}
// copyFile copies a single file from src to dest, preserving permissions.
func copyFile(src, dest string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
destFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
return err
}
// Git wraps git operations for a working directory.
type Git struct {
workDir string
gitDir string // Optional: explicit git directory (for bare repos)
}
// NewGit creates a new Git wrapper for the given directory.
func NewGit(workDir string) *Git {
return &Git{workDir: workDir}
}
// NewGitWithDir creates a Git wrapper with an explicit git directory.
// This is used for bare repos where gitDir points to the .git directory
// and workDir may be empty or point to a worktree.
func NewGitWithDir(gitDir, workDir string) *Git {
return &Git{gitDir: gitDir, workDir: workDir}
}
// WorkDir returns the working directory for this Git instance.
func (g *Git) WorkDir() string {
return g.workDir
}
// IsRepo returns true if the workDir is a git repository.
func (g *Git) IsRepo() bool {
_, err := g.run("rev-parse", "--git-dir")
return err == nil
}
// run executes a git command and returns stdout.
func (g *Git) run(args ...string) (string, error) {
// If gitDir is set (bare repo), prepend --git-dir flag
if g.gitDir != "" {
args = append([]string{"--git-dir=" + g.gitDir}, args...)
}
cmd := exec.Command("git", args...)
if g.workDir != "" {
cmd.Dir = g.workDir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return "", g.wrapError(err, stdout.String(), stderr.String(), args)
}
return strings.TrimSpace(stdout.String()), nil
}
// wrapError wraps git errors with context.
// ZFC: Returns GitError with raw output for agent observation.
// Does not detect or interpret error types - agents should observe and decide.
func (g *Git) wrapError(err error, stdout, stderr string, args []string) error {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
// Determine command name (first arg, or first non-flag arg)
command := ""
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
command = arg
break
}
}
if command == "" && len(args) > 0 {
command = args[0]
}
return &GitError{
Command: command,
Args: args,
Stdout: stdout,
Stderr: stderr,
Err: err,
}
}
// Clone clones a repository to the destination.
func (g *Git) Clone(url, dest string) error {
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", url})
}
// Move to final destination (handles cross-filesystem moves)
if err := moveDir(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure hooks path for Gas Town clones
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.
// This saves disk by sharing objects without changing remotes.
func (g *Git) CloneWithReference(url, dest, reference string) error {
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--reference-if-able", url})
}
// Move to final destination (handles cross-filesystem moves)
if err := moveDir(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure hooks path for Gas Town clones
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).
// This is used for the shared repo architecture where all worktrees share a single git database.
func (g *Git) CloneBare(url, dest string) error {
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--bare", url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", url})
}
// Move to final destination (handles cross-filesystem moves)
if err := moveDir(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure refspec so worktrees can fetch and see origin/* refs
return configureRefspec(dest)
}
// configureHooksPath sets core.hooksPath to use the repo's .githooks directory
// if it exists. This ensures Gas Town agents use the pre-push hook that blocks
// pushes to non-main branches (internal PRs are not allowed).
func configureHooksPath(repoPath string) error {
hooksDir := filepath.Join(repoPath, ".githooks")
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
// No .githooks directory, nothing to configure
return nil
}
cmd := exec.Command("git", "-C", repoPath, "config", "core.hooksPath", ".githooks")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("configuring hooks path: %s", strings.TrimSpace(stderr.String()))
}
return nil
}
// configureRefspec sets remote.origin.fetch to the standard refspec for bare repos.
// Bare clones don't have this set by default, which breaks worktrees that need to
// fetch and see origin/* refs. Without this, `git fetch` only updates FETCH_HEAD
// and origin/main never appears in refs/remotes/origin/main.
// See: https://github.com/anthropics/gastown/issues/286
func configureRefspec(repoPath string) error {
gitDir := repoPath
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
gitDir = filepath.Join(repoPath, ".git")
}
gitDir = filepath.Clean(gitDir)
var stderr bytes.Buffer
configCmd := exec.Command("git", "--git-dir", gitDir, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
configCmd.Stderr = &stderr
if err := configCmd.Run(); err != nil {
return fmt.Errorf("configuring refspec: %s", strings.TrimSpace(stderr.String()))
}
fetchCmd := exec.Command("git", "--git-dir", gitDir, "fetch", "origin")
fetchCmd.Stderr = &stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("fetching origin: %s", strings.TrimSpace(stderr.String()))
}
return nil
}
// CloneBareWithReference clones a bare repository using a local repo as an object reference.
func (g *Git) CloneBareWithReference(url, dest, reference string) error {
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", "--reference-if-able", url})
}
// Move to final destination (handles cross-filesystem moves)
if err := moveDir(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure refspec so worktrees can fetch and see origin/* refs
return configureRefspec(dest)
}
// Checkout checks out the given ref.
func (g *Git) Checkout(ref string) error {
_, err := g.run("checkout", ref)
return err
}
// Fetch fetches from the remote.
func (g *Git) Fetch(remote string) error {
_, err := g.run("fetch", remote)
return err
}
// FetchBranch fetches a specific branch from the remote.
func (g *Git) FetchBranch(remote, branch string) error {
_, err := g.run("fetch", remote, branch)
return err
}
// Pull pulls from the remote branch.
func (g *Git) Pull(remote, branch string) error {
_, err := g.run("pull", remote, branch)
return err
}
// Push pushes to the remote branch.
func (g *Git) Push(remote, branch string, force bool) error {
args := []string{"push", remote, branch}
if force {
args = append(args, "--force")
}
_, err := g.run(args...)
return err
}
// Add stages files for commit.
func (g *Git) Add(paths ...string) error {
args := append([]string{"add"}, paths...)
_, err := g.run(args...)
return err
}
// Commit creates a commit with the given message.
func (g *Git) Commit(message string) error {
_, err := g.run("commit", "-m", message)
return err
}
// CommitAll stages all changes and commits.
func (g *Git) CommitAll(message string) error {
_, err := g.run("commit", "-am", message)
return err
}
// GitStatus represents the status of the working directory.
type GitStatus struct {
Clean bool
Modified []string
Added []string
Deleted []string
Untracked []string
}
// Status returns the current git status.
func (g *Git) Status() (*GitStatus, error) {
out, err := g.run("status", "--porcelain")
if err != nil {
return nil, err
}
status := &GitStatus{Clean: true}
if out == "" {
return status, nil
}
status.Clean = false
for _, line := range strings.Split(out, "\n") {
if len(line) < 3 {
continue
}
code := line[:2]
file := line[3:]
switch {
case strings.Contains(code, "M"):
status.Modified = append(status.Modified, file)
case strings.Contains(code, "A"):
status.Added = append(status.Added, file)
case strings.Contains(code, "D"):
status.Deleted = append(status.Deleted, file)
case strings.Contains(code, "?"):
status.Untracked = append(status.Untracked, file)
}
}
return status, nil
}
// CurrentBranch returns the current branch name.
func (g *Git) CurrentBranch() (string, error) {
return g.run("rev-parse", "--abbrev-ref", "HEAD")
}
// DefaultBranch returns the default branch name (what HEAD points to).
// This works for both regular and bare repositories.
// Returns "main" as fallback if detection fails.
func (g *Git) DefaultBranch() string {
// Try symbolic-ref first (works for bare repos)
branch, err := g.run("symbolic-ref", "--short", "HEAD")
if err == nil && branch != "" {
return branch
}
// Fallback to main
return "main"
}
// RemoteDefaultBranch returns the default branch from the remote (origin).
// This is useful in worktrees where HEAD may not reflect the repo's actual default.
// Checks origin/HEAD first, then falls back to checking if master/main exists.
// Returns "main" as final fallback.
func (g *Git) RemoteDefaultBranch() string {
// Try to get from origin/HEAD symbolic ref
out, err := g.run("symbolic-ref", "refs/remotes/origin/HEAD")
if err == nil && out != "" {
// Returns refs/remotes/origin/main -> extract branch name
parts := strings.Split(out, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
}
// Fallback: check if origin/master exists
_, err = g.run("rev-parse", "--verify", "origin/master")
if err == nil {
return "master"
}
// Fallback: check if origin/main exists
_, err = g.run("rev-parse", "--verify", "origin/main")
if err == nil {
return "main"
}
return "main" // final fallback
}
// HasUncommittedChanges returns true if there are uncommitted changes.
func (g *Git) HasUncommittedChanges() (bool, error) {
status, err := g.Status()
if err != nil {
return false, err
}
return !status.Clean, nil
}
// RemoteURL returns the URL for the given remote.
func (g *Git) RemoteURL(remote string) (string, error) {
return g.run("remote", "get-url", remote)
}
// Remotes returns the list of configured remote names.
func (g *Git) Remotes() ([]string, error) {
out, err := g.run("remote")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// Merge merges the given branch into the current branch.
func (g *Git) Merge(branch string) error {
_, err := g.run("merge", branch)
return err
}
// MergeNoFF merges the given branch with --no-ff flag and a custom message.
func (g *Git) MergeNoFF(branch, message string) error {
_, err := g.run("merge", "--no-ff", "-m", message, branch)
return err
}
// DeleteRemoteBranch deletes a branch on the remote.
func (g *Git) DeleteRemoteBranch(remote, branch string) error {
_, err := g.run("push", remote, "--delete", branch)
return err
}
// Rebase rebases the current branch onto the given ref.
func (g *Git) Rebase(onto string) error {
_, err := g.run("rebase", onto)
return err
}
// AbortMerge aborts a merge in progress.
func (g *Git) AbortMerge() error {
_, err := g.run("merge", "--abort")
return err
}
// CheckConflicts performs a test merge to check if source can be merged into target
// without conflicts. Returns a list of conflicting files, or empty slice if clean.
// The merge is always aborted after checking - no actual changes are made.
//
// The caller must ensure the working directory is clean before calling this.
// After return, the working directory is restored to the target branch.
func (g *Git) CheckConflicts(source, target string) ([]string, error) {
// Checkout the target branch
if err := g.Checkout(target); err != nil {
return nil, fmt.Errorf("checkout target %s: %w", target, err)
}
// Attempt test merge with --no-commit --no-ff
// We need to capture both stdout and stderr to detect conflicts
_, mergeErr := g.runMergeCheck("merge", "--no-commit", "--no-ff", source)
if mergeErr != nil {
// ZFC: Use git's porcelain output to detect conflicts instead of parsing stderr.
// GetConflictingFiles() uses `git diff --diff-filter=U` which is the proper way.
conflicts, err := g.GetConflictingFiles()
if err == nil && len(conflicts) > 0 {
// Abort the test merge (best-effort cleanup)
_ = g.AbortMerge()
return conflicts, nil
}
// No unmerged files detected - this is some other merge error
_ = g.AbortMerge()
return nil, mergeErr
}
// Merge succeeded (no conflicts) - abort the test merge
// Use reset since --abort won't work on successful merge (best-effort cleanup)
_, _ = g.run("reset", "--hard", "HEAD")
return nil, nil
}
// runMergeCheck runs a git merge command and returns error info from both stdout and stderr.
// ZFC: Returns GitError with raw output for agent observation.
func (g *Git) runMergeCheck(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = g.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
// ZFC: Return raw output for observation, don't interpret CONFLICT
return "", g.wrapError(err, stdout.String(), stderr.String(), args)
}
return strings.TrimSpace(stdout.String()), nil
}
// GetConflictingFiles returns the list of files with merge conflicts.
// ZFC: Uses git's porcelain output (diff --diff-filter=U) instead of parsing stderr.
// This is the proper way to detect conflicts without violating ZFC.
func (g *Git) GetConflictingFiles() ([]string, error) {
// git diff --name-only --diff-filter=U shows unmerged files
out, err := g.run("diff", "--name-only", "--diff-filter=U")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
files := strings.Split(out, "\n")
// Filter out empty strings
var result []string
for _, f := range files {
if f != "" {
result = append(result, f)
}
}
return result, nil
}
// AbortRebase aborts a rebase in progress.
func (g *Git) AbortRebase() error {
_, err := g.run("rebase", "--abort")
return err
}
// CreateBranch creates a new branch.
func (g *Git) CreateBranch(name string) error {
_, err := g.run("branch", name)
return err
}
// CreateBranchFrom creates a new branch from a specific ref.
func (g *Git) CreateBranchFrom(name, ref string) error {
_, err := g.run("branch", name, ref)
return err
}
// BranchExists checks if a branch exists locally.
func (g *Git) BranchExists(name string) (bool, error) {
_, err := g.run("show-ref", "--verify", "--quiet", "refs/heads/"+name)
if err != nil {
// Exit code 1 means branch doesn't exist
if strings.Contains(err.Error(), "exit status 1") {
return false, nil
}
return false, err
}
return true, nil
}
// RemoteBranchExists checks if a branch exists on the remote.
func (g *Git) RemoteBranchExists(remote, branch string) (bool, error) {
_, err := g.run("ls-remote", "--heads", remote, branch)
if err != nil {
return false, err
}
// ls-remote returns empty if branch doesn't exist, need to check output
out, err := g.run("ls-remote", "--heads", remote, branch)
if err != nil {
return false, err
}
return out != "", nil
}
// DeleteBranch deletes a local branch.
func (g *Git) DeleteBranch(name string, force bool) error {
flag := "-d"
if force {
flag = "-D"
}
_, err := g.run("branch", flag, name)
return err
}
// ListBranches returns all local branches matching a pattern.
// Pattern uses git's pattern matching (e.g., "polecat/*" matches all polecat branches).
// Returns branch names without the refs/heads/ prefix.
func (g *Git) ListBranches(pattern string) ([]string, error) {
args := []string{"branch", "--list", "--format=%(refname:short)"}
if pattern != "" {
args = append(args, pattern)
}
out, err := g.run(args...)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// ResetBranch force-updates a branch to point to a ref.
// This is useful for resetting stale polecat branches to main.
func (g *Git) ResetBranch(name, ref string) error {
_, err := g.run("branch", "-f", name, ref)
return err
}
// Rev returns the commit hash for the given ref.
func (g *Git) Rev(ref string) (string, error) {
return g.run("rev-parse", ref)
}
// IsAncestor checks if ancestor is an ancestor of descendant.
func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) {
_, err := g.run("merge-base", "--is-ancestor", ancestor, descendant)
if err != nil {
// Exit code 1 means not an ancestor, not an error
if strings.Contains(err.Error(), "exit status 1") {
return false, nil
}
return false, err
}
return true, nil
}
// EnableParallelCheckout configures git to use parallel workers during checkout.
// This can significantly speed up worktree creation on SSDs (2-8x for large repos).
// Setting workers=0 auto-detects the number of CPU cores.
// Safe to call multiple times - git config is idempotent.
func (g *Git) EnableParallelCheckout() error {
// Auto-detect number of workers (0 = number of logical cores)
if _, err := g.run("config", "checkout.workers", "0"); err != nil {
return fmt.Errorf("setting checkout.workers: %w", err)
}
// Only parallelize when there are enough files to benefit
if _, err := g.run("config", "checkout.thresholdForParallelism", "100"); err != nil {
return fmt.Errorf("setting checkout.thresholdForParallelism: %w", err)
}
return nil
}
// 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 {
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 {
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 {
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 {
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 {
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)
// 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")
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)
}
// Check if HEAD exists (repo has commits) before running read-tree
// Empty repos (no commits) don't need read-tree and it would fail
checkHead := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", "HEAD")
if err := checkHead.Run(); err != nil {
// No commits yet, sparse checkout config is set up for future use
return nil
}
// 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
}
// 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 Code context files for a given repo/worktree.
// Returns true only if both core.sparseCheckout is true AND the sparse-checkout
// 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")
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 context files
sparseFile := filepath.Join(gitDir, "info", "sparse-checkout")
content, err := os.ReadFile(sparseFile)
if err != nil {
return false
}
// 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.
func (g *Git) WorktreeRemove(path string, force bool) error {
args := []string{"worktree", "remove", path}
if force {
args = append(args, "--force")
}
_, err := g.run(args...)
return err
}
// WorktreePrune removes worktree entries for deleted paths.
func (g *Git) WorktreePrune() error {
_, err := g.run("worktree", "prune")
return err
}
// Worktree represents a git worktree.
type Worktree struct {
Path string
Branch string
Commit string
}
// WorktreeList returns all worktrees for this repository.
func (g *Git) WorktreeList() ([]Worktree, error) {
out, err := g.run("worktree", "list", "--porcelain")
if err != nil {
return nil, err
}
var worktrees []Worktree
var current Worktree
for _, line := range strings.Split(out, "\n") {
if line == "" {
if current.Path != "" {
worktrees = append(worktrees, current)
current = Worktree{}
}
continue
}
switch {
case strings.HasPrefix(line, "worktree "):
current.Path = strings.TrimPrefix(line, "worktree ")
case strings.HasPrefix(line, "HEAD "):
current.Commit = strings.TrimPrefix(line, "HEAD ")
case strings.HasPrefix(line, "branch "):
current.Branch = strings.TrimPrefix(line, "branch refs/heads/")
}
}
// Don't forget the last one
if current.Path != "" {
worktrees = append(worktrees, current)
}
return worktrees, nil
}
// BranchCreatedDate returns the date when a branch was created.
// This uses the committer date of the first commit on the branch.
// Returns date in YYYY-MM-DD format.
func (g *Git) BranchCreatedDate(branch string) (string, error) {
// Get the date of the first commit on the branch that's not on main
// Use merge-base to find where the branch diverged from main
mergeBase, err := g.run("merge-base", "main", branch)
if err != nil {
// If merge-base fails, fall back to the branch tip's date
out, err := g.run("log", "-1", "--format=%cs", branch)
if err != nil {
return "", err
}
return out, nil
}
// Get the first commit after the merge base on this branch
out, err := g.run("log", "--format=%cs", "--reverse", mergeBase+".."+branch)
if err != nil {
return "", err
}
// Get the first line (first commit's date)
lines := strings.Split(out, "\n")
if len(lines) > 0 && lines[0] != "" {
return lines[0], nil
}
// If no commits after merge-base, the branch points to merge-base
// Return the merge-base commit date
out, err = g.run("log", "-1", "--format=%cs", mergeBase)
if err != nil {
return "", err
}
return out, nil
}
// CommitsAhead returns the number of commits that branch has ahead of base.
// For example, CommitsAhead("main", "feature") returns how many commits
// are on feature that are not on main.
func (g *Git) CommitsAhead(base, branch string) (int, error) {
out, err := g.run("rev-list", "--count", base+".."+branch)
if err != nil {
return 0, err
}
var count int
_, err = fmt.Sscanf(out, "%d", &count)
if err != nil {
return 0, fmt.Errorf("parsing commit count: %w", err)
}
return count, nil
}
// CountCommitsBehind returns the number of commits that HEAD is behind the given ref.
// For example, CountCommitsBehind("origin/main") returns how many commits
// are on origin/main that are not on the current HEAD.
func (g *Git) CountCommitsBehind(ref string) (int, error) {
out, err := g.run("rev-list", "--count", "HEAD.."+ref)
if err != nil {
return 0, err
}
var count int
_, err = fmt.Sscanf(out, "%d", &count)
if err != nil {
return 0, fmt.Errorf("parsing commit count: %w", err)
}
return count, nil
}
// StashCount returns the number of stashes in the repository.
func (g *Git) StashCount() (int, error) {
out, err := g.run("stash", "list")
if err != nil {
return 0, err
}
if out == "" {
return 0, nil
}
// Count lines in the stash list
lines := strings.Split(out, "\n")
count := 0
for _, line := range lines {
if line != "" {
count++
}
}
return count, nil
}
// UnpushedCommits returns the number of commits that are not pushed to the remote.
// It checks if the current branch has an upstream and counts commits ahead.
// Returns 0 if there is no upstream configured.
func (g *Git) UnpushedCommits() (int, error) {
// Get the upstream branch
upstream, err := g.run("rev-parse", "--abbrev-ref", "@{u}")
if err != nil {
// No upstream configured - this is common for polecat branches
// Check if we can compare against origin/main instead
// If we can't get any reference, return 0 (benefit of the doubt)
return 0, nil
}
// Count commits between upstream and HEAD
out, err := g.run("rev-list", "--count", upstream+"..HEAD")
if err != nil {
return 0, err
}
var count int
_, err = fmt.Sscanf(out, "%d", &count)
if err != nil {
return 0, fmt.Errorf("parsing unpushed count: %w", err)
}
return count, nil
}
// UncommittedWorkStatus contains information about uncommitted work in a repo.
type UncommittedWorkStatus struct {
HasUncommittedChanges bool
StashCount int
UnpushedCommits int
// Details for error messages
ModifiedFiles []string
UntrackedFiles []string
}
// Clean returns true if there is no uncommitted work.
func (s *UncommittedWorkStatus) Clean() bool {
return !s.HasUncommittedChanges && s.StashCount == 0 && s.UnpushedCommits == 0
}
// CleanExcludingBeads returns true if the only uncommitted changes are .beads/ files.
// This is useful for polecat stale detection where beads database files are synced
// across worktrees and shouldn't block cleanup.
func (s *UncommittedWorkStatus) CleanExcludingBeads() bool {
// Stashes and unpushed commits always count as uncommitted work
if s.StashCount > 0 || s.UnpushedCommits > 0 {
return false
}
// Check if all modified files are beads files
for _, f := range s.ModifiedFiles {
if !isBeadsPath(f) {
return false
}
}
// Check if all untracked files are beads files
for _, f := range s.UntrackedFiles {
if !isBeadsPath(f) {
return false
}
}
return true
}
// isBeadsPath returns true if the path is a .beads/ file.
func isBeadsPath(path string) bool {
return strings.Contains(path, ".beads/") || strings.Contains(path, ".beads\\")
}
// String returns a human-readable summary of uncommitted work.
func (s *UncommittedWorkStatus) String() string {
var issues []string
if s.HasUncommittedChanges {
issues = append(issues, fmt.Sprintf("%d uncommitted change(s)", len(s.ModifiedFiles)+len(s.UntrackedFiles)))
}
if s.StashCount > 0 {
issues = append(issues, fmt.Sprintf("%d stash(es)", s.StashCount))
}
if s.UnpushedCommits > 0 {
issues = append(issues, fmt.Sprintf("%d unpushed commit(s)", s.UnpushedCommits))
}
if len(issues) == 0 {
return "clean"
}
return strings.Join(issues, ", ")
}
// CheckUncommittedWork performs a comprehensive check for uncommitted work.
func (g *Git) CheckUncommittedWork() (*UncommittedWorkStatus, error) {
status := &UncommittedWorkStatus{}
// Check git status
gitStatus, err := g.Status()
if err != nil {
return nil, fmt.Errorf("checking git status: %w", err)
}
status.HasUncommittedChanges = !gitStatus.Clean
status.ModifiedFiles = append(gitStatus.Modified, gitStatus.Added...)
status.ModifiedFiles = append(status.ModifiedFiles, gitStatus.Deleted...)
status.UntrackedFiles = gitStatus.Untracked
// Check stashes
stashCount, err := g.StashCount()
if err != nil {
return nil, fmt.Errorf("checking stashes: %w", err)
}
status.StashCount = stashCount
// Check unpushed commits
unpushed, err := g.UnpushedCommits()
if err != nil {
return nil, fmt.Errorf("checking unpushed commits: %w", err)
}
status.UnpushedCommits = unpushed
return status, nil
}
// BranchPushedToRemote checks if a branch has been pushed to the remote.
// Returns (pushed bool, unpushedCount int, err).
// This handles polecat branches that don't have upstream tracking configured.
func (g *Git) BranchPushedToRemote(localBranch, remote string) (bool, int, error) {
remoteBranch := remote + "/" + localBranch
// First check if the remote branch exists
exists, err := g.RemoteBranchExists(remote, localBranch)
if err != nil {
return false, 0, fmt.Errorf("checking remote branch: %w", err)
}
if !exists {
// Remote branch doesn't exist - count commits since origin/main (or HEAD if that fails)
count, err := g.run("rev-list", "--count", "origin/main..HEAD")
if err != nil {
// Fallback: just count all commits on HEAD
count, err = g.run("rev-list", "--count", "HEAD")
if err != nil {
return false, 0, fmt.Errorf("counting commits: %w", err)
}
}
var n int
_, err = fmt.Sscanf(count, "%d", &n)
if err != nil {
return false, 0, fmt.Errorf("parsing commit count: %w", err)
}
// If there are any commits since main, branch is not pushed
return n == 0, n, nil
}
// Remote branch exists - fetch to ensure we have the local tracking ref
// This handles the case where we just pushed and origin/branch doesn't exist locally yet
_, fetchErr := g.run("fetch", remote, localBranch)
// In worktrees, the fetch may not update refs/remotes/origin/<branch> due to
// missing refspecs. If the remote ref doesn't exist locally, create it from FETCH_HEAD.
// See: gt-cehl8 (gt done fails in worktrees due to missing origin tracking ref)
remoteRef := "refs/remotes/" + remoteBranch
if _, err := g.run("rev-parse", "--verify", remoteRef); err != nil {
// Remote ref doesn't exist locally - update it from FETCH_HEAD if fetch succeeded.
// Best-effort: if this fails, the code below falls back to ls-remote.
if fetchErr == nil {
_, _ = g.run("update-ref", remoteRef, "FETCH_HEAD")
}
}
// Check if local is ahead
count, err := g.run("rev-list", "--count", remoteBranch+"..HEAD")
if err != nil {
// Fallback: If we can't use the tracking ref (possibly missing remote.origin.fetch),
// get the remote commit SHA directly via ls-remote and compare.
// See: gt-0eh3r (gt done fails in worktree with missing remote.origin.fetch config)
remoteSHA, lsErr := g.run("ls-remote", remote, "refs/heads/"+localBranch)
if lsErr != nil {
return false, 0, fmt.Errorf("counting unpushed commits: %w (fallback also failed: %v)", err, lsErr)
}
// Parse SHA from ls-remote output (format: "<sha>\trefs/heads/<branch>")
remoteSHA = strings.TrimSpace(remoteSHA)
if remoteSHA == "" {
return false, 0, fmt.Errorf("counting unpushed commits: %w (remote branch not found)", err)
}
parts := strings.Fields(remoteSHA)
if len(parts) == 0 {
return false, 0, fmt.Errorf("counting unpushed commits: %w (invalid ls-remote output)", err)
}
remoteSHA = parts[0]
// Count commits from remote SHA to HEAD
count, err = g.run("rev-list", "--count", remoteSHA+"..HEAD")
if err != nil {
return false, 0, fmt.Errorf("counting unpushed commits (fallback): %w", err)
}
}
var n int
_, err = fmt.Sscanf(count, "%d", &n)
if err != nil {
return false, 0, fmt.Errorf("parsing unpushed count: %w", err)
}
return n == 0, n, nil
}