12 Commits

Author SHA1 Message Date
benvin 72adebbf8b Merge pull request 'Add agentpr pr edit subcommand' (#15) from benvin/agentpr-pr-edit into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #15
2026-09-19 13:48:44 +10:00
unkin-agent 3f990c841d Mock the 404 body Gitea really returns
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-09-19 12:44:00 +10:00
unkin-agent 78d83b7a61 Reject an empty pr edit title Gitea would ignore
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
2026-09-19 12:38:12 +10:00
unkin-agent 7bc4082cb0 Add agentpr pr edit subcommand
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Update a PR's title and/or body as the agent user, sending only the
fields supplied.
2026-09-19 12:32:07 +10:00
benvin cdced6536e Merge pull request 'Find every stale worktree, not just the managed ones' (#14) from benvin/worktree-gc into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #14
2026-09-12 00:46:39 +10:00
unkin-agent 72a8923c3d Cover the keep gate for unmanaged worktrees
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
--include-unmanaged lifts only the location gate, and nothing proved the keep
gate still held behind it.

- assert a dirty hand-made worktree survives --include-unmanaged alone
2026-09-12 00:40:12 +10:00
unkin-agent 62aeaf063b Keep worktrees git could not read, never delete them
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Any git error on a directory under the worktree root was classified orphan,
and orphan deletes the directory outright, so a transient failure reading the
source root became data loss on a plain `agentws prune --yes`.

- prove a backing repo gone by stat before calling a directory an orphan
- classify an unexplained git failure as keep, naming the error
- refuse to remove a worktree whose git state is unknown, even with --include-keep
- spell out that --include-keep discards uncommitted and in-progress work
2026-09-12 00:37:10 +10:00
unkin-agent 6380270ac6 Find every stale worktree, not just the managed ones
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
- 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
2026-09-12 00:16:32 +10:00
benvin 6d0e954cce Merge pull request 'Add agentws prune' (#13) from benvin/agentws-prune into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #13
2026-09-10 21:50:20 +10:00
unkin-agent 387653a3c0 Distrust origin/<branch> when prune's fetch fails
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
A stale remote-tracking ref survives a failed fetch and the next successful
--prune deletes it, so it cannot prove a branch's commits survive upstream.
Record whether the pruning fetch succeeded and gate the origin/<branch>
existence and containment proofs on it; a failed fetch removes the worktree and
keeps the branch. Local-object proofs and the merged head SHA are unaffected.
2026-09-10 00:18:54 +10:00
unkin-agent c1c02c01cf Require git proof before prune deletes a branch
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
A merged or closed PR no longer authorises a delete on its own: HEAD must
be contained in the PR's head commit or in origin/<branch>, otherwise the
worktree goes and the branch stays. Branch deletion runs `git branch -d`
first and falls back to -D only for a proven branch.

Reword the cherry check to say patches reached the default branch's
history, print the verdict --keep-branches will actually perform, and warn
when a PR listing hits the pagination cap instead of reading it as "no PR".
2026-09-10 00:00:22 +10:00
unkin-agent 4bbeaae8f0 add agentws prune
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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
13 changed files with 2825 additions and 27 deletions
+26 -6
View File
@@ -8,9 +8,9 @@ from Vault, so actions are attributed to the agent rather than to whoever runs
the tool. Setting `AGENT_LOGIN` selects a different agent identity, so a service
like repospawner can run these tools as itself.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent`
(fixes the "tea posts as Ben" attribution problem). Subcommands:
`pr create`, `pr comment`, `whoami`.
- **`agentpr`** — create and edit pull requests, and post PR comments, as
`unkin-agent` (fixes the "tea posts as Ben" attribution problem).
Subcommands: `pr create`, `pr comment`, `pr edit`, `whoami`.
- **`watchpr`** — poll one or more PRs and exit when a tracked PR changes
meaningfully: it merges/closes, gets a new non-agent comment, its CI fails,
or it loses mergeability. Benign transitions (CI pending→success, the agent's
@@ -19,7 +19,7 @@ like repospawner can run these tools as itself.
repos into the source root (`~/src/prodenv/<repo>`), creates worktrees under
the worktree root (`~/.cache/agentws/<repo>__<branch>`), and authenticates
clone/fetch/push via an ephemeral credential helper. Subcommands: `new`,
`list`, `rm`, `clean`, `token`, `credential`.
`list`, `rm`, `prune`, `clean`, `token`, `credential`.
All tools are separate `main` packages under `cmd/` and share the
`internal/agent` package (Vault AppRole login, Gitea REST client, PR-ref
@@ -28,14 +28,15 @@ parsing, watch-state comparison, git worktree helpers).
## Structure
```
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami)
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / pr edit / whoami)
cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit)
cmd/agentws/main.go # agentws CLI (new / list / rm / clean / token / credential)
cmd/agentws/prune.go # agentws prune (classify worktrees, remove the safe ones)
cmd/agentvault/main.go # agentvault CLI (seed-outpost / seed-oauth)
internal/agent/ # shared plumbing:
token.go # env config + in-process Gitea-token cache
vault.go # AppRole login + read the gitea creds path
gitea.go # Gitea REST client (PR create/get, comments, status, whoami)
gitea.go # Gitea REST client (PR create/edit/get, comments, status, whoami)
parse.go # owner/repo#N and owner/repo parsing
watch.go # PRState snapshot + MeaningfulChange comparison
git.go # git worktree/clone/fetch helpers (os/exec, no go-git)
@@ -186,5 +187,24 @@ wrapped per stage (login / read denied / write denied) via `ErrVaultDenied`.
`seed-oauth` reports key names only.
- `--rotate` regenerates the `client_secret` too, which then no longer matches
the IdP provider unless that is rotated alongside.
- `agentws prune` is a dry run unless `--yes`. It matches a branch to its PR on
`head.label`: Gitea rewrites `head.ref` to `refs/pull/<n>/head` once the branch
is deleted, which merging does, so `head.ref` matching misses every merged PR.
Git signals (`merge-base --is-ancestor`, `git cherry`) are authoritative and
offline-safe; an unreachable Gitea only means no branch gets deleted without
git proof. A PR's state never authorises a branch delete on its own — HEAD
must be contained in the PR's head commit or in `origin/<branch>`, otherwise
the worktree goes and the branch stays. `origin/<branch>` is only evidence when
this run's pruning fetch succeeded; a failed fetch leaves stale tracking refs,
so those verdicts fall back to keeping the branch.
- `agentws prune` discovers worktrees from the worktree root *and* from
`git worktree list` on each source checkout, merging the two so git's own
`locked`/`prunable` flags reach entries the directory scan already found.
Removing a worktree is only safe because the local branch keeps its commits, so
the cases with no branch to fall back on are kept: a detached HEAD carrying
commits on no remote, a locked checkout, or one with a sequencer operation
half-finished (`rebase-merge`, `MERGE_HEAD`, `CHERRY_PICK_HEAD`, …). A directory
whose backing repo is gone is deleted outright, but only ever inside the
worktree root.
- CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA
yields an empty state without an API call.
+90 -1
View File
@@ -6,7 +6,8 @@ token from Vault, so automated PRs, comments and pushes are attributed to the
agent — not to whoever happens to run the command. Set `AGENT_LOGIN` to act as a
different agent identity.
- **`agentpr`** — create pull requests and post PR comments as the agent user.
- **`agentpr`** — create and edit pull requests, and post PR comments as the
agent user.
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
acting on.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning
@@ -50,6 +51,11 @@ agentpr pr create --repo unkin/argocd-apps \
# Comment on a PR
agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green."
# Edit a PR's title and/or body; an omitted flag is left unchanged
agentpr pr edit --repo unkin/argocd-apps --pr 42 --body "Adds the ServiceAccount ..."
agentpr pr edit --repo unkin/argocd-apps --pr 42 --title "Add woodpecker SA"
# prints: #<number> <html_url>
agentpr --version
agentpr --help
```
@@ -104,6 +110,13 @@ agentws list
agentws rm benvin/my-change
agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch
# Classify every worktree found; dry run unless --yes is given
agentws prune
agentws prune --json
agentws prune --no-fetch
agentws prune --yes
agentws prune --yes --keep-branches
# Remove every managed worktree and prune each source repo
agentws clean
@@ -111,6 +124,82 @@ agentws clean
agentws token
```
### prune
`agentws prune` finds worktrees two ways and merges the results: the managed
directories under the worktree root, and `git worktree list` on every source
checkout — so hand-made worktrees, stale registrations whose directory is gone,
and leftover directories whose source repo was deleted all show up too.
It then decides, per worktree, whether its work is safely upstream:
| Signal (first match wins) | Verdict |
|---|---|
| working tree gone (registration only) | prune the registration |
| backing repo gone | delete the leftover directory |
| locked by `git worktree lock` | keep |
| rebase, merge, cherry-pick, revert or bisect in progress | keep |
| uncommitted, staged or untracked changes | keep |
| branch has an open PR | keep |
| tip contained in `origin/<default>` | remove worktree + local branch |
| every commit patch-equivalent to one in `origin/<default>`'s history | remove worktree + local branch |
| PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/<branch>`) | remove worktree + local branch |
| PR closed **and** HEAD contained in a verified `origin/<branch>` | remove worktree + local branch |
| detached HEAD carrying commits on no remote | keep |
| anything else | remove worktree, keep the branch |
A branch is deleted only where git proves its commits survive elsewhere. PR
state alone never authorises that: a merged or closed PR whose branch picked up
commits since keeps its branch, because those commits exist nowhere but here.
The delete runs `git branch -d` first so git's own unmerged check is a backstop,
falling back to `-D` only for a proven branch — squash merges keep the guard
tripping even once the work has landed.
Patch equivalence comes from `git cherry`, which these squash-merging repos need
because a merged branch's commits carry different SHAs upstream. It proves the
patches reached the default branch's history at some point — a later revert
still counts — not that they stand at its tip.
`origin/<branch>` counts as evidence only when this run's `git fetch --prune`
succeeded. A tracking ref left over from an earlier fetch may name a branch that
is already gone upstream and is itself due for deletion, so a failed fetch
downgrades those verdicts to `remove` and keeps the branch. Proofs that read
only local objects — containment in `origin/<default>`, patch equivalence, and
containment in a merged PR's head SHA — stand on their own.
Gitea PR state only adds to the git answer: when it cannot be reached, prune
says so and never deletes a branch it could not prove, and a PR listing that
hits the pagination cap is reported rather than read as "no PR". Matching a
branch to its PR uses `head.label`, since Gitea rewrites `head.ref` to
`refs/pull/<n>/head` once the branch is deleted on merge.
The last row is safe only because the local branch keeps the commits, so the
reason names the branch it is relying on. A detached HEAD has no such branch, so
unique commits there are kept instead.
Output is a table (`REPO BRANCH PATH VERDICT REASON`) with every verdict's reason
spelled out, or `--json` for scripting. Neither form needs a terminal.
| Flag | Effect |
|---|---|
| `--yes` | apply the plan; without it nothing is touched |
| `--keep-branches` | remove worktrees only; verdicts print as `remove` |
| `--no-fetch` | judge against the refs already on disk, for offline use |
| `--json` | emit the report as JSON on stdout, notes on stderr |
| `--include-unmanaged` | also remove worktrees outside the worktree root |
| `--include-keep` | dangerous: also remove worktrees classified `keep`, destroying uncommitted and in-progress work |
Without `--include-unmanaged` a hand-made worktree is reported and then skipped,
naming the flag that would remove it. `--include-keep` is the only way past a
`keep`. It leaves the branch, so committed work outlives the worktree, but
`git worktree remove --force` discards a dirty working tree and a paused
rebase's sequencer state without a word, and no branch was carrying those.
A directory under the worktree root is deleted outright only when its git dir
and the repo's shared `.git` are both proven absent by `stat`. When git merely
fails to answer for a checkout, the verdict is `keep` with the error as its
reason: an unread state is never a dead one.
### Auth / credential-helper design
Gitea tokens minted from Vault are short-lived (~1h), so `agentws` never
+57 -2
View File
@@ -5,6 +5,7 @@
//
// agentpr pr create --repo owner/repo --base main --head feature --title T --body B
// agentpr pr comment --repo owner/repo --pr 12 --body "..."
// agentpr pr edit --repo owner/repo --pr 12 --title T --body B
// agentpr whoami
package main
@@ -56,9 +57,9 @@ func client() (*agent.GiteaClient, error) {
func newPRCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pr",
Short: "Create PRs and post PR comments",
Short: "Create and edit PRs, and post PR comments",
}
cmd.AddCommand(newPRCreateCmd(), newPRCommentCmd())
cmd.AddCommand(newPRCreateCmd(), newPRCommentCmd(), newPREditCmd())
return cmd
}
@@ -143,6 +144,60 @@ func newPRCommentCmd() *cobra.Command {
return cmd
}
func newPREditCmd() *cobra.Command {
var repo, title, body string
var pr int
cmd := &cobra.Command{
Use: "edit",
Short: "Edit a pull request's title and/or body",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if pr <= 0 {
return fmt.Errorf("--pr must be a positive PR number")
}
// Only the flags actually given are sent: omitting --title must
// leave the title as it is, not blank it.
var opts agent.EditPROptions
if cmd.Flags().Changed("title") {
// Gitea ignores an empty title, so sending one would report
// success while changing nothing.
if title == "" {
return fmt.Errorf("--title cannot be empty: a title can be set but not cleared")
}
opts.Title = &title
}
if cmd.Flags().Changed("body") {
opts.Body = &body
}
if opts.Title == nil && opts.Body == nil {
return fmt.Errorf("at least one of --title or --body is required")
}
c, err := client()
if err != nil {
return err
}
updated, err := c.EditPR(owner+"/"+name, pr, opts)
if err != nil {
return err
}
fmt.Printf("#%d %s\n", updated.Number, updated.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&pr, "pr", 0, "PR number (required)")
f.StringVar(&title, "title", "", "New PR title (unchanged when omitted)")
f.StringVar(&body, "body", "", "New PR body (unchanged when omitted)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("pr")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
+33
View File
@@ -2,6 +2,7 @@ package main
import (
"io"
"strings"
"testing"
)
@@ -16,3 +17,35 @@ func TestExecuteBadRepoErrors(t *testing.T) {
t.Fatal("Execute() = nil, want error for a malformed --repo")
}
}
// `pr edit` with neither --title nor --body has nothing to send; it must fail
// with a usage error before any Vault/Gitea call, so this stays hermetic.
func TestPREditRequiresTitleOrBody(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"pr", "edit", "--repo", "unkin/repo", "--pr", "7"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() = nil, want an error when neither --title nor --body is given")
}
if !strings.Contains(err.Error(), "--title or --body") {
t.Errorf("error = %q, want it to name the missing flags", err)
}
}
// Gitea silently ignores an empty title, so `pr edit --title ""` would report
// success while changing nothing; it must fail before any Vault/Gitea call.
func TestPREditRejectsEmptyTitle(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"pr", "edit", "--repo", "unkin/repo", "--pr", "7", "--title", ""})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() = nil, want an error for an empty --title")
}
if !strings.Contains(err.Error(), "--title cannot be empty") {
t.Errorf("error = %q, want it to reject the empty title", err)
}
}
+305 -15
View File
@@ -13,6 +13,8 @@
// agentws new <repo> [--branch benvin/<name>] [--from <base-branch>]
// agentws list
// agentws rm <path-or-branch> [--delete-branch]
// agentws prune [--yes] [--keep-branches] [--no-fetch] [--json]
// [--include-unmanaged] [--include-keep]
// agentws clean
// agentws token
// agentws credential get # git credential-helper protocol on stdin
@@ -24,6 +26,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"git.unkin.net/unkin/agent-tools/internal/agent"
@@ -54,6 +57,7 @@ func newRootCmd() *cobra.Command {
newNewCmd(),
newListCmd(),
newRmCmd(),
newPruneCmd(),
newCleanCmd(),
newTokenCmd(),
newCredentialCmd(),
@@ -226,7 +230,14 @@ func newListCmd() *cobra.Command {
return nil
}
for _, w := range managed {
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, w.branch, w.path)
branch := w.branch
switch {
case w.orphan:
branch = "(orphan)"
case w.inspectErr != nil:
branch = "(unreadable)"
}
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, branch, w.path)
}
return nil
},
@@ -239,10 +250,31 @@ type managedWt struct {
branch string
path string
srcDir string
// managed is false for worktrees found via `git worktree list` that live
// outside the worktree root, i.e. somebody made them by hand.
managed bool
// detached is true when the worktree has no branch to fall back on, so its
// commits die with the checkout.
detached bool
// locked records git's own "do not remove me" marker.
locked bool
// missing is a registration whose working tree is gone: nothing to inspect,
// nothing to lose.
missing bool
// orphan is a directory under the worktree root whose backing git dir is
// proven gone, so no git state can be read from it ever again.
orphan bool
// inspectErr is set when git refused to answer for a checkout and the reason
// was not a proven-absent backing repo. The state is unknown, never removable.
inspectErr error
}
// managedWorktrees scans the worktree root and resolves each entry's repo and
// branch from git so branch names are accurate (not the sanitized dir name).
// Directories whose backing repo is proven gone are returned as orphans rather
// than dropped, so callers can see (and clean up) the leftovers; a directory git
// merely failed to answer for is returned with its error instead, because an
// unread state must never be mistaken for a dead one.
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
@@ -261,24 +293,238 @@ func managedWorktrees() ([]managedWt, error) {
continue
}
path := filepath.Join(wr, e.Name())
branch, err := agent.GitCurrentBranch(path)
if err != nil {
continue // not a git worktree; skip
if _, err := os.Stat(filepath.Join(path, ".git")); err != nil {
continue // not a worktree checkout at all
}
srcDir, err := agent.SourceRepoDir(path)
if err != nil {
branch, branchErr := agent.GitCurrentBranch(path)
srcDir, srcErr := agent.SourceRepoDir(path)
if branchErr != nil || srcErr != nil {
entry := managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true}
if gone, err := backingRepoGone(path); err == nil && gone {
entry.orphan = true
} else if branchErr != nil {
entry.inspectErr = branchErr
} else {
entry.inspectErr = srcErr
}
out = append(out, entry)
continue
}
out = append(out, managedWt{
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
managed: true,
detached: branch == "HEAD",
})
}
return out, nil
}
// backingRepoGone proves, by stat alone, that a worktree directory's backing
// repo no longer exists: its .git file names a git dir that is absent, and the
// repo's shared .git the git dir lived in is absent too. Only that pair licenses
// deleting the directory. Every other outcome — an unreadable .git file, a git
// dir still on disk, a stat that failed for any reason other than "not there",
// or a mere lost registration in a repo that is still present — reports false,
// so a transient or unexplained failure can never be read as "safe to delete".
func backingRepoGone(path string) (bool, error) {
dot := filepath.Join(path, ".git")
info, err := os.Lstat(dot)
if err != nil {
return false, err
}
if info.IsDir() {
return false, nil // a standalone checkout, not a linked worktree
}
data, err := os.ReadFile(dot)
if err != nil {
return false, err
}
rest, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:")
if !ok {
return false, fmt.Errorf("%s: not a worktree gitdir pointer", dot)
}
gitDir := strings.TrimSpace(rest)
if gitDir == "" {
return false, fmt.Errorf("%s: empty gitdir", dot)
}
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// The git dir is "<repo>/.git/worktrees/<name>"; both it and the shared .git
// it sits in must be absent before the repo counts as gone.
for _, dir := range []string{gitDir, filepath.Dir(filepath.Dir(gitDir))} {
if _, err := os.Stat(dir); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return false, err
}
}
return true, nil
}
// repoFromDirName recovers the repo name from the "<repo>__<branch>" layout used
// under the worktree root, for entries git can no longer answer for.
func repoFromDirName(name string) string {
if repo, _, ok := strings.Cut(name, "__"); ok {
return repo
}
return name
}
// allWorktrees is every worktree prune should consider: the managed ones under
// the worktree root, plus whatever `git worktree list` reports for the repos
// they belong to and for every checkout in the source root. The second source
// finds hand-made worktrees and stale registrations whose directory is gone, and
// carries git's own locked/prunable flags onto the entries the first source
// already found.
func allWorktrees() ([]managedWt, error) {
managed, err := managedWorktrees()
if err != nil {
return nil, err
}
wr, err := worktreeRoot()
if err != nil {
return nil, err
}
out := make([]managedWt, 0, len(managed))
index := map[string]int{}
for _, w := range managed {
index[resolvePath(w.path)] = len(out)
out = append(out, w)
}
for _, srcDir := range sourceRepos(managed) {
wts, err := agent.GitWorktreeList(srcDir)
if err != nil {
continue // not a repo any more, or unreadable; managed entries still stand
}
for _, wt := range wts {
if wt.Bare || sameDir(wt.Path, srcDir) {
continue
}
_, statErr := os.Stat(wt.Path)
entry := managedWt{
repo: filepath.Base(srcDir),
branch: worktreeBranch(wt),
path: wt.Path,
srcDir: srcDir,
managed: underRoot(wt.Path, wr),
detached: wt.Detached,
locked: wt.Locked,
missing: wt.Prunable != "" || os.IsNotExist(statErr),
}
key := resolvePath(wt.Path)
if i, ok := index[key]; ok {
// Keep the managed scan's own view, but adopt the flags only git knows.
out[i].locked = entry.locked
out[i].missing = out[i].missing || entry.missing
out[i].detached = out[i].detached || entry.detached
continue
}
index[key] = len(out)
out = append(out, entry)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].repo != out[j].repo {
return out[i].repo < out[j].repo
}
return out[i].path < out[j].path
})
return out, nil
}
// resolvePath is a path key that matches however git spells the same directory.
func resolvePath(path string) string {
if p, err := filepath.EvalSymlinks(path); err == nil {
return p
}
return filepath.Clean(path)
}
// worktreeBranch names a worktree's branch, reporting a detached checkout as
// "HEAD" so it reads the same as GitCurrentBranch does.
func worktreeBranch(wt agent.Worktree) string {
if wt.Branch != "" {
return wt.Branch
}
return "HEAD"
}
// sourceRepos is every repo to enumerate worktrees from: the ones the managed
// worktrees point back at, plus every git checkout directly under the source
// root (so a repo with only hand-made worktrees is still covered). Each is
// normalised to its main checkout, because a directory in the source root may
// itself be a linked worktree — enumerating from there would report the repo's
// real checkout as a removable worktree of itself.
func sourceRepos(managed []managedWt) []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
if dir == "" {
return
}
if main, err := agent.SourceRepoDir(dir); err == nil {
dir = main
}
key := resolvePath(dir)
if seen[key] {
return
}
seen[key] = true
dirs = append(dirs, dir)
}
for _, w := range managed {
add(w.srcDir)
}
if sr, err := srcRoot(); err == nil {
if entries, err := os.ReadDir(sr); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(sr, e.Name())
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
add(dir)
}
}
}
}
sort.Strings(dirs)
return dirs
}
// sameDir compares two paths after resolving symlinks, because git reports
// worktree paths fully resolved while our own paths may not be.
func sameDir(a, b string) bool {
if a == b {
return true
}
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
// underRoot reports whether path sits inside root, comparing resolved paths
// because git hands back worktree paths with symlinks already resolved.
func underRoot(path, root string) bool {
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if p, err := filepath.EvalSymlinks(path); err == nil {
path = p
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// --- rm -------------------------------------------------------------------
func newRmCmd() *cobra.Command {
@@ -294,7 +540,8 @@ func newRmCmd() *cobra.Command {
if err != nil {
return err
}
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch)
// Naming one worktree to delete is explicit, so rm keeps the force fallback.
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch, true)
},
}
cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Also delete the local branch after removing the worktree")
@@ -309,20 +556,35 @@ func resolveWorktree(target string) (managedWt, error) {
}
abs, _ := filepath.Abs(target)
for _, w := range managed {
if w.path == target || w.path == abs || w.branch == target {
if w.path == target || w.path == abs || (w.branch != "" && w.branch == target) {
return w, nil
}
}
return managedWt{}, fmt.Errorf("no managed worktree matching %q (try `agentws list`)", target)
}
func removeWorktree(out io.Writer, wt managedWt, deleteBranch bool) error {
// removeWorktree removes a managed worktree and, when asked, its local branch.
// forceBranch overrides git's unmerged-branch guard, so only a caller that
// proved the commits survive elsewhere may set it.
func removeWorktree(out io.Writer, wt managedWt, deleteBranch, forceBranch bool) error {
switch {
case wt.inspectErr != nil:
// No srcDir to act through and no idea what is in there; --include-keep
// must not turn that into a delete.
return fmt.Errorf("refusing to remove %s: git state unreadable: %w", wt.path, wt.inspectErr)
case wt.orphan:
return removeOrphanDir(out, wt)
case wt.missing:
// The working tree is already gone; only the registration is left.
_, _ = fmt.Fprintf(out, "pruned stale registration %s\n", wt.path)
return agent.GitWorktreePrune(wt.srcDir)
}
if err := agent.GitWorktreeRemove(wt.srcDir, wt.path, true); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "removed worktree %s\n", wt.path)
if deleteBranch {
if err := agent.GitDeleteBranch(wt.srcDir, wt.branch, true); err != nil {
if err := deleteLocalBranch(wt, forceBranch); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "deleted branch %s\n", wt.branch)
@@ -334,6 +596,34 @@ func removeWorktree(out io.Writer, wt managedWt, deleteBranch bool) error {
return agent.GitWorktreePrune(wt.srcDir)
}
// removeOrphanDir deletes a worktree directory whose backing repo is gone. git
// cannot act on it, so this is a plain delete — confined to the worktree root so
// a bad path can never reach a real checkout.
func removeOrphanDir(out io.Writer, wt managedWt) error {
wr, err := worktreeRoot()
if err != nil {
return err
}
if !underRoot(wt.path, wr) || sameDir(wt.path, wr) {
return fmt.Errorf("refusing to delete %s: not inside the worktree root %s", wt.path, wr)
}
if err := os.RemoveAll(wt.path); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "deleted orphaned worktree directory %s\n", wt.path)
return nil
}
// deleteLocalBranch tries the guarded delete first so git refuses to drop
// unmerged commits on its own; force is a fallback, never the first attempt.
func deleteLocalBranch(wt managedWt, force bool) error {
err := agent.GitDeleteBranch(wt.srcDir, wt.branch, false)
if err == nil || !force {
return err
}
return agent.GitDeleteBranch(wt.srcDir, wt.branch, true)
}
// --- clean ----------------------------------------------------------------
func newCleanCmd() *cobra.Command {
@@ -352,7 +642,7 @@ func newCleanCmd() *cobra.Command {
return nil
}
for _, w := range managed {
if err := removeWorktree(out, w, false); err != nil {
if err := removeWorktree(out, w, false, false); err != nil {
return err
}
}
+480
View File
@@ -0,0 +1,480 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
// Verdicts a worktree can be classified into.
const (
verdictKeep = "keep"
verdictRemove = "remove"
verdictRemoveBranch = "remove+branch"
)
// prLister is the slice of the Gitea client prune needs, so tests can drive
// classification without a live server.
type prLister interface {
ListPRs(repoPath, state string) ([]agent.PullRequest, error)
}
type pruneResult struct {
wt managedWt
verdict string
reason string
// proven records that git itself confirmed the branch's commits survive
// elsewhere; only then may a branch delete override git's own guard.
proven bool
}
// repoCtx is the per-repo state classification is decided against.
type repoCtx struct {
srcDir string
defBranch string
prs map[string]agent.PullRequest
prsKnown bool
// fetched records that this run's pruning fetch succeeded; without it an
// origin/<branch> ref may be stale and due for deletion, so it proves nothing.
fetched bool
// unfetched explains why, so a verdict can say which it was.
unfetched string
}
// pruneOpts is the knob set runPrune is driven by.
type pruneOpts struct {
apply bool
keepBranches bool
noFetch bool
jsonOut bool
includeKeep bool
includeUnmanaged bool
}
// reportEntry is the --json shape: one object per worktree, mirroring the table.
type reportEntry struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Path string `json:"path"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Managed bool `json:"managed"`
Applied bool `json:"applied"`
}
func newPruneCmd() *cobra.Command {
var opts pruneOpts
cmd := &cobra.Command{
Use: "prune",
Short: "Classify worktrees and remove the ones whose work is safely upstream",
Long: "prune inspects every worktree it can find — the managed ones under the worktree\nroot plus whatever `git worktree list` reports for the source checkouts — and\nclassifies each against git and its Gitea pull request. It reports and changes\nnothing unless --yes is given.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runPrune(cmd.OutOrStdout(), cmd.ErrOrStderr(), pruneClient(), opts)
},
}
f := cmd.Flags()
f.BoolVar(&opts.apply, "yes", false, "Actually remove worktrees (default is a dry run)")
f.BoolVar(&opts.keepBranches, "keep-branches", false, "Never delete a local branch, whatever the classification")
f.BoolVar(&opts.noFetch, "no-fetch", false, "Do not fetch; judge against the refs already on disk")
f.BoolVar(&opts.jsonOut, "json", false, "Emit JSON instead of a table")
f.BoolVar(&opts.includeKeep, "include-keep", false, "Dangerous: also remove worktrees classified keep (needs --yes). Destroys uncommitted changes and paused rebase/merge state, which no branch is carrying; only the branch itself survives")
f.BoolVar(&opts.includeUnmanaged, "include-unmanaged", false, "Also remove worktrees that live outside the worktree root")
return cmd
}
// pruneClient builds a Gitea client, falling back to anonymous access when no
// token can be minted; prune degrades to git-only signals if that fails too.
func pruneClient() prLister {
tok, err := agent.GiteaToken()
if err != nil {
tok = ""
}
return agent.NewGiteaClient(tok)
}
func runPrune(out, errOut io.Writer, prs prLister, opts pruneOpts) error {
// In JSON mode stdout carries the document alone, so notes go to stderr.
notes := out
if opts.jsonOut {
notes = errOut
}
worktrees, err := allWorktrees()
if err != nil {
return err
}
if len(worktrees) == 0 {
if opts.jsonOut {
_, _ = fmt.Fprintln(out, "[]")
return nil
}
_, _ = fmt.Fprintln(out, "no managed worktrees")
return nil
}
results := classifyAll(notes, prs, worktrees, opts)
if err := report(out, results, opts); err != nil {
return err
}
if !opts.apply {
if !opts.jsonOut {
_, _ = fmt.Fprintln(out, "dry run: nothing removed (pass --yes to apply)")
}
return nil
}
return applyPrune(notes, results, opts)
}
// classifyAll groups worktrees by source repo so each repo is fetched and its
// PRs listed once, then classifies every worktree against that repo's state.
func classifyAll(notes io.Writer, prs prLister, worktrees []managedWt, opts pruneOpts) []pruneResult {
byRepo := map[string][]managedWt{}
var results []pruneResult
for _, w := range worktrees {
switch {
case w.inspectErr != nil:
// Unknown is not gone: a checkout git refused to answer for keeps.
results = append(results, pruneResult{wt: w, verdict: verdictKeep, reason: "inspection failed: " + oneLine(w.inspectErr.Error())})
case w.orphan:
results = append(results, pruneResult{wt: w, verdict: verdictRemove, reason: "backing repo gone, no git state to read"})
default:
byRepo[w.srcDir] = append(byRepo[w.srcDir], w)
}
}
srcDirs := make([]string, 0, len(byRepo))
for dir := range byRepo {
srcDirs = append(srcDirs, dir)
}
sort.Strings(srcDirs)
for _, srcDir := range srcDirs {
ctx, err := newRepoCtx(notes, prs, srcDir, opts.noFetch)
if err != nil {
for _, w := range byRepo[srcDir] {
results = append(results, pruneResult{wt: w, verdict: verdictKeep, reason: "repo state unknown: " + oneLine(err.Error())})
}
continue
}
for _, w := range byRepo[srcDir] {
res, err := classify(w, ctx)
if err != nil {
res = pruneResult{wt: w, verdict: verdictKeep, reason: "inspection failed: " + oneLine(err.Error())}
}
results = append(results, res)
}
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].wt.repo != results[j].wt.repo {
return results[i].wt.repo < results[j].wt.repo
}
return results[i].wt.path < results[j].wt.path
})
return results
}
// report writes the classification as a table or as JSON.
func report(out io.Writer, results []pruneResult, opts pruneOpts) error {
if opts.jsonOut {
entries := make([]reportEntry, 0, len(results))
for _, r := range results {
entries = append(entries, reportEntry{
Repo: r.wt.repo,
Branch: r.wt.branch,
Path: r.wt.path,
Verdict: plannedVerdict(r, opts),
Reason: r.reason,
Managed: r.wt.managed,
Applied: opts.apply && willRemove(r, opts),
})
}
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// Padded with spaces only, so the table reads the same with or without a TTY.
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "REPO\tBRANCH\tPATH\tVERDICT\tREASON")
for _, r := range results {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
dash(r.wt.repo), dash(r.wt.branch), abbrevHome(r.wt.path), plannedVerdict(r, opts), r.reason)
}
return tw.Flush()
}
// oneLine flattens a git error onto a single line so one row stays one row.
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func dash(s string) string {
if s == "" {
return "-"
}
return s
}
// abbrevHome shortens $HOME to ~ so paths do not dominate the table.
func abbrevHome(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" || !strings.HasPrefix(path, home+string(filepath.Separator)) {
return path
}
return "~" + path[len(home):]
}
// applyPrune performs the removals the classification authorised, reporting each
// one and collecting failures so one bad worktree does not stop the rest.
func applyPrune(out io.Writer, results []pruneResult, opts pruneOpts) error {
var errs []error
for _, r := range results {
if !willRemove(r, opts) {
if !r.wt.managed && r.verdict != verdictKeep {
_, _ = fmt.Fprintf(out, "skipped %s: outside the worktree root (pass --include-unmanaged)\n", r.wt.path)
}
continue
}
if r.verdict == verdictKeep {
_, _ = fmt.Fprintf(out, "warn: removing %s despite %q (--include-keep)\n", r.wt.path, r.reason)
}
deleteBranch := plannedVerdict(r, opts) == verdictRemoveBranch
if err := removeWorktree(out, r.wt, deleteBranch, r.proven); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", r.wt.path, err))
}
}
return errors.Join(errs...)
}
// willRemove is the single gate on destruction: a keep verdict needs
// --include-keep, and a worktree outside the worktree root needs
// --include-unmanaged.
func willRemove(r pruneResult, opts pruneOpts) bool {
if !r.wt.managed && !opts.includeUnmanaged {
return false
}
if r.verdict == verdictKeep {
return opts.includeKeep
}
return true
}
// plannedVerdict is what will actually happen, so no flag prints an action it
// will not perform. A detached HEAD has no branch to delete, and a keep forced
// through with --include-keep never takes its branch with it.
func plannedVerdict(r pruneResult, opts pruneOpts) string {
if r.verdict != verdictRemoveBranch {
return r.verdict
}
if opts.keepBranches || r.wt.detached {
return verdictRemove
}
return verdictRemoveBranch
}
// newRepoCtx refreshes a source repo and collects the signals prune classifies
// against. A failed or skipped fetch and an unreachable Gitea are reported and
// tolerated: the signals that hold offline still work, and the rest are recorded
// as unverified.
func newRepoCtx(out io.Writer, prs prLister, srcDir string, noFetch bool) (repoCtx, error) {
ctx := repoCtx{srcDir: srcDir, prs: map[string]agent.PullRequest{}}
repo := filepath.Base(srcDir)
if noFetch {
ctx.unfetched = "fetch skipped"
_, _ = fmt.Fprintf(out, "warn: fetch %s skipped (--no-fetch, remote state unverified)\n", repo)
} else if err := agent.GitFetchPrune(srcDir, "origin", credentialHelperArgs()...); err != nil {
ctx.unfetched = "fetch failed"
_, _ = fmt.Fprintf(out, "warn: fetch %s: %v (remote state unverified)\n", repo, err)
} else {
ctx.fetched = true
}
def, err := agent.GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
return repoCtx{}, err
}
ctx.defBranch = def
if prs == nil {
return ctx, nil
}
list, err := prs.ListPRs(repoPath(srcDir, repo), "all")
switch {
case errors.Is(err, agent.ErrPRListTruncated):
// A branch missing from a partial listing must not read as "no PR".
_, _ = fmt.Fprintf(out, "warn: list PRs for %s: %v (older PRs unseen)\n", repo, err)
ctx.prs = prsByBranch(list)
case err != nil:
_, _ = fmt.Fprintf(out, "warn: list PRs for %s: %v (git signals only)\n", repo, err)
default:
ctx.prs = prsByBranch(list)
ctx.prsKnown = true
}
return ctx, nil
}
// repoPath is the Gitea "owner/repo" for a checkout, read from origin's URL
// because not every managed repo lives under AGENTWS_OWNER.
func repoPath(srcDir, repo string) string {
url, err := agent.GitRemoteURL(srcDir, "origin")
if err == nil && agent.RemoteHost(url) == giteaHost() {
if path, err := agent.RepoPathFromRemoteURL(url); err == nil {
return path
}
}
return owner() + "/" + repo
}
// prsByBranch indexes PRs by head branch, preferring an open PR and otherwise
// the most recent one when a branch has been used more than once.
func prsByBranch(list []agent.PullRequest) map[string]agent.PullRequest {
out := map[string]agent.PullRequest{}
for _, pr := range list {
branch := agent.PRHeadBranch(pr)
if branch == "" {
continue
}
if cur, ok := out[branch]; ok && !supersedes(pr, cur) {
continue
}
out[branch] = pr
}
return out
}
func supersedes(a, b agent.PullRequest) bool {
if a.IsOpen() != b.IsOpen() {
return a.IsOpen()
}
if a.Merged != b.Merged {
return a.Merged
}
return a.Number > b.Number
}
// headContainedIn reports whether the worktree's HEAD is reachable from ref. A
// ref that cannot be resolved proves nothing, so it reads as not contained.
func headContainedIn(dir, ref string) bool {
if ref == "" {
return false
}
ok, err := agent.GitIsAncestor(dir, "HEAD", ref)
return err == nil && ok
}
// classify applies the prune precedence. Removal must never destroy state that
// exists nowhere else: a vanished working tree is the one case with nothing to
// lose, a locked or mid-rebase checkout holds sequencer state git itself refuses
// to discard, and a dirty checkout or a detached HEAD with unique commits holds
// work no branch is carrying. Past those guards, provably-upstream work loses its
// branch too, and anything unproven keeps its branch so no commit becomes
// unreachable. A PR's state alone never authorises deleting a branch — git must
// confirm HEAD is contained in what merged or in what origin still holds, and
// origin's refs only count when this run's pruning fetch refreshed them.
func classify(wt managedWt, ctx repoCtx) (pruneResult, error) {
res := pruneResult{wt: wt}
if wt.missing {
res.verdict, res.reason = verdictRemove, "working tree gone, stale registration only"
return res, nil
}
if wt.locked {
res.verdict, res.reason = verdictKeep, "locked"
return res, nil
}
op, err := agent.GitInProgressOp(wt.path)
if err != nil {
return res, err
}
if op != "" {
res.verdict, res.reason = verdictKeep, op+" in progress"
return res, nil
}
dirty, err := agent.GitIsDirty(wt.path)
if err != nil {
return res, err
}
if dirty {
res.verdict, res.reason = verdictKeep, "dirty"
return res, nil
}
pr, hasPR := ctx.prs[wt.branch]
if hasPR && pr.IsOpen() {
res.verdict, res.reason = verdictKeep, fmt.Sprintf("PR open #%d", pr.Number)
return res, nil
}
upstream := "origin/" + ctx.defBranch
contained, err := agent.GitIsAncestor(wt.path, "HEAD", upstream)
if err != nil {
return res, err
}
if contained {
res.verdict, res.reason, res.proven = verdictRemoveBranch, "contained in "+upstream, true
return res, nil
}
unmerged, err := agent.GitUnmergedCommits(wt.path, upstream, "HEAD")
if err != nil {
return res, err
}
if unmerged == 0 {
// git cherry proves the patches reached that history, not that they stand at its tip.
res.verdict, res.reason, res.proven = verdictRemoveBranch, "patch-equivalent commits in "+upstream+" history", true
return res, nil
}
local, err := agent.GitCommitsNotOnRemotes(wt.path)
if err != nil {
return res, err
}
if wt.detached && local > 0 {
res.verdict, res.reason = verdictKeep, fmt.Sprintf("detached HEAD carrying %s on no remote", commitCount(local))
return res, nil
}
remote := "origin/" + wt.branch
onOrigin := hasPR && ctx.fetched && agent.GitRemoteBranchExists(ctx.srcDir, "origin", wt.branch)
switch {
case hasPR && pr.Merged && headContainedIn(wt.path, pr.Head.Sha):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR merged #%d, HEAD contained in the merged head", pr.Number), true
case hasPR && pr.Merged && onOrigin && headContainedIn(wt.path, remote):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR merged #%d, HEAD contained in %s", pr.Number, remote), true
case hasPR && pr.Merged && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, %s so %s is unverified", pr.Number, ctx.unfetched, remote)
case hasPR && pr.Merged:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR merged #%d, local commits not in the merged head", pr.Number)
case hasPR && onOrigin && headContainedIn(wt.path, remote):
res.verdict, res.reason, res.proven = verdictRemoveBranch, fmt.Sprintf("PR closed #%d, HEAD contained in %s", pr.Number, remote), true
case hasPR && onOrigin:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, local commits not on %s", pr.Number, remote)
case hasPR && !ctx.fetched:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, %s so %s is unverified", pr.Number, ctx.unfetched, remote)
case hasPR:
res.verdict, res.reason = verdictRemove, fmt.Sprintf("PR closed #%d, branch gone", pr.Number)
case ctx.prsKnown:
res.verdict, res.reason = verdictRemove, "no PR"
default:
res.verdict, res.reason = verdictRemove, "PR state unknown"
}
if res.verdict == verdictRemove && local > 0 {
res.reason += fmt.Sprintf(", %s on no remote so branch %s is kept", commitCount(local), wt.branch)
}
return res, nil
}
func commitCount(n int) string {
if n == 1 {
return "1 commit"
}
return fmt.Sprintf("%d commits", n)
}
File diff suppressed because it is too large Load Diff
+169
View File
@@ -3,6 +3,7 @@ package agent
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -97,6 +98,80 @@ func TestCreatePRRequestBody(t *testing.T) {
}
}
// An edit must send only the fields it was given: Gitea overwrites whatever
// key it receives, so an omitted --title arriving as "" would blank the title.
func TestEditPRSendsOnlySuppliedFields(t *testing.T) {
title, body, empty := "new title", "new body", ""
tests := []struct {
name string
opts EditPROptions
want map[string]any
}{
{"body only", EditPROptions{Body: &body}, map[string]any{"body": "new body"}},
{"title only", EditPROptions{Title: &title}, map[string]any{"title": "new title"}},
{"both", EditPROptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}},
{"explicit empty body is sent", EditPROptions{Body: &empty}, map[string]any{"body": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotBody map[string]any
var gotMethod, gotPath string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = io.WriteString(w, `{"number":7,"title":"new title","html_url":"https://git.unkin.net/unkin/repo/pulls/7"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
pr, err := c.EditPR("unkin/repo", 7, tt.opts)
if err != nil {
t.Fatalf("EditPR: %v", err)
}
if gotMethod != http.MethodPatch {
t.Errorf("method = %s, want PATCH", gotMethod)
}
if gotPath != "/api/v1/repos/unkin/repo/pulls/7" {
t.Errorf("path = %q", gotPath)
}
if len(gotBody) != len(tt.want) {
t.Errorf("payload = %v, want exactly the supplied fields %v", gotBody, tt.want)
}
for k, v := range tt.want {
if gotBody[k] != v {
t.Errorf("payload[%q] = %v, want %v", k, gotBody[k], v)
}
}
if pr.Number != 7 || pr.HTMLURL == "" {
t.Errorf("parsed PR = %+v", pr)
}
})
}
}
// A non-2xx must surface the API's own message rather than a bare status.
func TestEditPRAPIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"not found","url":"https://git.unkin.net/api/swagger","errors":null}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
title := "new title"
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
_, err := c.EditPR("unkin/repo", 7, EditPROptions{Title: &title})
if err == nil {
t.Fatal("expected error on 404")
}
if !strings.Contains(err.Error(), `"message":"not found"`) {
t.Errorf("error %q should carry the API message", err)
}
}
func TestCreateComment(t *testing.T) {
var gotBody map[string]string
mux := http.NewServeMux()
@@ -219,6 +294,100 @@ func TestFetchStateFailsOnNon404StatusError(t *testing.T) {
}
}
// Gitea rewrites head.ref to "refs/pull/<n>/head" once the PR's branch is
// deleted, which merging does in these repos. Matching a branch against
// head.ref alone therefore finds nothing for every merged PR; head.label keeps
// the original name.
func TestPRHeadBranch(t *testing.T) {
tests := []struct {
name string
ref, label string
want string
}{
{"merged, branch deleted", "refs/pull/12/head", "benvin/merged", "benvin/merged"},
{"open PR", "benvin/open", "benvin/open", "benvin/open"},
{"fully qualified ref", "refs/heads/benvin/x", "", "benvin/x"},
{"no label falls back to ref", "benvin/y", "", "benvin/y"},
{"cross-repo label", "benvin/z", "someone:benvin/z", "benvin/z"},
{"nothing usable", "refs/pull/12/head", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var pr PullRequest
pr.Head.Ref = tt.ref
pr.Head.Label = tt.label
if got := PRHeadBranch(pr); got != tt.want {
t.Errorf("PRHeadBranch(ref=%q,label=%q) = %q, want %q", tt.ref, tt.label, got, tt.want)
}
})
}
}
func TestListPRsPaginates(t *testing.T) {
var pages []string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
pages = append(pages, q.Get("page"))
if q.Get("state") != "all" {
t.Errorf("state = %q, want all", q.Get("state"))
}
if q.Get("page") == "1" {
full := make([]string, 0, prPageSize)
for i := 0; i < prPageSize; i++ {
full = append(full, fmt.Sprintf(`{"number":%d,"state":"closed","merged":true,"head":{"ref":"refs/pull/%d/head","label":"benvin/b%d"}}`, i+1, i+1, i+1))
}
_, _ = io.WriteString(w, "["+strings.Join(full, ",")+"]")
return
}
_, _ = io.WriteString(w, `[{"number":99,"state":"open","head":{"ref":"benvin/last","label":"benvin/last"}}]`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client()}
prs, err := c.ListPRs("unkin/repo", "all")
if err != nil {
t.Fatalf("ListPRs: %v", err)
}
if len(prs) != prPageSize+1 {
t.Fatalf("got %d PRs, want %d", len(prs), prPageSize+1)
}
if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" {
t.Errorf("pages requested = %v, want [1 2]", pages)
}
if got := PRHeadBranch(prs[0]); got != "benvin/b1" {
t.Errorf("first PR head branch = %q, want benvin/b1", got)
}
if !prs[len(prs)-1].IsOpen() {
t.Error("last PR should be open")
}
}
// A listing that fills every page is truncated: the caller must be told rather
// than treating a partial view as the whole repo.
func TestListPRsReportsTruncation(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
full := make([]string, 0, prPageSize)
for i := 0; i < prPageSize; i++ {
full = append(full, fmt.Sprintf(`{"number":%s,"state":"open"}`, r.URL.Query().Get("page")))
}
_, _ = io.WriteString(w, "["+strings.Join(full, ",")+"]")
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, HTTP: srv.Client()}
prs, err := c.ListPRs("unkin/repo", "all")
if !errors.Is(err, ErrPRListTruncated) {
t.Fatalf("ListPRs err = %v, want ErrPRListTruncated", err)
}
if len(prs) != maxPRPages*prPageSize {
t.Errorf("got %d PRs, want %d", len(prs), maxPRPages*prPageSize)
}
}
func TestGiteaAPIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
+132
View File
@@ -2,10 +2,12 @@ package agent
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -24,6 +26,10 @@ type Worktree struct {
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
@@ -67,6 +73,14 @@ func GitFetch(repoDir, remote string, globalArgs ...string) error {
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) {
@@ -83,6 +97,112 @@ func GitBranchExists(repoDir, branch string) bool {
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 {
@@ -210,6 +330,18 @@ func ParseWorktreeList(out string) []Worktree {
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()
+238 -2
View File
@@ -42,10 +42,20 @@ branch refs/heads/benvin/foo
worktree /home/ben/.cache/agentws/repo__detached
HEAD 3333333333333333333333333333333333333333
detached
worktree /home/ben/.cache/agentws/repo__gone
HEAD 4444444444444444444444444444444444444444
branch refs/heads/benvin/gone
prunable gitdir file points to non-existent location
worktree /home/ben/.cache/agentws/repo__held
HEAD 5555555555555555555555555555555555555555
branch refs/heads/benvin/held
locked
`
wts := ParseWorktreeList(out)
if len(wts) != 3 {
t.Fatalf("got %d worktrees, want 3: %+v", len(wts), wts)
if len(wts) != 5 {
t.Fatalf("got %d worktrees, want 5: %+v", len(wts), wts)
}
if wts[0].Branch != "main" || wts[0].Path != "/home/ben/src/prodenv/repo" {
t.Errorf("wt[0] = %+v", wts[0])
@@ -56,6 +66,104 @@ detached
if !wts[2].Detached || wts[2].Branch != "" {
t.Errorf("wt[2] = %+v, want detached with empty branch", wts[2])
}
if wts[3].Prunable != "gitdir file points to non-existent location" {
t.Errorf("wt[3].Prunable = %q", wts[3].Prunable)
}
if !wts[4].Locked || wts[4].Prunable != "" {
t.Errorf("wt[4] = %+v, want locked and not prunable", wts[4])
}
}
// A bare "prunable" with no reason still has to read as prunable.
func TestParseWorktreeListPrunableWithoutReason(t *testing.T) {
wts := ParseWorktreeList("worktree /tmp/wt\nHEAD 1111111111111111111111111111111111111111\ndetached\nprunable\n")
if len(wts) != 1 || wts[0].Prunable == "" {
t.Errorf("ParseWorktreeList = %+v, want one prunable worktree", wts)
}
}
func TestGitInProgressOp(t *testing.T) {
srcDir := newTempRepos(t)
gitDir, err := GitDir(srcDir)
if err != nil {
t.Fatalf("GitDir: %v", err)
}
if op, err := GitInProgressOp(srcDir); err != nil || op != "" {
t.Fatalf("clean checkout: op = %q, err = %v", op, err)
}
tests := []struct {
marker string
dir bool
want string
}{
{"MERGE_HEAD", false, "merge"},
{"CHERRY_PICK_HEAD", false, "cherry-pick"},
{"REVERT_HEAD", false, "revert"},
{"BISECT_LOG", false, "bisect"},
{"rebase-merge", true, "rebase"},
{"rebase-apply", true, "rebase"},
}
for _, tt := range tests {
path := filepath.Join(gitDir, tt.marker)
if tt.dir {
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(path, []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
op, err := GitInProgressOp(srcDir)
if err != nil || op != tt.want {
t.Errorf("%s: op = %q, err = %v; want %q", tt.marker, op, err, tt.want)
}
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
}
}
// Commits that reached a remote-tracking ref are not unique local work; commits
// made after the push are.
func TestGitCommitsNotOnRemotes(t *testing.T) {
srcDir := newTempRepos(t)
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 0 {
t.Fatalf("freshly cloned main: n = %d, err = %v; want 0", n, err)
}
if _, err := runGit(srcDir, "checkout", "-b", "benvin/x"); err != nil {
t.Fatal(err)
}
commitFile(t, srcDir, "a.txt", "a\n", "local a")
commitFile(t, srcDir, "b.txt", "b\n", "local b")
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 2 {
t.Fatalf("two unpushed commits: n = %d, err = %v; want 2", n, err)
}
if _, err := runGit(srcDir, "push", "origin", "benvin/x"); err != nil {
t.Fatal(err)
}
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 0 {
t.Fatalf("after push: n = %d, err = %v; want 0", n, err)
}
commitFile(t, srcDir, "c.txt", "c\n", "local c")
if n, err := GitCommitsNotOnRemotes(srcDir); err != nil || n != 1 {
t.Fatalf("one commit past the push: n = %d, err = %v; want 1", n, err)
}
}
func commitFile(t *testing.T, dir, name, content, msg string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "add", "."); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "commit", "-m", msg); err != nil {
t.Fatal(err)
}
}
// gitSeed sets a repo-local identity so commits work without global config.
@@ -185,6 +293,134 @@ func TestGitWorktreeLifecycle(t *testing.T) {
}
}
// commit writes a file and commits it, returning the new HEAD sha.
func commit(t *testing.T, dir, name, content, msg string) string {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(dir, "add", "."); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := runGit(dir, "commit", "-m", msg); err != nil {
t.Fatalf("commit: %v", err)
}
sha, err := runGit(dir, "rev-parse", "HEAD")
if err != nil {
t.Fatalf("rev-parse: %v", err)
}
return sha
}
func TestGitIsAncestor(t *testing.T) {
srcDir := newTempRepos(t)
base, err := runGit(srcDir, "rev-parse", "HEAD")
if err != nil {
t.Fatal(err)
}
tip := commit(t, srcDir, "a.txt", "a\n", "add a")
if ok, err := GitIsAncestor(srcDir, base, tip); err != nil || !ok {
t.Errorf("GitIsAncestor(base, tip) = %v, %v; want true", ok, err)
}
if ok, err := GitIsAncestor(srcDir, tip, base); err != nil || ok {
t.Errorf("GitIsAncestor(tip, base) = %v, %v; want false with no error", ok, err)
}
if _, err := GitIsAncestor(srcDir, "no-such-ref", tip); err == nil {
t.Error("GitIsAncestor with a bogus ref should error, not report false")
}
}
// These repos squash-merge, so merged work keeps its local SHA while the
// upstream commit is a different one carrying the same patch. `git cherry` must
// see that as merged even though the SHAs differ.
func TestGitUnmergedCommitsIgnoresRewrittenSHAs(t *testing.T) {
srcDir := newTempRepos(t)
if _, err := runGit(srcDir, "checkout", "-b", "feature"); err != nil {
t.Fatalf("checkout: %v", err)
}
commit(t, srcDir, "f.txt", "hello\n", "add f")
n, err := GitUnmergedCommits(srcDir, "origin/main", "HEAD")
if err != nil {
t.Fatalf("GitUnmergedCommits: %v", err)
}
if n != 1 {
t.Fatalf("unmerged before upstream landing = %d, want 1", n)
}
// Land the same patch upstream under a different SHA.
if _, err := runGit(srcDir, "checkout", "main"); err != nil {
t.Fatalf("checkout main: %v", err)
}
commit(t, srcDir, "f.txt", "hello\n", "squashed f")
if _, err := runGit(srcDir, "push", "origin", "main"); err != nil {
t.Fatalf("push: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if ok, err := GitIsAncestor(srcDir, "feature", "origin/main"); err != nil || ok {
t.Fatalf("squash-merged branch must not be an ancestor: %v, %v", ok, err)
}
n, err = GitUnmergedCommits(srcDir, "origin/main", "feature")
if err != nil {
t.Fatalf("GitUnmergedCommits: %v", err)
}
if n != 0 {
t.Errorf("unmerged after upstream landing = %d, want 0", n)
}
}
func TestGitIsDirty(t *testing.T) {
srcDir := newTempRepos(t)
if dirty, err := GitIsDirty(srcDir); err != nil || dirty {
t.Fatalf("clean checkout reported dirty=%v, err=%v", dirty, err)
}
if err := os.WriteFile(filepath.Join(srcDir, "scratch.txt"), []byte("wip\n"), 0o644); err != nil {
t.Fatal(err)
}
if dirty, err := GitIsDirty(srcDir); err != nil || !dirty {
t.Errorf("untracked file must count as dirty: dirty=%v, err=%v", dirty, err)
}
}
func TestGitRemoteBranchExists(t *testing.T) {
srcDir := newTempRepos(t)
if !GitRemoteBranchExists(srcDir, "origin", "main") {
t.Error("origin/main should exist")
}
if GitRemoteBranchExists(srcDir, "origin", "benvin/nope") {
t.Error("origin/benvin/nope should not exist")
}
if _, err := runGit(srcDir, "checkout", "-b", "benvin/pushed"); err != nil {
t.Fatalf("checkout: %v", err)
}
commit(t, srcDir, "p.txt", "p\n", "add p")
if _, err := runGit(srcDir, "push", "origin", "benvin/pushed"); err != nil {
t.Fatalf("push: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if !GitRemoteBranchExists(srcDir, "origin", "benvin/pushed") {
t.Error("pushed branch should have a remote-tracking ref")
}
if _, err := runGit(srcDir, "push", "origin", "--delete", "benvin/pushed"); err != nil {
t.Fatalf("delete remote branch: %v", err)
}
if err := GitFetchPrune(srcDir, "origin"); err != nil {
t.Fatalf("GitFetchPrune: %v", err)
}
if GitRemoteBranchExists(srcDir, "origin", "benvin/pushed") {
t.Error("a pruning fetch must drop the tracking ref for a deleted remote branch")
}
}
// resolve canonicalizes a path (temp dirs may live behind symlinks like /var).
func resolve(t *testing.T, p string) string {
t.Helper()
+77 -1
View File
@@ -137,10 +137,68 @@ type PullRequest struct {
Mergeable bool `json:"mergeable"`
HTMLURL string `json:"html_url"`
Head struct {
Sha string `json:"sha"`
Sha string `json:"sha"`
Ref string `json:"ref"`
Label string `json:"label"`
} `json:"head"`
}
// prPageSize is the per-page limit for the pulls listing; maxPRPages caps how
// far back a listing walks.
const (
prPageSize = 50
maxPRPages = 20
)
// ErrPRListTruncated reports that a listing hit the page cap, so the returned
// pull requests are only the most recent ones and older PRs went unseen.
var ErrPRListTruncated = errors.New("pull request listing truncated at the page cap")
// ListPRs lists a repo's pull requests in the given state ("open", "closed" or
// "all"), following pagination. A repo with more PRs than the page cap returns
// the PRs it did read alongside ErrPRListTruncated.
func (c *GiteaClient) ListPRs(repoPath, state string) ([]PullRequest, error) {
if state == "" {
state = "all"
}
var all []PullRequest
for page := 1; page <= maxPRPages; page++ {
var batch []PullRequest
path := fmt.Sprintf("/api/v1/repos/%s/pulls?state=%s&limit=%d&page=%d", repoPath, state, prPageSize, page)
if err := c.do(http.MethodGet, path, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < prPageSize {
return all, nil
}
}
return all, fmt.Errorf("%s: %w after %d pull requests", repoPath, ErrPRListTruncated, len(all))
}
// PRHeadBranch returns the branch a PR was opened from. Gitea rewrites head.ref
// to "refs/pull/<n>/head" once the branch is deleted (which merging does), so
// head.label — which keeps the original name — is authoritative.
func PRHeadBranch(pr PullRequest) string {
if label := pr.Head.Label; label != "" && !strings.HasPrefix(label, "refs/pull/") {
// Cross-repo PRs label as "<owner>:<branch>".
if _, branch, ok := strings.Cut(label, ":"); ok {
return branch
}
return label
}
ref := pr.Head.Ref
if strings.HasPrefix(ref, "refs/pull/") {
return ""
}
return strings.TrimPrefix(ref, "refs/heads/")
}
// IsOpen reports whether a PR is still open (not merged, not closed).
func (pr PullRequest) IsOpen() bool {
return pr.State == "open" && !pr.Merged
}
// CreatePROptions are the fields for opening a PR.
type CreatePROptions struct {
Base string `json:"base"`
@@ -156,6 +214,24 @@ func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullReque
return pr, err
}
// EditPROptions are the fields an edit may change. Pointers so an unset field
// is omitted from the payload entirely, leaving that field as it is. The two
// fields are not symmetric: Gitea only applies a title when it is non-empty,
// so Title can be set but never cleared and a "" title is a silent no-op,
// while a pointer to "" Body really does blank the body.
type EditPROptions struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
// EditPR updates a pull request's title and/or body
// (PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}).
func (c *GiteaClient) EditPR(repoPath string, number int, opts EditPROptions) (PullRequest, error) {
var pr PullRequest
err := c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), opts, &pr)
return pr, err
}
// GetPR fetches a single pull request.
func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
var pr PullRequest
+46
View File
@@ -68,6 +68,52 @@ func ParseDurationFlag(flag, value string) (time.Duration, error) {
return d, nil
}
// RemoteHost returns the host a git remote URL points at, or "" for a local
// path remote.
func RemoteHost(remote string) string {
s := strings.TrimSpace(remote)
if _, after, ok := strings.Cut(s, "://"); ok {
host, _, _ := strings.Cut(after, "/")
if _, bare, ok := strings.Cut(host, "@"); ok {
host = bare
}
return host
}
if strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") {
return ""
}
host, _, ok := strings.Cut(s, ":")
if !ok {
return ""
}
if _, bare, ok := strings.Cut(host, "@"); ok {
host = bare
}
return host
}
// RepoPathFromRemoteURL extracts the "owner/repo" API path from a git remote
// URL, accepting both https and scp-style ssh forms.
func RepoPathFromRemoteURL(remote string) (string, error) {
s := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(remote), "/"), ".git")
switch {
case strings.Contains(s, "://"):
_, after, _ := strings.Cut(s, "://")
_, path, ok := strings.Cut(after, "/")
if !ok {
return "", fmt.Errorf("remote URL %q has no repo path", remote)
}
s = path
case strings.Contains(s, ":"):
_, s, _ = strings.Cut(s, ":")
}
parts := strings.Split(strings.Trim(s, "/"), "/")
if len(parts) < 2 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" {
return "", fmt.Errorf("remote URL %q is not owner/repo shaped", remote)
}
return parts[len(parts)-2] + "/" + parts[len(parts)-1], nil
}
// ParseRepo validates and splits an "owner/repo" string.
func ParseRepo(s string) (owner, repo string, err error) {
s = strings.TrimSpace(s)
+49
View File
@@ -132,3 +132,52 @@ func TestParseDurationFlagErrorMessage(t *testing.T) {
}
}
}
// Not every managed repo lives under the default owner, so the API path comes
// from origin's URL rather than the directory name.
func TestRepoPathFromRemoteURL(t *testing.T) {
tests := []struct {
in string
want string
}{
{"https://git.unkin.net/unkin/agent-tools.git", "unkin/agent-tools"},
{"https://git.unkin.net/unkinben/dotfiles.git", "unkinben/dotfiles"},
{"https://git.unkin.net/unkin/agent-tools", "unkin/agent-tools"},
{"https://user@git.unkin.net/unkin/agent-tools.git", "unkin/agent-tools"},
{"ssh://git@git.unkin.net:2222/unkin/agent-tools.git", "unkin/agent-tools"},
{"git@git.unkin.net:unkin/agent-tools.git", "unkin/agent-tools"},
}
for _, tt := range tests {
got, err := RepoPathFromRemoteURL(tt.in)
if err != nil {
t.Errorf("RepoPathFromRemoteURL(%q): %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("RepoPathFromRemoteURL(%q) = %q, want %q", tt.in, got, tt.want)
}
}
for _, bad := range []string{"", "https://git.unkin.net", "agent-tools"} {
if got, err := RepoPathFromRemoteURL(bad); err == nil {
t.Errorf("RepoPathFromRemoteURL(%q) = %q, want error", bad, got)
}
}
}
func TestRemoteHost(t *testing.T) {
tests := []struct {
in, want string
}{
{"https://git.unkin.net/unkin/repo.git", "git.unkin.net"},
{"https://user@git.unkin.net/unkin/repo.git", "git.unkin.net"},
{"ssh://git@git.unkin.net:2222/unkin/repo.git", "git.unkin.net:2222"},
{"git@git.unkin.net:unkin/repo.git", "git.unkin.net"},
{"/tmp/fixture/origin.git", ""},
{"../other/origin.git", ""},
}
for _, tt := range tests {
if got := RemoteHost(tt.in); got != tt.want {
t.Errorf("RemoteHost(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}