package git import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/kunchenguid/no-mistakes/internal/safeurl" "github.com/kunchenguid/no-mistakes/internal/winproc" ) // EmptyTreeSHA is the well-known SHA of an empty tree in git. // Used as a base when there is no prior commit to diff against. const EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" // IsZeroSHA returns true if the SHA is the null/zero ref that git uses for // new or deleted branches (40 zeros). func IsZeroSHA(sha string) bool { return sha == "0000000000000000000000000000000000000000" } // Run executes a git command in the given directory and returns trimmed stdout. // Returns an error that includes the command and stderr on failure. // // When dir is itself a bare repository (a gate repo), the repo is named // explicitly via --git-dir instead of relying on cwd-based discovery, which // safe.bareRepository=explicit forbids. Agent harnesses (e.g. Claude Code) // and hardened CI inject that setting, so gate operations must never depend // on discovering a bare repo from the working directory (issue #362). func Run(ctx context.Context, dir string, args ...string) (string, error) { if isBareGitDir(dir) { args = append([]string{"--git-dir=" + dir}, args...) } cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Env = NonInteractiveEnv(dir) winproc.Harden(cmd) out, err := cmd.Output() if err != nil { stderr := "" if ee, ok := err.(*exec.ExitError); ok { stderr = strings.TrimSpace(string(ee.Stderr)) } return "", fmt.Errorf("git %s: %w: %s", safeurl.RedactText(strings.Join(args, " ")), err, safeurl.RedactText(stderr)) } return strings.TrimSpace(string(out)), nil } // isBareGitDir reports whether dir is itself a git directory (a bare repo), // as opposed to a working tree or linked worktree, which carry a .git entry // and keep using normal discovery. The check mirrors git's own git-dir // heuristic: a HEAD file plus an objects directory. func isBareGitDir(dir string) bool { if dir == "" { return false } if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { return false } if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() { return false } fi, err := os.Stat(filepath.Join(dir, "objects")) return err == nil && fi.IsDir() } // InitBare creates a new bare git repository at the given path. func InitBare(ctx context.Context, path string) error { cmd := exec.CommandContext(ctx, "git", "init", "--bare", path) winproc.Harden(cmd) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git init --bare: %w: %s", err, strings.TrimSpace(string(out))) } return nil } // AddRemote adds a named remote to the repo at dir. func AddRemote(ctx context.Context, dir, name, url string) error { _, err := Run(ctx, dir, "remote", "add", name, url) return err } // EnsureRemote sets the named remote to url, adding it when absent and // updating its URL when it already exists. Idempotent, so it is safe to call // when repairing or re-running an init. func EnsureRemote(ctx context.Context, dir, name, url string) error { if _, err := GetRemoteURL(ctx, dir, name); err == nil { _, err := Run(ctx, dir, "remote", "set-url", name, url) return err } return AddRemote(ctx, dir, name, url) } // RemoveRemote removes a named remote from the repo at dir. func RemoveRemote(ctx context.Context, dir, name string) error { _, err := Run(ctx, dir, "remote", "remove", name) return err } // GetRemoteURL returns the URL of a named remote. func GetRemoteURL(ctx context.Context, dir, name string) (string, error) { return Run(ctx, dir, "remote", "get-url", name) } // GetConfiguredRemoteURL returns the literal remote URL from git config, // without applying url.*.insteadOf rewrites. func GetConfiguredRemoteURL(ctx context.Context, dir, name string) (string, error) { return Run(ctx, dir, "config", "--get", "remote."+name+".url") } // FindGitRoot walks up from path to find the git repository root. // Resolves symlinks for consistency on macOS (e.g. /tmp -> /private/tmp). func FindGitRoot(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { return "", err } cmd := exec.Command("git", "rev-parse", "--show-toplevel") cmd.Dir = abs winproc.Harden(cmd) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("not a git repository: %s", abs) } root := strings.TrimSpace(string(out)) resolved, err := filepath.EvalSymlinks(root) if err != nil { return root, nil } return resolved, nil } // FindMainRepoRoot returns the root of the main working tree for a git // repository. Three layouts are supported: // // 1. A regular repository or a linked worktree: the git common dir is // /.git, so the main working tree is filepath.Dir(commonDir). // 2. An absorbed submodule (including nested .../modules/a/modules/b): // the git common dir lives under the superproject's .git/modules/... // and is detached from its working tree. Git writes core.worktree // when it absorbs a submodule, pointing at the working tree whose // remote.origin.url is the submodule's own origin (which is what // callers like init and eject need). // 3. Exotic GIT_DIR layouts without a core.worktree: fall back to // `git rev-parse --show-toplevel` from the original path, the same // answer FindGitRoot returns. // // In every branch the returned path is run through filepath.EvalSymlinks // when possible so callers can compare it against other symlink-resolved // paths (notably on macOS, where /tmp and /private/tmp refer to the same // directory). Symlink resolution failures fall back to the unresolved // path, matching the historical behavior. func FindMainRepoRoot(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { return "", err } // Resolve the git common dir. commonDirCmd := exec.Command("git", "rev-parse", "--git-common-dir") commonDirCmd.Dir = abs winproc.Harden(commonDirCmd) commonDirOut, err := commonDirCmd.Output() if err != nil { return "", fmt.Errorf("not a git repository: %s", abs) } commonDir := strings.TrimSpace(string(commonDirOut)) if !filepath.IsAbs(commonDir) { commonDir = filepath.Join(abs, commonDir) } // Branch 1: regular repo or linked worktree. Linked worktrees share // the main repo's /.git, so the common dir's basename is still // ".git" and its parent is the main working tree. if filepath.Base(commonDir) == ".git" { return resolveMainRoot(filepath.Dir(commonDir)) } // Branch 2: detached git dir (absorbed submodule). Ask the git dir // itself for its core.worktree, which git writes when it absorbs a // submodule's git dir. The value is typically relative (e.g. // "../../../sub"); resolve it against the common dir. worktreeCmd := exec.Command("git", "--git-dir", commonDir, "config", "--get", "core.worktree") winproc.Harden(worktreeCmd) if worktreeOut, err := worktreeCmd.Output(); err == nil { worktree := strings.TrimSpace(string(worktreeOut)) if worktree != "" { if !filepath.IsAbs(worktree) { worktree = filepath.Join(commonDir, worktree) } return resolveMainRoot(worktree) } } // Branch 3: exotic GIT_DIR without a usable core.worktree. Defer to // `git rev-parse --show-toplevel` from the original path. topCmd := exec.Command("git", "rev-parse", "--show-toplevel") topCmd.Dir = abs winproc.Harden(topCmd) topOut, err := topCmd.Output() if err != nil { return "", fmt.Errorf("not a git repository: %s", abs) } return resolveMainRoot(strings.TrimSpace(string(topOut))) } // resolveMainRoot applies filepath.EvalSymlinks to path, falling back to // the unresolved path when symlink resolution fails. func resolveMainRoot(path string) (string, error) { resolved, err := filepath.EvalSymlinks(path) if err != nil { return path, nil } return resolved, nil } // Diff returns the unified diff between two commits. func Diff(ctx context.Context, dir, base, head string) (string, error) { return Run(ctx, dir, "diff", base+".."+head) } // DiffNameOnly returns the list of files changed between base and head. // Output is split on newlines with empty entries removed. func DiffNameOnly(ctx context.Context, dir, base, head string) ([]string, error) { out, err := Run(ctx, dir, "diff", "--name-only", base+".."+head) if err != nil { return nil, err } var files []string for _, line := range strings.Split(out, "\n") { if trimmed := strings.TrimSpace(line); trimmed != "" { files = append(files, trimmed) } } return files, nil } // DiffStat returns the bounded size of the diff between base and head: the // number of changed files and the net changed lines (insertions + deletions) // from `git diff --numstat`. Binary files (numstat "-") contribute a changed // file but no line count. It carries no paths or content - just two counts. func DiffStat(ctx context.Context, dir, base, head string) (files, lines int, err error) { out, err := Run(ctx, dir, "diff", "--numstat", base+".."+head) if err != nil { return 0, 0, err } for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) if len(fields) < 3 { continue } files++ added, aerr := strconv.Atoi(fields[0]) deleted, derr := strconv.Atoi(fields[1]) if aerr == nil { lines += added } if derr == nil { lines += deleted } } return files, lines, nil } // CommitTime returns the committer timestamp for a SHA in UTC. func CommitTime(ctx context.Context, dir, sha string) (time.Time, error) { out, err := Run(ctx, dir, "show", "-s", "--format=%ct", sha) if err != nil { return time.Time{}, err } secs, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64) if err != nil { return time.Time{}, fmt.Errorf("parse commit time %q: %w", out, err) } return time.Unix(secs, 0).UTC(), nil } // CommitAuthorEmail returns the author email for a SHA. func CommitAuthorEmail(ctx context.Context, dir, sha string) (string, error) { return Run(ctx, dir, "show", "-s", "--format=%ae", sha) } // DiffHead returns the unified diff between HEAD and the working tree // (both staged and unstaged changes). func DiffHead(ctx context.Context, dir string) (string, error) { return Run(ctx, dir, "diff", "HEAD") } // Log returns oneline log entries between two commits. func Log(ctx context.Context, dir, base, head string) (string, error) { return Run(ctx, dir, "log", "--oneline", base+".."+head) } // HeadSHA returns the full SHA of HEAD. func HeadSHA(ctx context.Context, dir string) (string, error) { return Run(ctx, dir, "rev-parse", "HEAD") } // CurrentBranch returns the current branch name. func CurrentBranch(ctx context.Context, dir string) (string, error) { return Run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") } // IsDetachedHEAD reports whether the working tree is in a detached-HEAD state // (HEAD points at a commit rather than a branch ref). Uses `git symbolic-ref` // which fails cleanly when HEAD is not a symbolic ref. func IsDetachedHEAD(ctx context.Context, dir string) (bool, error) { cmd := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "HEAD") cmd.Dir = dir winproc.Harden(cmd) if err := cmd.Run(); err != nil { if ee, ok := err.(*exec.ExitError); ok { // Exit 1 means HEAD is not a symbolic ref — detached. if ee.ExitCode() == 1 { return true, nil } } return false, fmt.Errorf("git symbolic-ref: %w", err) } return false, nil } // DefaultBranch queries a remote to determine its default branch name. // Uses git ls-remote --symref to read the remote's HEAD symref. // Falls back to "main" if detection fails (e.g. empty remote, unreachable). func DefaultBranch(ctx context.Context, dir, remote string) string { out, err := Run(ctx, dir, "ls-remote", "--symref", remote, "HEAD") if err != nil { return "main" } // Output format: "ref: refs/heads/main\tHEAD\n\tHEAD\n" // Fields splits: ["ref:", "refs/heads/main", "HEAD"] for _, line := range strings.Split(out, "\n") { if strings.HasPrefix(line, "ref: refs/heads/") { parts := strings.Fields(line) if len(parts) >= 2 { return strings.TrimPrefix(parts[1], "refs/heads/") } } } return "main" } // FetchRemoteBranch fetches a single branch into a remote-tracking ref. // Uses a force-update refspec (+) so non-fast-forward updates (e.g. after // a force push on the remote) are accepted instead of silently rejected. func FetchRemoteBranch(ctx context.Context, dir, remote, branch string) error { refspec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branch, remote, branch) _, err := Run(ctx, dir, "fetch", "--no-tags", remote, refspec) return err } func FetchRemoteBranchToRef(ctx context.Context, dir, remote, branch, localRef string) error { refspec := fmt.Sprintf("+refs/heads/%s:%s", branch, localRef) _, err := Run(ctx, dir, "fetch", "--no-tags", remote, refspec) return err } // Push pushes a ref to a remote. If forceWithLease is true, uses // --force-with-lease with the expectedSHA for safe force-push. func Push(ctx context.Context, dir, remote, ref, expectedSHA string, forceWithLease bool) error { return PushWithOptions(ctx, dir, remote, ref, expectedSHA, forceWithLease, nil) } // PushWithOptions pushes a ref to a remote with per-push options. func PushWithOptions(ctx context.Context, dir, remote, ref, expectedSHA string, forceWithLease bool, pushOptions []string) error { args := []string{"push"} for _, option := range pushOptions { args = append(args, "-o", option) } args = append(args, remote) if forceWithLease { if expectedSHA != "" { args = append(args, fmt.Sprintf("--force-with-lease=%s:%s", ref, expectedSHA)) } else { args = append(args, "--force-with-lease") } } args = append(args, "HEAD:"+ref) _, err := Run(ctx, dir, args...) return err } // LsRemote returns the SHA of a ref on a remote. Returns empty string if the ref doesn't exist. func LsRemote(ctx context.Context, dir, remote, ref string) (string, error) { out, err := Run(ctx, dir, "ls-remote", remote, ref) if err != nil { return "", err } if out == "" { return "", nil } // Output format: "\t" parts := strings.Fields(out) if len(parts) < 1 { return "", nil } return parts[0], nil } // HasUncommittedChanges reports whether the working tree or index differs from HEAD. // Returns true if any tracked file is modified, staged, or deleted, or if there are // untracked files. Equivalent to a non-empty `git status --porcelain`. func HasUncommittedChanges(ctx context.Context, dir string) (bool, error) { out, err := Run(ctx, dir, "status", "--porcelain") if err != nil { return false, err } return out != "", nil } // CreateBranch creates a new branch with the given name and switches to it. // Fails if the branch already exists. func CreateBranch(ctx context.Context, dir, name string) error { _, err := Run(ctx, dir, "checkout", "-b", name) return err } // CommitAll stages every change in the working tree and creates a single commit // with the given message. Fails if there are no changes to commit. func CommitAll(ctx context.Context, dir, message string) error { if _, err := Run(ctx, dir, "add", "-A"); err != nil { return err } dirty, err := HasUncommittedChanges(ctx, dir) if err != nil { return err } if !dirty { return fmt.Errorf("no changes to commit") } _, err = Run(ctx, dir, "commit", "-m", message) return err } // CopyLocalUserIdentity copies local user.name and user.email from srcDir into // dstDir. Missing values in srcDir are ignored. // // The write into dstDir uses per-worktree scope (`git config --worktree`) when // the repository has worktree config enabled. dstDir is typically a linked // worktree of the shared gate bare repo, where an unscoped `git config --local` // write lands in the bare's shared config and takes /config.lock. Two // runs starting concurrently on different branches of the same repo then race // on that single lock and one fails with "could not lock config file ... // config: File exists". Writing per-worktree puts each run's identity in its own // /worktrees//config.worktree, so concurrent startups never contend. // Older Git without `--worktree` support falls back to `--local`. func CopyLocalUserIdentity(ctx context.Context, srcDir, dstDir string) error { for _, key := range []string{"user.name", "user.email"} { value, err := Run(ctx, srcDir, "config", "--local", "--get", "--default", "", key) if err != nil { return err } if value == "" { continue } if _, err := Run(ctx, dstDir, "config", "--worktree", key, value); err != nil { if !isWorktreeConfigWriteUnavailable(err) { return err } // Per-worktree config is not usable here (Git too old for the // flag, or the repo has multiple worktrees without // extensions.worktreeConfig enabled). Fall back to the shared // local config. Such gates also lack per-worktree isolation, so // this matches the legacy behavior. if _, err := Run(ctx, dstDir, "config", "--local", key, value); err != nil { return err } } } return nil } // isWorktreeConfigWriteUnavailable reports whether a `git config --worktree` // write failed because per-worktree config cannot be used on this repo: either // the installed Git is too old for the flag (isWorktreeConfigUnsupported), or // the repo has more than one worktree without extensions.worktreeConfig enabled // ("--worktree cannot be used with multiple working trees unless the config // extension worktreeConfig is enabled"). Both mean the caller should fall back // to the shared --local config. func isWorktreeConfigWriteUnavailable(err error) bool { if isWorktreeConfigUnsupported(err) { return true } return strings.Contains(err.Error(), "worktreeConfig") } // WorktreeAdd creates a detached worktree at wtPath checked out to the given SHA. func WorktreeAdd(ctx context.Context, repoDir, wtPath, sha string) error { _, err := Run(ctx, repoDir, "worktree", "add", "--detach", wtPath, sha) return err } // WorktreeRemove removes a worktree at the given path. func WorktreeRemove(ctx context.Context, repoDir, wtPath string) error { _, err := Run(ctx, repoDir, "worktree", "remove", "--force", wtPath) return err } // ResolveRef returns the commit SHA that ref resolves to via // `git rev-parse --verify ^{commit}`. Use it to pin an exact commit // (e.g. the default-branch tip just fetched) before reading a file from it, // so a shared-ref worktree cannot serve a stale remote-tracking ref. Returns // an error if the ref does not resolve to a commit. func ResolveRef(ctx context.Context, dir, ref string) (string, error) { out, err := Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}") if err != nil { return "", fmt.Errorf("resolve ref %s: %w", ref, err) } return out, nil } // RefExists reports whether the given ref resolves to a commit. It uses // `git rev-parse --verify --quiet` so a missing ref is a clean (nil, false) // result rather than a loud error. func RefExists(ctx context.Context, dir, ref string) (bool, error) { cmd := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}") cmd.Env = NonInteractiveEnv(dir) winproc.Harden(cmd) if err := cmd.Run(); err != nil { var ee *exec.ExitError if errors.As(err, &ee) && ee.ExitCode() == 1 { return false, nil } return false, fmt.Errorf("git rev-parse %s: %w", ref, err) } return true, nil } // ShowFile returns the content of path as stored at the given ref (e.g. // "HEAD", "origin/main", or a SHA) via `git show :`. A failure // (e.g. the path is absent at the ref) is returned as the underlying git // error from Run; callers that need to distinguish "absent" from a real // failure should check RefExists first or inspect the error text. func ShowFile(ctx context.Context, dir, ref, path string) (string, error) { out, err := Run(ctx, dir, "show", fmt.Sprintf("%s:%s", ref, path)) if err != nil { return "", err } return out, nil }