package agent import ( "bytes" "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 } // 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 } // 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) }