package agent import ( "bytes" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) // ensureDir creates dir (and parents) if it does not already exist. func ensureDir(dir string) error { if dir == "" { return nil } return os.MkdirAll(dir, 0o755) } // Worktree is one entry from `git worktree list --porcelain`. type Worktree struct { Path string Head string Branch string // short branch name ("" when detached or bare) Bare bool Detached bool } // runGit runs git with args, using dir as the working directory (empty = the // process cwd). It returns trimmed stdout, or an error that includes stderr so // failures like "branch already checked out" surface verbatim. func runGit(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = strings.TrimSpace(stdout.String()) } return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg) } return strings.TrimSpace(stdout.String()), nil } // GitClone clones url into dir. Any globalArgs (e.g. "-c", // "credential.helper=...") are passed before the clone subcommand so auth can be // supplied without persisting it in the resulting checkout's config. func GitClone(url, dir string, globalArgs ...string) error { if err := ensureDir(filepath.Dir(dir)); err != nil { return err } args := append(append([]string{}, globalArgs...), "clone", url, dir) _, err := runGit(filepath.Dir(dir), args...) return err } // GitFetch runs `git fetch ` in repoDir. globalArgs are passed before // the subcommand (used to inject an ephemeral credential helper). func GitFetch(repoDir, remote string, globalArgs ...string) error { args := append(append([]string{}, globalArgs...), "fetch", remote) _, err := runGit(repoDir, args...) return err } // GitFetchPrune runs `git fetch --prune ` in repoDir so remote-tracking // refs for branches deleted on the remote (e.g. after a merge) disappear. func GitFetchPrune(repoDir, remote string, globalArgs ...string) error { args := append(append([]string{}, globalArgs...), "fetch", "--prune", remote) _, err := runGit(repoDir, args...) return err } // GitRemoteDefaultBranch returns the short name of remote's default branch // (e.g. "main") by resolving refs/remotes//HEAD. func GitRemoteDefaultBranch(repoDir, remote string) (string, error) { out, err := runGit(repoDir, "rev-parse", "--abbrev-ref", remote+"/HEAD") if err != nil { return "", err } return strings.TrimPrefix(out, remote+"/"), nil } // GitBranchExists reports whether a local branch exists. func GitBranchExists(repoDir, branch string) bool { _, err := runGit(repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) return err == nil } // GitRemoteURL returns the configured URL for a remote. func GitRemoteURL(repoDir, remote string) (string, error) { return runGit(repoDir, "remote", "get-url", remote) } // GitRemoteBranchExists reports whether a remote-tracking ref for branch exists // (accurate only after a pruning fetch). func GitRemoteBranchExists(repoDir, remote, branch string) bool { _, err := runGit(repoDir, "show-ref", "--verify", "--quiet", "refs/remotes/"+remote+"/"+branch) return err == nil } // GitIsDirty reports whether the checkout at dir has uncommitted or untracked // changes. func GitIsDirty(dir string) (bool, error) { out, err := runGit(dir, "status", "--porcelain") if err != nil { return false, err } return strings.TrimSpace(out) != "", nil } // GitIsAncestor reports whether ancestor is reachable from descendant. func GitIsAncestor(repoDir, ancestor, descendant string) (bool, error) { cmd := exec.Command("git", "merge-base", "--is-ancestor", ancestor, descendant) cmd.Dir = repoDir var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // Exit 1 is the documented "not an ancestor" answer; anything else is a // real failure (bad ref, not a repo). var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return false, nil } return false, fmt.Errorf("git merge-base --is-ancestor %s %s: %w: %s", ancestor, descendant, err, strings.TrimSpace(stderr.String())) } return true, nil } // GitUnmergedCommits counts commits on head whose patch has no equivalent on // upstream, using `git cherry` so squash- and rebase-merged work is recognised // despite its rewritten SHAs. func GitUnmergedCommits(repoDir, upstream, head string) (int, error) { out, err := runGit(repoDir, "cherry", upstream, head) if err != nil { return 0, err } n := 0 for _, line := range strings.Split(out, "\n") { if strings.HasPrefix(strings.TrimSpace(line), "+") { n++ } } return n, nil } // GitWorktreeAdd adds a worktree at path checked out to branch. When the branch // already exists it is reused; otherwise it is created from startPoint. func GitWorktreeAdd(repoDir, path, branch, startPoint string) error { if err := ensureDir(filepath.Dir(path)); err != nil { return err } var args []string if GitBranchExists(repoDir, branch) { args = []string{"worktree", "add", path, branch} } else { args = []string{"worktree", "add", path, "-b", branch, startPoint} } _, err := runGit(repoDir, args...) return err } // GitWorktreeRemove removes the worktree at path (force skips the dirty check). func GitWorktreeRemove(repoDir, path string, force bool) error { args := []string{"worktree", "remove", path} if force { args = append(args, "--force") } _, err := runGit(repoDir, args...) return err } // GitWorktreePrune prunes stale worktree administrative entries. func GitWorktreePrune(repoDir string) error { _, err := runGit(repoDir, "worktree", "prune") return err } // GitWorktreeList returns the worktrees registered for repoDir. func GitWorktreeList(repoDir string) ([]Worktree, error) { out, err := runGit(repoDir, "worktree", "list", "--porcelain") if err != nil { return nil, err } return ParseWorktreeList(out), nil } // GitDeleteBranch deletes a local branch (force uses -D). func GitDeleteBranch(repoDir, branch string, force bool) error { flag := "-d" if force { flag = "-D" } _, err := runGit(repoDir, "branch", flag, branch) return err } // GitConfigSet sets a config key in repoDir. When worktree is true the value is // written to the per-worktree config (extensions.worktreeConfig must be enabled) // so it does not touch the shared checkout's config. func GitConfigSet(repoDir string, worktree bool, key, value string) error { args := []string{"config"} if worktree { args = append(args, "--worktree") } args = append(args, key, value) _, err := runGit(repoDir, args...) return err } // GitCommonDir returns the absolute path to the shared .git directory for the // checkout at dir (a worktree's common dir points back at its source repo). func GitCommonDir(dir string) (string, error) { out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir") if err != nil { return "", err } return out, nil } // GitCurrentBranch returns the short branch name checked out at dir. func GitCurrentBranch(dir string) (string, error) { return runGit(dir, "rev-parse", "--abbrev-ref", "HEAD") } // SourceRepoDir maps a worktree checkout to its source repo directory by walking // from the shared .git common dir up to the repo root. func SourceRepoDir(worktreeDir string) (string, error) { common, err := GitCommonDir(worktreeDir) if err != nil { return "", err } // common is "...//.git"; the repo dir is its parent. return filepath.Dir(common), nil } // ParseWorktreeList parses the output of `git worktree list --porcelain`. func ParseWorktreeList(out string) []Worktree { var wts []Worktree var cur *Worktree flush := func() { if cur != nil { wts = append(wts, *cur) cur = nil } } for _, line := range strings.Split(out, "\n") { line = strings.TrimRight(line, "\r") if line == "" { flush() continue } key, val, _ := strings.Cut(line, " ") switch key { case "worktree": flush() cur = &Worktree{Path: val} case "HEAD": if cur != nil { cur.Head = val } case "branch": if cur != nil { cur.Branch = strings.TrimPrefix(val, "refs/heads/") } case "bare": if cur != nil { cur.Bare = true } case "detached": if cur != nil { cur.Detached = true } } } flush() return wts } // SanitizeBranch turns a branch name into a filesystem-safe path segment by // replacing separators that would otherwise create nested directories. func SanitizeBranch(branch string) string { r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-") return r.Replace(strings.TrimSpace(branch)) } // WorktreeDirName is the directory name (under the worktree root) for a repo's // branch worktree: "__". func WorktreeDirName(repo, branch string) string { return repo + "__" + SanitizeBranch(branch) }