6380270ac6
- discover worktrees from `git worktree list` on each source checkout, not only the worktree root, so hand-made ones, stale registrations and orphaned directories are classified too - normalise each candidate to its main checkout, so a linked worktree in the source root cannot offer up the repo's real checkout - keep locked, mid-rebase and detached-with-unique-commits worktrees, whose removal would destroy state nothing else holds - name the retained branch in every unproven verdict - add --no-fetch, --json, --include-unmanaged and --include-keep
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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
|
|
Locked bool
|
|
// Prunable is git's own reason a registration is stale (e.g. "gitdir file
|
|
// points to non-existent location"); empty when the worktree is intact.
|
|
Prunable string
|
|
}
|
|
|
|
// 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 <remote>` 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 <remote>` 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/<remote>/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
|
|
}
|
|
|
|
// GitDir returns the absolute path to the git directory backing the checkout at
|
|
// dir (per-worktree, unlike GitCommonDir).
|
|
func GitDir(dir string) (string, error) {
|
|
return runGit(dir, "rev-parse", "--path-format=absolute", "--git-dir")
|
|
}
|
|
|
|
// inProgressMarkers maps a sentinel inside the git dir to the operation it means
|
|
// is half-finished. Such a checkout holds state that lives nowhere else.
|
|
var inProgressMarkers = []struct{ path, op string }{
|
|
{"rebase-merge", "rebase"},
|
|
{"rebase-apply", "rebase"},
|
|
{"MERGE_HEAD", "merge"},
|
|
{"CHERRY_PICK_HEAD", "cherry-pick"},
|
|
{"REVERT_HEAD", "revert"},
|
|
{"BISECT_LOG", "bisect"},
|
|
}
|
|
|
|
// GitInProgressOp names the sequencer operation underway in the checkout at dir,
|
|
// or "" when none is.
|
|
func GitInProgressOp(dir string) (string, error) {
|
|
gitDir, err := GitDir(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, m := range inProgressMarkers {
|
|
if _, err := os.Stat(filepath.Join(gitDir, m.path)); err == nil {
|
|
return m.op, nil
|
|
} else if !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// GitCommitsNotOnRemotes counts commits reachable from HEAD that no
|
|
// remote-tracking ref holds, i.e. work that exists only in this checkout.
|
|
func GitCommitsNotOnRemotes(dir string) (int, error) {
|
|
out, err := runGit(dir, "rev-list", "--count", "HEAD", "--not", "--remotes")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSpace(out))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse rev-list count %q: %w", out, err)
|
|
}
|
|
return n, 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 ".../<repo>/.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
|
|
}
|
|
case "locked":
|
|
if cur != nil {
|
|
cur.Locked = true
|
|
}
|
|
case "prunable":
|
|
if cur != nil {
|
|
// git omits the reason when it has none, so record the flag itself.
|
|
cur.Prunable = val
|
|
if cur.Prunable == "" {
|
|
cur.Prunable = "prunable"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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: "<repo>__<sanitized-branch>".
|
|
func WorktreeDirName(repo, branch string) string {
|
|
return repo + "__" + SanitizeBranch(branch)
|
|
}
|