Files
agent-tools/cmd/agentws/main.go
T
unkin-agent 62aeaf063b
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Keep worktrees git could not read, never delete them
Any git error on a directory under the worktree root was classified orphan,
and orphan deletes the directory outright, so a transient failure reading the
source root became data loss on a plain `agentws prune --yes`.

- prove a backing repo gone by stat before calling a directory an orphan
- classify an unexplained git failure as keep, naming the error
- refuse to remove a worktree whose git state is unknown, even with --include-keep
- spell out that --include-keep discards uncommitted and in-progress work
2026-09-12 00:37:10 +10:00

749 lines
23 KiB
Go

// 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 prune [--yes] [--keep-branches] [--no-fetch] [--json]
// [--include-unmanaged] [--include-keep]
// agentws clean
// agentws token
// agentws credential get # git credential-helper protocol on stdin
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"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(),
newPruneCmd(),
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 {
branch := w.branch
switch {
case w.orphan:
branch = "(orphan)"
case w.inspectErr != nil:
branch = "(unreadable)"
}
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, branch, w.path)
}
return nil
},
}
}
// managedWt describes one worktree living under the worktree root.
type managedWt struct {
repo string
branch string
path string
srcDir string
// managed is false for worktrees found via `git worktree list` that live
// outside the worktree root, i.e. somebody made them by hand.
managed bool
// detached is true when the worktree has no branch to fall back on, so its
// commits die with the checkout.
detached bool
// locked records git's own "do not remove me" marker.
locked bool
// missing is a registration whose working tree is gone: nothing to inspect,
// nothing to lose.
missing bool
// orphan is a directory under the worktree root whose backing git dir is
// proven gone, so no git state can be read from it ever again.
orphan bool
// inspectErr is set when git refused to answer for a checkout and the reason
// was not a proven-absent backing repo. The state is unknown, never removable.
inspectErr error
}
// managedWorktrees scans the worktree root and resolves each entry's repo and
// branch from git so branch names are accurate (not the sanitized dir name).
// Directories whose backing repo is proven gone are returned as orphans rather
// than dropped, so callers can see (and clean up) the leftovers; a directory git
// merely failed to answer for is returned with its error instead, because an
// unread state must never be mistaken for a dead one.
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
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())
if _, err := os.Stat(filepath.Join(path, ".git")); err != nil {
continue // not a worktree checkout at all
}
branch, branchErr := agent.GitCurrentBranch(path)
srcDir, srcErr := agent.SourceRepoDir(path)
if branchErr != nil || srcErr != nil {
entry := managedWt{repo: repoFromDirName(e.Name()), path: path, managed: true}
if gone, err := backingRepoGone(path); err == nil && gone {
entry.orphan = true
} else if branchErr != nil {
entry.inspectErr = branchErr
} else {
entry.inspectErr = srcErr
}
out = append(out, entry)
continue
}
out = append(out, managedWt{
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
managed: true,
detached: branch == "HEAD",
})
}
return out, nil
}
// backingRepoGone proves, by stat alone, that a worktree directory's backing
// repo no longer exists: its .git file names a git dir that is absent, and the
// repo's shared .git the git dir lived in is absent too. Only that pair licenses
// deleting the directory. Every other outcome — an unreadable .git file, a git
// dir still on disk, a stat that failed for any reason other than "not there",
// or a mere lost registration in a repo that is still present — reports false,
// so a transient or unexplained failure can never be read as "safe to delete".
func backingRepoGone(path string) (bool, error) {
dot := filepath.Join(path, ".git")
info, err := os.Lstat(dot)
if err != nil {
return false, err
}
if info.IsDir() {
return false, nil // a standalone checkout, not a linked worktree
}
data, err := os.ReadFile(dot)
if err != nil {
return false, err
}
rest, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:")
if !ok {
return false, fmt.Errorf("%s: not a worktree gitdir pointer", dot)
}
gitDir := strings.TrimSpace(rest)
if gitDir == "" {
return false, fmt.Errorf("%s: empty gitdir", dot)
}
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// The git dir is "<repo>/.git/worktrees/<name>"; both it and the shared .git
// it sits in must be absent before the repo counts as gone.
for _, dir := range []string{gitDir, filepath.Dir(filepath.Dir(gitDir))} {
if _, err := os.Stat(dir); err == nil {
return false, nil
} else if !os.IsNotExist(err) {
return false, err
}
}
return true, nil
}
// repoFromDirName recovers the repo name from the "<repo>__<branch>" layout used
// under the worktree root, for entries git can no longer answer for.
func repoFromDirName(name string) string {
if repo, _, ok := strings.Cut(name, "__"); ok {
return repo
}
return name
}
// allWorktrees is every worktree prune should consider: the managed ones under
// the worktree root, plus whatever `git worktree list` reports for the repos
// they belong to and for every checkout in the source root. The second source
// finds hand-made worktrees and stale registrations whose directory is gone, and
// carries git's own locked/prunable flags onto the entries the first source
// already found.
func allWorktrees() ([]managedWt, error) {
managed, err := managedWorktrees()
if err != nil {
return nil, err
}
wr, err := worktreeRoot()
if err != nil {
return nil, err
}
out := make([]managedWt, 0, len(managed))
index := map[string]int{}
for _, w := range managed {
index[resolvePath(w.path)] = len(out)
out = append(out, w)
}
for _, srcDir := range sourceRepos(managed) {
wts, err := agent.GitWorktreeList(srcDir)
if err != nil {
continue // not a repo any more, or unreadable; managed entries still stand
}
for _, wt := range wts {
if wt.Bare || sameDir(wt.Path, srcDir) {
continue
}
_, statErr := os.Stat(wt.Path)
entry := managedWt{
repo: filepath.Base(srcDir),
branch: worktreeBranch(wt),
path: wt.Path,
srcDir: srcDir,
managed: underRoot(wt.Path, wr),
detached: wt.Detached,
locked: wt.Locked,
missing: wt.Prunable != "" || os.IsNotExist(statErr),
}
key := resolvePath(wt.Path)
if i, ok := index[key]; ok {
// Keep the managed scan's own view, but adopt the flags only git knows.
out[i].locked = entry.locked
out[i].missing = out[i].missing || entry.missing
out[i].detached = out[i].detached || entry.detached
continue
}
index[key] = len(out)
out = append(out, entry)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].repo != out[j].repo {
return out[i].repo < out[j].repo
}
return out[i].path < out[j].path
})
return out, nil
}
// resolvePath is a path key that matches however git spells the same directory.
func resolvePath(path string) string {
if p, err := filepath.EvalSymlinks(path); err == nil {
return p
}
return filepath.Clean(path)
}
// worktreeBranch names a worktree's branch, reporting a detached checkout as
// "HEAD" so it reads the same as GitCurrentBranch does.
func worktreeBranch(wt agent.Worktree) string {
if wt.Branch != "" {
return wt.Branch
}
return "HEAD"
}
// sourceRepos is every repo to enumerate worktrees from: the ones the managed
// worktrees point back at, plus every git checkout directly under the source
// root (so a repo with only hand-made worktrees is still covered). Each is
// normalised to its main checkout, because a directory in the source root may
// itself be a linked worktree — enumerating from there would report the repo's
// real checkout as a removable worktree of itself.
func sourceRepos(managed []managedWt) []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
if dir == "" {
return
}
if main, err := agent.SourceRepoDir(dir); err == nil {
dir = main
}
key := resolvePath(dir)
if seen[key] {
return
}
seen[key] = true
dirs = append(dirs, dir)
}
for _, w := range managed {
add(w.srcDir)
}
if sr, err := srcRoot(); err == nil {
if entries, err := os.ReadDir(sr); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(sr, e.Name())
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
add(dir)
}
}
}
}
sort.Strings(dirs)
return dirs
}
// sameDir compares two paths after resolving symlinks, because git reports
// worktree paths fully resolved while our own paths may not be.
func sameDir(a, b string) bool {
if a == b {
return true
}
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
// underRoot reports whether path sits inside root, comparing resolved paths
// because git hands back worktree paths with symlinks already resolved.
func underRoot(path, root string) bool {
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if p, err := filepath.EvalSymlinks(path); err == nil {
path = p
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// --- rm -------------------------------------------------------------------
func newRmCmd() *cobra.Command {
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
}
// Naming one worktree to delete is explicit, so rm keeps the force fallback.
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch, true)
},
}
cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Also delete the local branch after removing the worktree")
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 != "" && w.branch == target) {
return w, nil
}
}
return managedWt{}, fmt.Errorf("no managed worktree matching %q (try `agentws list`)", target)
}
// removeWorktree removes a managed worktree and, when asked, its local branch.
// forceBranch overrides git's unmerged-branch guard, so only a caller that
// proved the commits survive elsewhere may set it.
func removeWorktree(out io.Writer, wt managedWt, deleteBranch, forceBranch bool) error {
switch {
case wt.inspectErr != nil:
// No srcDir to act through and no idea what is in there; --include-keep
// must not turn that into a delete.
return fmt.Errorf("refusing to remove %s: git state unreadable: %w", wt.path, wt.inspectErr)
case wt.orphan:
return removeOrphanDir(out, wt)
case wt.missing:
// The working tree is already gone; only the registration is left.
_, _ = fmt.Fprintf(out, "pruned stale registration %s\n", wt.path)
return agent.GitWorktreePrune(wt.srcDir)
}
if err := agent.GitWorktreeRemove(wt.srcDir, wt.path, true); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "removed worktree %s\n", wt.path)
if deleteBranch {
if err := deleteLocalBranch(wt, forceBranch); 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)
}
// removeOrphanDir deletes a worktree directory whose backing repo is gone. git
// cannot act on it, so this is a plain delete — confined to the worktree root so
// a bad path can never reach a real checkout.
func removeOrphanDir(out io.Writer, wt managedWt) error {
wr, err := worktreeRoot()
if err != nil {
return err
}
if !underRoot(wt.path, wr) || sameDir(wt.path, wr) {
return fmt.Errorf("refusing to delete %s: not inside the worktree root %s", wt.path, wr)
}
if err := os.RemoveAll(wt.path); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "deleted orphaned worktree directory %s\n", wt.path)
return nil
}
// deleteLocalBranch tries the guarded delete first so git refuses to drop
// unmerged commits on its own; force is a fallback, never the first attempt.
func deleteLocalBranch(wt managedWt, force bool) error {
err := agent.GitDeleteBranch(wt.srcDir, wt.branch, false)
if err == nil || !force {
return err
}
return agent.GitDeleteBranch(wt.srcDir, wt.branch, true)
}
// --- clean ----------------------------------------------------------------
func newCleanCmd() *cobra.Command {
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, 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,
}
}