Files
agent-tools/internal/agent/git.go
T
unkin-agent 4bbeaae8f0
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
add agentws prune
Agents leave their managed worktrees behind, and `agentws rm` takes one path at
a time with no idea whether a branch's work is safely upstream, so clearing an
accumulation by hand risks destroying unmerged commits.

- classify every managed worktree: dirty, PR open, upstream, or unproven
- remove only what is safe; delete the local branch only when work is upstream
- prove "upstream" with merge-base and git cherry, so squash merges count
- match a PR by head.label, which survives the branch deletion a merge does
- dry run by default; --yes applies, --keep-branches spares every branch
- read the Gitea path from origin's URL rather than assuming the owner
2026-09-09 23:41:09 +10:00

298 lines
8.9 KiB
Go

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 <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
}
// 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
}
}
}
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)
}