diff --git a/.gitignore b/.gitignore index 5e507cc..05fabee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ # built binaries (repo root only — not the cmd/ source dirs) /agentpr /watchpr +/agentws # cross-compiled release artifacts (e.g. agentpr-linux-amd64) /agentpr-* /watchpr-* +/agentws-* dist/ diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 232649b..22841c0 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -28,7 +28,7 @@ steps: # for the shell instead of substituting them (as pipeline vars) at parse # 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##*:}" for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do os="$${osarch%/*}"; arch="$${osarch#*/}" @@ -135,7 +135,7 @@ steps: # root; the package step writes the RPM to dist/. Generate a checksums # manifest over everything we attach so downloads can be verified. 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" sha256sum $$ASSETS > sha256sums.txt tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ diff --git a/AGENTS.md b/AGENTS.md index 62437f6..c6b218c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Project Overview -This repo ships two Gitea-automation CLIs in one RPM (`agent-tools`). Both act -as the `unkin-agent` user by minting a scoped Gitea token from Vault, so +This repo ships several Gitea-automation CLIs in one RPM (`agent-tools`). They +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. - **`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, or it loses mergeability. Benign transitions (CI pending→success, the agent's own comments) are ignored. +- **`agentws`** — manage per-branch git worktrees for `unkin-agent`. Clones + repos into the source root (`~/src/prodenv/`), creates worktrees under + the worktree root (`~/.cache/agentws/__`), 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 -parsing, watch-state comparison). +parsing, watch-state comparison, git worktree helpers). ## Structure ``` cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami) 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: token.go # env config + in-process Gitea-token cache vault.go # AppRole login + read gitea/creds/unkin-agent gitea.go # Gitea REST client (PR create/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) go.mod # module git.unkin.net/unkin/agent-tools 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 .woodpecker/ # CI: build, test, pre-commit (PR) + release (tag) 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) -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 (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 | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) | | `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=`). `agentws new` wires this per worktree — it enables +`extensions.worktreeConfig` on the repo once, then writes `user.name`, +`user.email` and `credential.helper = ! 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/` (`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 ```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). @@ -68,11 +93,12 @@ Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI). ## Packaging (RPM) ```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 -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 artifactapi `rpm-internal` repo, then cuts a Gitea release. diff --git a/Makefile b/Makefile index e23ec6c..0ba2c0a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # 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. -BINARIES := agentpr watchpr +BINARIES := agentpr watchpr agentws DIST := dist VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" diff --git a/README.md b/README.md index 897997f..831eaf5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # agent-tools -Two small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). -Both act as the **`unkin-agent`** user by minting a scoped Gitea token from -Vault, so automated PRs and comments are attributed to the agent — not to -whoever happens to run the command. +Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They +act as the **`unkin-agent`** user 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. - **`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 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 @@ -23,6 +25,9 @@ Everything is configured by environment variables, all with defaults: | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `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 @@ -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, 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/`) so branches created here are visible in the main +checkout too; the worktrees themselves live under the **worktree root** +(`~/.cache/agentws/__`). + +```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=`. + +`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 = ! 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/` so its default branch stays +current. + ## Build & package ```bash -make build # -> dist/agentpr, dist/watchpr +make build # -> dist/agentpr, dist/watchpr, dist/agentws make test # go test -race ./... make rpm # build + package dist/agent-tools--1.x86_64.rpm ``` diff --git a/cmd/agentws/main.go b/cmd/agentws/main.go new file mode 100644 index 0000000..814bb52 --- /dev/null +++ b/cmd/agentws/main.go @@ -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/) so +// branches created here are visible in the main checkout too. Worktrees live +// under the worktree root (default ~/.cache/agentws/__). 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 [--branch benvin/] [--from ] +// agentws list +// agentws rm [--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 ", + 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/)") + } + + 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/) (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 ", + 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 ", + 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, + } +} diff --git a/cmd/agentws/main_test.go b/cmd/agentws/main_test.go new file mode 100644 index 0000000..88b6f64 --- /dev/null +++ b/cmd/agentws/main_test.go @@ -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) + } +} diff --git a/internal/agent/git.go b/internal/agent/git.go new file mode 100644 index 0000000..44a9184 --- /dev/null +++ b/internal/agent/git.go @@ -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 ` 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//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 "...//.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: "__". +func WorktreeDirName(repo, branch string) string { + return repo + "__" + SanitizeBranch(branch) +} diff --git a/internal/agent/git_test.go b/internal/agent/git_test.go new file mode 100644 index 0000000..ff64596 --- /dev/null +++ b/internal/agent/git_test.go @@ -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 +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 6f4bbc2..fe20d22 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -36,6 +36,12 @@ contents: mode: 0755 owner: 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). - src: dist/completions/agentpr.bash @@ -62,3 +68,15 @@ contents: dst: /usr/share/fish/vendor_completions.d/watchpr.fish file_info: 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 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index afc9f3b..588f6e0 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# Package the (already built) agentpr and watchpr binaries into an RPM with -# nfpm, bundling generated bash/zsh/fish shell completions. +# Package the (already built) agentpr, watchpr and agentws binaries into an RPM +# with nfpm, bundling generated bash/zsh/fish shell completions. # Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) # set -euo pipefail @@ -12,7 +12,7 @@ cd "${ROOT_DIR}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${VERSION#v}" # strip a leading v PACKAGE="agent-tools" -BINARIES=(agentpr watchpr) +BINARIES=(agentpr watchpr agentws) DIST="dist" for b in "${BINARIES[@]}"; do @@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}" export PACKAGE_RELEASE="1" export PACKAGE_ARCH="amd64" 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 " export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools" export PACKAGE_LICENSE="MIT"