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
agent-tools
Small Gitea-automation CLIs, shipped together in one RPM (agent-tools). They
act as an agent user (unkin-agent by default) by minting a scoped Gitea
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.watchpr— poll one or more PRs and exit when one changes in a way worth acting on.agentws— manage per-branch git worktrees forunkin-agent, cloning into Ben's source checkout and isolating agent work under the XDG cache.agentvault— run deterministic Vault flows in one invocation, so agents never plumb secret material through a shell.
How it gets a token
On first use each tool performs a Vault AppRole login (role_id only, no
secret_id), then reads gitea/creds/$AGENT_LOGIN — or GITEA_CREDS_PATH when
set — to obtain a short-lived Gitea token, cached in-process for the run. With
neither variable set that is gitea/creds/unkin-agent, as before.
Everything is configured by environment variables, all with defaults:
| Variable | Default | Purpose |
|---|---|---|
VAULT_ADDR |
https://vault.service.consul:8200 |
Vault/OpenBao address |
AGENT_APPROLE_ROLE_ID |
built-in default | AppRole role_id (overridable) |
GITEA_URL |
https://git.unkin.net |
Gitea base URL |
AGENT_LOGIN |
unkin-agent |
agent identity: selects gitea/creds/<login>, and the login whose comments watchpr ignores |
GITEA_CREDS_PATH |
gitea/creds/$AGENT_LOGIN |
Vault path minting the Gitea token (wins over AGENT_LOGIN) |
AGENTWS_SRC_ROOT |
~/src/prodenv |
source-of-truth checkout root (agentws) |
AGENTWS_ROOT |
~/.cache/agentws |
worktree root (agentws) |
AGENTWS_OWNER |
unkin |
Gitea org that owns the repos (agentws) |
AUTHENTIK_URL |
https://identity.k8s.syd1.au.unkin.net |
Authentik base URL (agentvault) |
agentpr
# Verify identity (prints the agent login, unkin-agent by default)
agentpr whoami
# Open a PR
agentpr pr create --repo unkin/argocd-apps \
--base main --head benvin/my-change \
--title "Add woodpecker SA" --body "Adds the ServiceAccount ..."
# prints: #<number> <html_url>
# Comment on a PR
agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green."
agentpr --version
agentpr --help
Non-zero exit on any API error.
watchpr
Poll PRs and exit (reporting what changed) when a tracked PR merges/closes,
gets a new comment from someone other than the agent, its CI fails
(failure/error), or it loses mergeability (a conflict appears). Benign
transitions — CI pending→success, the agent's own comments — are ignored.
# Watch until something meaningful happens (default interval 60s)
watchpr unkin/argocd-apps#42
# Multiple PRs, custom interval; refs accept #N or :N
watchpr --interval 30s unkin/argocd-apps#42 unkin/terraform-vault:98
# --interval takes a duration (30s, 2m, 1h30m) or a bare number of seconds
watchpr --interval 30 unkin/argocd-apps#42
# One-shot: print current state and exit 0 (great for scripts)
watchpr --once unkin/argocd-apps#42
watchpr --once --json unkin/argocd-apps#42
On a meaningful change watchpr prints the reason and the PR's current state,
then exits 0. Use --json for machine-readable output.
agentws
agentws gives an agent an isolated git worktree per branch without disturbing
Ben's shared checkouts. Repos are cloned into the source root
(~/src/prodenv/<repo>) so branches created here are visible in the main
checkout too; the worktrees themselves live under the worktree root
(~/.cache/agentws/<repo>__<branch>).
# Clone unkin/argocd-apps into ~/src/prodenv if missing, then add a worktree for
# a new branch off the remote default branch. Prints the worktree path.
agentws new argocd-apps --branch benvin/my-change
# Branch off a specific base instead of the remote default
agentws new argocd-apps --branch benvin/hotfix --from release-1.2
# List managed worktrees (repo, branch, path)
agentws list
# Remove a worktree (by path or branch); refreshes the source repo afterwards
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
# Print a fresh Gitea token for the agent login
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
persists one in a remote URL or in git config. Instead it wires itself as an
ephemeral git credential helper:
agentws tokenprints a fresh token to stdout (handy for scripts).agentws credential getspeaks the git credential protocol on stdin and, for the configured Gitea host only, emitsusername=$AGENT_LOGIN+password=<fresh token>.
agentws new sets this up per worktree without touching the shared checkout: it
enables extensions.worktreeConfig on the repo once, then writes
user.name / user.email and credential.helper = !<agentws> credential to
the per-worktree config. Clone/fetch use the same helper via a transient
-c credential.helper=...; the shared origin URL is left clean. On worktree
removal agentws fetches in ~/src/prodenv/<repo> so its default branch stays
current.
agentvault
Deterministic Vault flows, each a single self-contained invocation: the tool reads and writes the secrets itself, and prints only identifiers.
seed-outpost
Copy an Authentik outpost's token into Vault KV-v2. agentvault reads the
Authentik API token from kv/service/authentik/agent-api-token, resolves the
named outpost's token_identifier, fetches its key via
/api/v3/core/tokens/<identifier>/view_key/ and writes it to the destination
KV path. The token value is never printed or logged.
agentvault seed-outpost \
--outpost k8s-outpost \
--dest-path kubernetes/namespace/authentik/default/outpost-token
outpost: k8s-outpost
token_identifier: ak-outpost-k8s-outpost
dest: kv/kubernetes/namespace/authentik/default/outpost-token
version: 3
Re-running is safe: it writes a new KV version. Flags: --outpost and
--dest-path are required; --dest-key (default token), --kv-mount
(default kv), --token-path (default service/authentik/agent-api-token) and
--authentik-url override the rest.
Errors name the failing stage: AppRole login, KV read denied (policy not
applied), outpost not found (terraform not applied), view_key failure, or KV
write denied.
seed-oauth
Make a Vault KV-v2 path hold a complete oauth2-proxy credential set. It is a
read-modify-write: client_id is set from the flag, client_secret and
cookie_secret are generated (32 bytes from crypto/rand) only when missing,
every other key on the path is written back untouched, and nothing is written
at all when the secret is already correct. cookie_secret is base64url so it
decodes to exactly the 32 bytes oauth2-proxy requires.
agentvault seed-oauth \
--path kubernetes/namespace/repospawner/default/oauth-credentials \
--client-id 4f1c…
path: kv/kubernetes/namespace/repospawner/default/oauth-credentials
keys: client_id, client_secret, cookie_secret
client_id: created
client_secret: kept
cookie_secret: created
version: 4
That is the common case: the provider's client_secret already lives on the
path, so only the missing keys are added. A run with nothing to do prints
version: unchanged and issues no write.
Flags: --path and --client-id are required; --kv-mount (default kv) and
--rotate override the rest. --rotate regenerates both secrets — only use it
when the IdP provider's secret is being rotated alongside, since a rotated
client_secret no longer matches the provider.
Errors name the failing stage: AppRole login, KV read denied, or KV write denied. Only key names, actions and the KV version are printed.
Build & package
make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault
make test # go test -race ./...
make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm
Release is tag-driven (v*) via Woodpecker: builds the RPM, PUTs it to the
artifactapi rpm-internal yum repo, and cuts a Gitea release with
cross-compiled binaries attached.
Version bump
make patch # or: make minor / make major — tags vX.Y.Z and pushes the tag