Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Test (push) Has been cancelled
CI / Coverage Report (push) Has been cancelled
CI / Check embedded formulas (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Integration Tests (push) Has been cancelled
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Multiple gt commands call git rev-parse --show-toplevel, adding ~50ms each invocation. Results rarely change within a session, and multiple agents calling git concurrently contend on .git/index.lock. Add cached RepoRoot() and RepoRootFrom() functions to the git package and update all callers to use them. This ensures a single git subprocess call per process for the common case of checking the current directory's repo root. Files updated: - internal/git/git.go: Add RepoRoot() and RepoRootFrom() - internal/cmd/prime.go: Use cached git.RepoRoot() - internal/cmd/molecule_status.go: Use cached git.RepoRoot() - internal/cmd/sling_helpers.go: Use cached git.RepoRoot() - internal/cmd/rig_quick_add.go: Use git.RepoRootFrom() for path arg - internal/version/stale.go: Use cached git.RepoRoot() Closes: bd-2zd.5
1383 lines
43 KiB
Go
1383 lines
43 KiB
Go
// Package git provides a wrapper for git operations via subprocess.
|
|
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Cached repo root for the current process.
|
|
// Since CLI commands are short-lived and the working directory doesn't change
|
|
// during a single invocation, caching this avoids repeated git subprocess calls
|
|
// that add ~50ms each and contend on .git/index.lock.
|
|
var (
|
|
cachedRepoRoot string
|
|
cachedRepoRootOnce sync.Once
|
|
cachedRepoRootErr error
|
|
)
|
|
|
|
// RepoRoot returns the root directory of the git repository containing the
|
|
// current working directory. The result is cached for the lifetime of the process.
|
|
// This avoids repeated git rev-parse calls that are expensive (~50ms each) and
|
|
// can cause lock contention when multiple agents are running.
|
|
func RepoRoot() (string, error) {
|
|
cachedRepoRootOnce.Do(func() {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
cachedRepoRootErr = err
|
|
return
|
|
}
|
|
cachedRepoRoot = strings.TrimSpace(string(out))
|
|
})
|
|
return cachedRepoRoot, cachedRepoRootErr
|
|
}
|
|
|
|
// RepoRootFrom returns the root directory of the git repository containing the
|
|
// specified path. Unlike RepoRoot(), this is not cached because it depends on
|
|
// the input path. Use RepoRoot() when checking the current working directory.
|
|
func RepoRootFrom(path string) (string, error) {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
cmd.Dir = path
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ConfigGet returns the value of a git config key.
|
|
// Returns empty string if the key is not set.
|
|
func (g *Git) ConfigGet(key string) (string, error) {
|
|
out, err := g.run("config", "--get", key)
|
|
if err != nil {
|
|
// git config --get returns exit code 1 if key not found
|
|
return "", nil
|
|
}
|
|
return out, 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
|
|
}
|
|
|
|
// MergeSquash performs a squash merge of the given branch and commits with the provided message.
|
|
// This stages all changes from the branch without creating a merge commit, then commits them
|
|
// as a single commit with the given message. This eliminates redundant merge commits while
|
|
// preserving the original commit message from the source branch.
|
|
func (g *Git) MergeSquash(branch, message string) error {
|
|
// Stage all changes from the branch without committing
|
|
if _, err := g.run("merge", "--squash", branch); err != nil {
|
|
return err
|
|
}
|
|
// Commit the staged changes with the provided message
|
|
_, err := g.run("commit", "-m", message)
|
|
return err
|
|
}
|
|
|
|
// GetBranchCommitMessage returns the commit message of the HEAD commit on the given branch.
|
|
// This is useful for preserving the original conventional commit message (feat:/fix:) when
|
|
// performing squash merges.
|
|
func (g *Git) GetBranchCommitMessage(branch string) (string, error) {
|
|
return g.run("log", "-1", "--format=%B", branch)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|