Add agentws worktree-management binary
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful

agentws manages per-branch git worktrees for the unkin-agent user: it clones
repos into the source root (~/src/prodenv/<repo>) so branches are visible in
Ben's main checkout, and creates isolated worktrees under the worktree root
(~/.cache/agentws/<repo>__<branch>).

- New internal/agent/git.go: small, testable git helpers shelling out to the
  git binary (clone/fetch/worktree add/remove/list/prune, branch + config ops,
  porcelain parsing, path sanitizing). No go-git dependency.
- New cmd/agentws: new / list / rm / clean / token / credential subcommands.
  Auth uses an ephemeral git credential helper (agentws credential get) so the
  ~1h Gitea token is never persisted in a remote URL or config; per-worktree
  config keeps the shared checkout's identity untouched.
- Wire agentws into Makefile, scripts/build-rpm.sh, packaging/nfpm.yaml (binary
  + bash/zsh/fish completions), .woodpecker/release.yaml (cross-compile + assets)
  and .gitignore.
- Tests: table tests for parsing/sanitizing/dir-naming, a real temp-git repo for
  the worktree lifecycle, and hermetic cmd tests (bad input + credential-helper
  host guard) that never touch the network.
- Document agentws in README.md and AGENTS.md.
This commit is contained in:
2026-08-15 12:21:08 +10:00
parent c6712063bc
commit 6a82b88947
11 changed files with 1069 additions and 22 deletions
+2
View File
@@ -1,7 +1,9 @@
# built binaries (repo root only — not the cmd/ source dirs) # built binaries (repo root only — not the cmd/ source dirs)
/agentpr /agentpr
/watchpr /watchpr
/agentws
# cross-compiled release artifacts (e.g. agentpr-linux-amd64) # cross-compiled release artifacts (e.g. agentpr-linux-amd64)
/agentpr-* /agentpr-*
/watchpr-* /watchpr-*
/agentws-*
dist/ dist/
+2 -2
View File
@@ -28,7 +28,7 @@ steps:
# for the shell instead of substituting them (as pipeline vars) at parse # for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. # time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- | - |
for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr"; do for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr" "agentws:./cmd/agentws"; do
name="$${entry%%:*}"; pkg="$${entry##*:}" name="$${entry%%:*}"; pkg="$${entry##*:}"
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}" os="$${osarch%/*}"; arch="$${osarch#*/}"
@@ -135,7 +135,7 @@ steps:
# root; the package step writes the RPM to dist/. Generate a checksums # root; the package step writes the RPM to dist/. Generate a checksums
# manifest over everything we attach so downloads can be verified. # manifest over everything we attach so downloads can be verified.
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64" ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64 agentws-linux-amd64 agentws-linux-arm64 agentws-darwin-amd64 agentws-darwin-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
+36 -10
View File
@@ -2,8 +2,8 @@
## Project Overview ## Project Overview
This repo ships two Gitea-automation CLIs in one RPM (`agent-tools`). Both act This repo ships several Gitea-automation CLIs in one RPM (`agent-tools`). They
as the `unkin-agent` user by minting a scoped Gitea token from Vault, so act as the `unkin-agent` user by minting a scoped Gitea token from Vault, so
actions are attributed to the agent rather than to whoever runs the tool. actions are attributed to the agent rather than to whoever runs the tool.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent` - **`agentpr`** — create pull requests and post PR comments as `unkin-agent`
@@ -13,25 +13,32 @@ actions are attributed to the agent rather than to whoever runs the tool.
meaningfully: it merges/closes, gets a new non-agent comment, its CI fails, 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 or it loses mergeability. Benign transitions (CI pending→success, the agent's
own comments) are ignored. own comments) are ignored.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`. Clones
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`.
Both tools are separate `main` packages under `cmd/` and share the All tools are separate `main` packages under `cmd/` and share the
`internal/agent` package (Vault AppRole login, Gitea REST client, PR-ref `internal/agent` package (Vault AppRole login, Gitea REST client, PR-ref
parsing, watch-state comparison). parsing, watch-state comparison, git worktree helpers).
## Structure ## Structure
``` ```
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami) cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami)
cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit) cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit)
cmd/agentws/main.go # agentws CLI (new / list / rm / clean / token / credential)
internal/agent/ # shared plumbing: internal/agent/ # shared plumbing:
token.go # env config + in-process Gitea-token cache token.go # env config + in-process Gitea-token cache
vault.go # AppRole login + read gitea/creds/unkin-agent vault.go # AppRole login + read gitea/creds/unkin-agent
gitea.go # Gitea REST client (PR create/get, comments, status, whoami) gitea.go # Gitea REST client (PR create/get, comments, status, whoami)
parse.go # owner/repo#N and owner/repo parsing parse.go # owner/repo#N and owner/repo parsing
watch.go # PRState snapshot + MeaningfulChange comparison watch.go # PRState snapshot + MeaningfulChange comparison
git.go # git worktree/clone/fetch helpers (os/exec, no go-git)
go.mod # module git.unkin.net/unkin/agent-tools go.mod # module git.unkin.net/unkin/agent-tools
Makefile # build / test / lint / completions / rpm / version-bump Makefile # build / test / lint / completions / rpm / version-bump
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (both binaries) packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (all binaries)
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag) .woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
dist/ # build output: binaries, completions, RPM (not committed) dist/ # build output: binaries, completions, RPM (not committed)
@@ -42,7 +49,7 @@ own `-o` (a single `go build ./...` can't emit multiple mains to one file).
## Token acquisition (shared) ## Token acquisition (shared)
Both tools call `agent.GiteaToken()`, which (once per process): All tools call `agent.GiteaToken()`, which (once per process):
1. AppRole login: `POST $VAULT_ADDR/v1/auth/approle/login` with `role_id` only 1. AppRole login: `POST $VAULT_ADDR/v1/auth/approle/login` with `role_id` only
(no `secret_id`) → `client_token`. (no `secret_id`) → `client_token`.
@@ -55,12 +62,30 @@ Config via env (all have defaults):
| `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address | | `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address |
| `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments watchpr ignores | | `AGENT_LOGIN` | `unkin-agent` | login whose comments watchpr ignores; agentws git identity |
| `AGENTWS_SRC_ROOT` | `~/src/prodenv` | agentws source-of-truth checkout root |
| `AGENTWS_ROOT` | `~/.cache/agentws` | agentws worktree root |
| `AGENTWS_OWNER` | `unkin` | Gitea org that owns agentws-managed repos |
### agentws git auth (ephemeral credential helper)
Gitea tokens are ~1h ephemeral, so `agentws` never bakes one into a remote URL
or config. `agentws token` prints a fresh token; `agentws credential get`
implements the git credential protocol (reads the key=value request on stdin,
and for the configured Gitea host only emits `username=unkin-agent` +
`password=<fresh token>`). `agentws new` wires this per worktree — it enables
`extensions.worktreeConfig` on the repo once, then writes `user.name`,
`user.email` and `credential.helper = !<agentws> credential` to the
**per-worktree** config so the shared checkout's identity/config is untouched.
Clone/fetch pass the same helper transiently via `-c credential.helper=...`.
Worktrees are created FROM `~/src/prodenv/<repo>` (`git worktree add`) so agent
branches are visible in Ben's main checkout; `rm`/`clean` fetch there afterwards
to keep the default branch current.
## Build ## Build
```bash ```bash
make build # -> dist/agentpr, dist/watchpr (CGO disabled, static) make build # -> dist/agentpr, dist/watchpr, dist/agentws (CGO disabled, static)
``` ```
Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI). Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI).
@@ -68,11 +93,12 @@ Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI).
## Packaging (RPM) ## Packaging (RPM)
```bash ```bash
make rpm # build both binaries + package into dist/*.rpm via nfpm make rpm # build all binaries + package into dist/*.rpm via nfpm
``` ```
`scripts/build-rpm.sh` generates bash/zsh/fish completions from the built `scripts/build-rpm.sh` generates bash/zsh/fish completions from the built
binaries and bundles them alongside `/usr/bin/agentpr` and `/usr/bin/watchpr`. binaries and bundles them alongside `/usr/bin/agentpr`, `/usr/bin/watchpr` and
`/usr/bin/agentws`.
On a `v*` tag the release pipeline builds the RPM and `PUT`s it to the On a `v*` tag the release pipeline builds the RPM and `PUT`s it to the
artifactapi `rpm-internal` repo, then cuts a Gitea release. artifactapi `rpm-internal` repo, then cuts a Gitea release.
+1 -1
View File
@@ -1,6 +1,6 @@
# All shipped binaries and the package path each is built from. Both tools live # All shipped binaries and the package path each is built from. Both tools live
# under cmd/; the module root ships no binary of its own. # under cmd/; the module root ships no binary of its own.
BINARIES := agentpr watchpr BINARIES := agentpr watchpr agentws
DIST := dist DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
+59 -5
View File
@@ -1,13 +1,15 @@
# agent-tools # agent-tools
Two small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They
Both act as the **`unkin-agent`** user by minting a scoped Gitea token from act as the **`unkin-agent`** user by minting a scoped Gitea token from Vault, so
Vault, so automated PRs and comments are attributed to the agent — not to automated PRs, comments and pushes are attributed to the agent — not to whoever
whoever happens to run the command. happens to run the command.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent`. - **`agentpr`** — create pull requests and post PR comments as `unkin-agent`.
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth - **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
acting on. acting on.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning
into Ben's source checkout and isolating agent work under the XDG cache.
## How it gets a token ## How it gets a token
@@ -23,6 +25,9 @@ Everything is configured by environment variables, all with defaults:
| `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments `watchpr` ignores | | `AGENT_LOGIN` | `unkin-agent` | login whose comments `watchpr` ignores |
| `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`) |
## agentpr ## agentpr
@@ -67,10 +72,59 @@ watchpr --once --json unkin/argocd-apps#42
On a meaningful change `watchpr` prints the reason and the PR's current state, On a meaningful change `watchpr` prints the reason and the PR's current state,
then exits 0. Use `--json` for machine-readable output. 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>`).
```bash
# 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
# Remove every managed worktree and prune each source repo
agentws clean
# Print a fresh unkin-agent Gitea token
agentws token
```
### 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 token` prints a fresh token to stdout (handy for scripts).
- `agentws credential get` speaks the git credential protocol on stdin and, for
the configured Gitea host only, emits `username=unkin-agent` +
`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.
## Build & package ## Build & package
```bash ```bash
make build # -> dist/agentpr, dist/watchpr make build # -> dist/agentpr, dist/watchpr, dist/agentws
make test # go test -race ./... make test # go test -race ./...
make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm
``` ```
+458
View File
@@ -0,0 +1,458 @@
// Command agentws (agentic workspace) manages git worktrees for the unkin-agent
// user so agents can work on isolated branches without disturbing Ben's shared
// checkouts.
//
// Repositories are cloned into the source root (default ~/src/prodenv/<repo>) so
// branches created here are visible in the main checkout too. Worktrees live
// under the worktree root (default ~/.cache/agentws/<repo>__<branch>). Auth for
// clone/fetch/push comes from a short-lived Gitea token minted from Vault via
// agent.GiteaToken(); it is supplied through an ephemeral git credential helper
// (`agentws credential get`) rather than being persisted in any remote URL or
// config, since the tokens expire in about an hour.
//
// agentws new <repo> [--branch benvin/<name>] [--from <base-branch>]
// agentws list
// agentws rm <path-or-branch> [--delete-branch]
// agentws clean
// agentws token
// agentws credential get # git credential-helper protocol on stdin
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the agentws command tree. Separated from main so tests can
// invoke Execute and assert behaviour without spawning a process.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "agentws",
Short: "Manage git worktrees for the unkin-agent user.",
Long: "agentws manages per-branch git worktrees for unkin-agent. Repos are cloned into\nthe source root (~/src/prodenv) and worktrees live under the worktree root\n(~/.cache/agentws), authenticated by an ephemeral Vault-minted Gitea token.",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(
newNewCmd(),
newListCmd(),
newRmCmd(),
newCleanCmd(),
newTokenCmd(),
newCredentialCmd(),
newVersionCmd(),
)
return root
}
// --- configuration (env-overridable) --------------------------------------
// srcRoot is where source-of-truth checkouts live (default ~/src/prodenv).
func srcRoot() (string, error) {
if v := os.Getenv("AGENTWS_SRC_ROOT"); v != "" {
return v, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "src", "prodenv"), nil
}
// worktreeRoot is where managed worktrees live (default ~/.cache/agentws).
func worktreeRoot() (string, error) {
if v := os.Getenv("AGENTWS_ROOT"); v != "" {
return v, nil
}
cache, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cache, "agentws"), nil
}
// owner is the Gitea org that owns the repos (default unkin).
func owner() string {
if v := os.Getenv("AGENTWS_OWNER"); v != "" {
return v
}
return "unkin"
}
// cloneURL builds the (token-free) HTTPS clone URL for a repo.
func cloneURL(repo string) string {
return strings.TrimRight(agent.GiteaURL(), "/") + "/" + owner() + "/" + repo + ".git"
}
// credentialHelperArgs returns git global args that wire this binary as an
// ephemeral credential helper, so clone/fetch/push authenticate without
// persisting a token anywhere.
func credentialHelperArgs() []string {
exe, err := os.Executable()
if err != nil || exe == "" {
exe = "agentws"
}
return []string{"-c", "credential.helper=!" + exe + " credential"}
}
// --- new ------------------------------------------------------------------
func newNewCmd() *cobra.Command {
var branch, from string
cmd := &cobra.Command{
Use: "new <repo>",
Short: "Clone (if needed) and create a worktree for a branch",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
repo := strings.TrimSpace(args[0])
if repo == "" || strings.Contains(repo, "/") {
return fmt.Errorf("repo must be a bare repository name (owner comes from AGENTWS_OWNER, default %q)", owner())
}
if branch == "" {
return fmt.Errorf("--branch is required (e.g. benvin/<name>)")
}
sr, err := srcRoot()
if err != nil {
return err
}
wr, err := worktreeRoot()
if err != nil {
return err
}
srcDir := filepath.Join(sr, repo)
auth := credentialHelperArgs()
// a. Clone the source-of-truth checkout if missing.
if _, statErr := os.Stat(srcDir); statErr != nil {
if !os.IsNotExist(statErr) {
return statErr
}
fmt.Fprintf(cmd.OutOrStdout(), "cloning %s into %s\n", cloneURL(repo), srcDir)
if err := agent.GitClone(cloneURL(repo), srcDir, auth...); err != nil {
return err
}
}
// b. Refresh so the base branch is current.
if err := agent.GitFetch(srcDir, "origin", auth...); err != nil {
return err
}
// c. Base branch: --from or the remote default.
base := from
if base == "" {
base, err = agent.GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
return err
}
}
// d. Create the worktree FROM the source checkout so the branch is
// visible in the main checkout too.
wtPath := filepath.Join(wr, agent.WorktreeDirName(repo, branch))
if _, statErr := os.Stat(wtPath); statErr == nil {
return fmt.Errorf("worktree already exists at %s", wtPath)
}
if err := agent.GitWorktreeAdd(srcDir, wtPath, branch, "origin/"+base); err != nil {
return err
}
// e. Set the agent identity + auth WITHOUT polluting the shared
// checkout: per-worktree config only.
if err := agent.GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
return err
}
if err := agent.GitConfigSet(wtPath, true, "user.name", agent.AgentLogin()); err != nil {
return err
}
if err := agent.GitConfigSet(wtPath, true, "user.email", agent.AgentLogin()+"@unkin.net"); err != nil {
return err
}
exe, _ := os.Executable()
if exe == "" {
exe = "agentws"
}
if err := agent.GitConfigSet(wtPath, true, "credential.helper", "!"+exe+" credential"); err != nil {
return err
}
// f. Report the worktree path and branch.
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", wtPath)
fmt.Fprintf(cmd.OutOrStdout(), "branch %s (from origin/%s)\n", branch, base)
return nil
},
}
f := cmd.Flags()
f.StringVar(&branch, "branch", "", "Branch to check out/create (e.g. benvin/<name>) (required)")
f.StringVar(&from, "from", "", "Base branch to branch from (default: remote default branch)")
_ = cmd.MarkFlagRequired("branch")
return cmd
}
// --- list -----------------------------------------------------------------
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List managed worktrees under the worktree root",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
managed, err := managedWorktrees()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(managed) == 0 {
fmt.Fprintln(out, "no managed worktrees")
return nil
}
for _, w := range managed {
fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, w.branch, w.path)
}
return nil
},
}
}
// managedWt describes one worktree living under the worktree root.
type managedWt struct {
repo string
branch string
path string
srcDir string
}
// 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).
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(wr)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []managedWt
for _, e := range entries {
if !e.IsDir() {
continue
}
path := filepath.Join(wr, e.Name())
branch, err := agent.GitCurrentBranch(path)
if err != nil {
continue // not a git worktree; skip
}
srcDir, err := agent.SourceRepoDir(path)
if err != nil {
continue
}
out = append(out, managedWt{
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
})
}
return out, nil
}
// --- rm -------------------------------------------------------------------
func newRmCmd() *cobra.Command {
var deleteBranch bool
cmd := &cobra.Command{
Use: "rm <path-or-branch>",
Short: "Remove a managed worktree and refresh its source repo",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
target := strings.TrimSpace(args[0])
wt, err := resolveWorktree(target)
if err != nil {
return err
}
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch)
},
}
cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Also delete the local branch after removing the worktree")
return cmd
}
// resolveWorktree finds a managed worktree by exact path or by branch name.
func resolveWorktree(target string) (managedWt, error) {
managed, err := managedWorktrees()
if err != nil {
return managedWt{}, err
}
abs, _ := filepath.Abs(target)
for _, w := range managed {
if w.path == target || w.path == abs || 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 {
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 {
return err
}
fmt.Fprintf(out, "deleted branch %s\n", wt.branch)
}
// Refresh the source repo's default branch, then prune.
if err := agent.GitFetch(wt.srcDir, "origin", credentialHelperArgs()...); err != nil {
return err
}
return agent.GitWorktreePrune(wt.srcDir)
}
// --- clean ----------------------------------------------------------------
func newCleanCmd() *cobra.Command {
return &cobra.Command{
Use: "clean",
Short: "Remove all managed worktrees and prune their source repos",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
managed, err := managedWorktrees()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(managed) == 0 {
fmt.Fprintln(out, "no managed worktrees")
return nil
}
for _, w := range managed {
if err := removeWorktree(out, w, false); err != nil {
return err
}
}
return nil
},
}
}
// --- token ----------------------------------------------------------------
func newTokenCmd() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Print a fresh unkin-agent Gitea token",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
tok, err := agent.GiteaToken()
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), tok)
return nil
},
}
}
// --- credential (git credential-helper protocol) --------------------------
func newCredentialCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "credential <get|store|erase>",
Short: "git credential-helper: emit unkin-agent creds for git.unkin.net",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "get":
return credentialGet(cmd.InOrStdin(), cmd.OutOrStdout())
case "store", "erase":
// Nothing to persist/erase for an ephemeral helper; git ignores
// empty output and moves on.
return nil
default:
return fmt.Errorf("unknown credential action %q", args[0])
}
},
}
return cmd
}
// credentialGet implements the `get` half of the git credential protocol: read
// the key=value request on stdin and, for the configured Gitea host, emit a
// username/password pair (unkin-agent + a fresh Vault-minted token).
func credentialGet(stdin io.Reader, stdout io.Writer) error {
req := map[string]string{}
sc := bufio.NewScanner(stdin)
for sc.Scan() {
line := sc.Text()
if line == "" {
break
}
if k, v, ok := strings.Cut(line, "="); ok {
req[k] = v
}
}
if err := sc.Err(); err != nil {
return err
}
// Only answer for the configured Gitea host to avoid handing the token to
// any other remote git might ask about.
if host := req["host"]; host != "" && host != giteaHost() {
return nil
}
tok, err := agent.GiteaToken()
if err != nil {
return err
}
fmt.Fprintf(stdout, "username=%s\n", agent.AgentLogin())
fmt.Fprintf(stdout, "password=%s\n", tok)
return nil
}
// giteaHost returns the host portion of the configured Gitea URL.
func giteaHost() string {
u := agent.GiteaURL()
u = strings.TrimPrefix(u, "https://")
u = strings.TrimPrefix(u, "http://")
if i := strings.IndexByte(u, '/'); i >= 0 {
u = u[:i]
}
return u
}
// --- version --------------------------------------------------------------
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"bytes"
"io"
"strings"
"testing"
)
// `new` with a bad repo name (contains a slash) must fail before any network
// call, keeping the test hermetic.
func TestNewRejectsOwnerQualifiedRepo(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"new", "unkin/argocd-apps", "--branch", "benvin/x"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for owner-qualified repo name")
}
}
// `new` without --branch must fail (cobra required-flag check) before any
// network call.
func TestNewRequiresBranch(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"new", "argocd-apps"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error when --branch is missing")
}
}
// An unknown credential action must fail.
func TestCredentialUnknownAction(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"credential", "bogus"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for unknown credential action")
}
}
// credentialGet must stay silent (and never mint a token) when git asks about a
// host other than the configured Gitea host. This exercises the stdin parser
// without any network access.
func TestCredentialGetIgnoresOtherHost(t *testing.T) {
in := strings.NewReader("protocol=https\nhost=github.com\n\n")
var out bytes.Buffer
if err := credentialGet(in, &out); err != nil {
t.Fatalf("credentialGet: %v", err)
}
if out.Len() != 0 {
t.Errorf("expected no output for non-Gitea host, got %q", out.String())
}
}
func TestGiteaHost(t *testing.T) {
if h := giteaHost(); h != "git.unkin.net" {
t.Errorf("giteaHost() = %q, want git.unkin.net", h)
}
}
+230
View File
@@ -0,0 +1,230 @@
package agent
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ensureDir creates dir (and parents) if it does not already exist.
func ensureDir(dir string) error {
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
// Worktree is one entry from `git worktree list --porcelain`.
type Worktree struct {
Path string
Head string
Branch string // short branch name ("" when detached or bare)
Bare bool
Detached bool
}
// runGit runs git with args, using dir as the working directory (empty = the
// process cwd). It returns trimmed stdout, or an error that includes stderr so
// failures like "branch already checked out" surface verbatim.
func runGit(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// GitClone clones url into dir. Any globalArgs (e.g. "-c",
// "credential.helper=...") are passed before the clone subcommand so auth can be
// supplied without persisting it in the resulting checkout's config.
func GitClone(url, dir string, globalArgs ...string) error {
if err := ensureDir(filepath.Dir(dir)); err != nil {
return err
}
args := append(append([]string{}, globalArgs...), "clone", url, dir)
_, err := runGit(filepath.Dir(dir), args...)
return err
}
// GitFetch runs `git fetch <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
}
// 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
}
// 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)
}
+196
View File
@@ -0,0 +1,196 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestSanitizeBranch(t *testing.T) {
tests := []struct {
in, want string
}{
{"benvin/agentws", "benvin-agentws"},
{"main", "main"},
{" feature/x ", "feature-x"},
{"a/b/c", "a-b-c"},
{"ns:thing", "ns-thing"},
{"with space", "with-space"},
}
for _, tt := range tests {
if got := SanitizeBranch(tt.in); got != tt.want {
t.Errorf("SanitizeBranch(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestWorktreeDirName(t *testing.T) {
if got := WorktreeDirName("argocd-apps", "benvin/foo"); got != "argocd-apps__benvin-foo" {
t.Errorf("WorktreeDirName = %q", got)
}
}
func TestParseWorktreeList(t *testing.T) {
out := `worktree /home/ben/src/prodenv/repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /home/ben/.cache/agentws/repo__benvin-foo
HEAD 2222222222222222222222222222222222222222
branch refs/heads/benvin/foo
worktree /home/ben/.cache/agentws/repo__detached
HEAD 3333333333333333333333333333333333333333
detached
`
wts := ParseWorktreeList(out)
if len(wts) != 3 {
t.Fatalf("got %d worktrees, want 3: %+v", len(wts), wts)
}
if wts[0].Branch != "main" || wts[0].Path != "/home/ben/src/prodenv/repo" {
t.Errorf("wt[0] = %+v", wts[0])
}
if wts[1].Branch != "benvin/foo" {
t.Errorf("wt[1].Branch = %q, want benvin/foo", wts[1].Branch)
}
if !wts[2].Detached || wts[2].Branch != "" {
t.Errorf("wt[2] = %+v, want detached with empty branch", wts[2])
}
}
// gitSeed sets a repo-local identity so commits work without global config.
func gitIdentity(t *testing.T, dir string) {
t.Helper()
if err := GitConfigSet(dir, false, "user.email", "test@example.com"); err != nil {
t.Fatalf("set user.email: %v", err)
}
if err := GitConfigSet(dir, false, "user.name", "Test"); err != nil {
t.Fatalf("set user.name: %v", err)
}
}
// newTempRepos builds a bare "origin" with one commit on main and clones it into
// srcDir (so refs/remotes/origin/HEAD is set), returning the source checkout.
func newTempRepos(t *testing.T) string {
t.Helper()
root := t.TempDir()
bare := filepath.Join(root, "origin.git")
if _, err := runGit(root, "init", "--bare", "-b", "main", bare); err != nil {
t.Fatalf("init bare: %v", err)
}
seed := filepath.Join(root, "seed")
if _, err := runGit(root, "init", "-b", "main", seed); err != nil {
t.Fatalf("init seed: %v", err)
}
gitIdentity(t, seed)
if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(seed, "add", "."); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := runGit(seed, "commit", "-m", "init"); err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := runGit(seed, "remote", "add", "origin", bare); err != nil {
t.Fatalf("remote add: %v", err)
}
if _, err := runGit(seed, "push", "-u", "origin", "main"); err != nil {
t.Fatalf("push: %v", err)
}
srcDir := filepath.Join(root, "src")
if err := GitClone(bare, srcDir); err != nil {
t.Fatalf("clone: %v", err)
}
gitIdentity(t, srcDir)
return srcDir
}
func TestGitWorktreeLifecycle(t *testing.T) {
srcDir := newTempRepos(t)
def, err := GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
t.Fatalf("GitRemoteDefaultBranch: %v", err)
}
if def != "main" {
t.Errorf("default branch = %q, want main", def)
}
if err := GitFetch(srcDir, "origin"); err != nil {
t.Fatalf("GitFetch: %v", err)
}
wtPath := filepath.Join(t.TempDir(), "repo__benvin-x")
if GitBranchExists(srcDir, "benvin/x") {
t.Fatal("branch benvin/x should not exist yet")
}
if err := GitWorktreeAdd(srcDir, wtPath, "benvin/x", "origin/main"); err != nil {
t.Fatalf("GitWorktreeAdd: %v", err)
}
if !GitBranchExists(srcDir, "benvin/x") {
t.Error("branch benvin/x should exist after worktree add")
}
if br, err := GitCurrentBranch(wtPath); err != nil || br != "benvin/x" {
t.Errorf("GitCurrentBranch = %q, %v; want benvin/x", br, err)
}
src2, err := SourceRepoDir(wtPath)
if err != nil {
t.Fatalf("SourceRepoDir: %v", err)
}
if resolve(t, src2) != resolve(t, srcDir) {
t.Errorf("SourceRepoDir = %q, want %q", src2, srcDir)
}
wts, err := GitWorktreeList(srcDir)
if err != nil {
t.Fatalf("GitWorktreeList: %v", err)
}
found := false
for _, w := range wts {
if resolve(t, w.Path) == resolve(t, wtPath) && w.Branch == "benvin/x" {
found = true
}
}
if !found {
t.Errorf("worktree %s not found in list: %+v", wtPath, wts)
}
// Per-worktree config must not leak into the shared checkout.
if err := GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
t.Fatalf("enable worktreeConfig: %v", err)
}
if err := GitConfigSet(wtPath, true, "user.name", "unkin-agent"); err != nil {
t.Fatalf("set worktree user.name: %v", err)
}
if name, _ := runGit(srcDir, "config", "user.name"); name == "unkin-agent" {
t.Error("shared checkout user.name was polluted by worktree config")
}
if err := GitWorktreeRemove(srcDir, wtPath, true); err != nil {
t.Fatalf("GitWorktreeRemove: %v", err)
}
if err := GitDeleteBranch(srcDir, "benvin/x", true); err != nil {
t.Fatalf("GitDeleteBranch: %v", err)
}
if GitBranchExists(srcDir, "benvin/x") {
t.Error("branch benvin/x should be gone after delete")
}
if err := GitWorktreePrune(srcDir); err != nil {
t.Fatalf("GitWorktreePrune: %v", err)
}
}
// resolve canonicalizes a path (temp dirs may live behind symlinks like /var).
func resolve(t *testing.T, p string) string {
t.Helper()
r, err := filepath.EvalSymlinks(p)
if err != nil {
return p
}
return r
}
+18
View File
@@ -36,6 +36,12 @@ contents:
mode: 0755 mode: 0755
owner: root owner: root
group: root group: root
- src: dist/agentws
dst: /usr/bin/agentws
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging). # Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/agentpr.bash - src: dist/completions/agentpr.bash
@@ -62,3 +68,15 @@ contents:
dst: /usr/share/fish/vendor_completions.d/watchpr.fish dst: /usr/share/fish/vendor_completions.d/watchpr.fish
file_info: file_info:
mode: 0644 mode: 0644
- src: dist/completions/agentws.bash
dst: /usr/share/bash-completion/completions/agentws
file_info:
mode: 0644
- src: dist/completions/_agentws
dst: /usr/share/zsh/site-functions/_agentws
file_info:
mode: 0644
- src: dist/completions/agentws.fish
dst: /usr/share/fish/vendor_completions.d/agentws.fish
file_info:
mode: 0644
+4 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Package the (already built) agentpr and watchpr binaries into an RPM with # Package the (already built) agentpr, watchpr and agentws binaries into an RPM
# nfpm, bundling generated bash/zsh/fish shell completions. # with nfpm, bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) # Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
# #
set -euo pipefail set -euo pipefail
@@ -12,7 +12,7 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v VERSION="${VERSION#v}" # strip a leading v
PACKAGE="agent-tools" PACKAGE="agent-tools"
BINARIES=(agentpr watchpr) BINARIES=(agentpr watchpr agentws)
DIST="dist" DIST="dist"
for b in "${BINARIES[@]}"; do for b in "${BINARIES[@]}"; do
@@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1" export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64" export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux" export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for Gitea automation as the unkin-agent user: agentpr (create PRs/comments) and watchpr (poll PRs and alert on meaningful change)" export PACKAGE_DESCRIPTION="CLI tools for Gitea automation as the unkin-agent user: agentpr (create PRs/comments), watchpr (poll PRs and alert on meaningful change) and agentws (manage per-branch git worktrees)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>" export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools" export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools"
export PACKAGE_LICENSE="MIT" export PACKAGE_LICENSE="MIT"