// 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 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 ", 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. A branch origin already has is work in progress and must be // checked out as it stands; only a branch origin does not have is // forked from a base. onRemote := agent.GitRemoteBranchExists(srcDir, "origin", branch) startPoint := "origin/" + branch base := from if onRemote { if from != "" { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "note: --from %s ignored, origin/%s already exists\n", from, branch) } } else { if base == "" { base, err = agent.GitRemoteDefaultBranch(srcDir, "origin") if err != nil { return err } } startPoint = "origin/" + base } // 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, startPoint); 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. A local branch left from an earlier run may sit behind origin, // so reusing it is not enough on its own. summary := fmt.Sprintf("branch %s (new, from origin/%s)", branch, base) if onRemote { summary, err = alignToRemote(cmd.OutOrStdout(), wtPath, branch) if err != nil { return err } } // g. Report the worktree path and which of the two paths was taken. _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", wtPath) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", summary) 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 } // alignToRemote puts the worktree on origin/ and reports what that took. // A reused local branch can be stale, and a fast-forward is the only move that // adds no commit and drops none; a local branch carrying commits origin does not // have is left where it stands, because those commits exist nowhere else. func alignToRemote(out io.Writer, wtPath, branch string) (string, error) { remoteRef := "origin/" + branch want, err := agent.GitRevParse(wtPath, remoteRef) if err != nil { return "", err } head, err := agent.GitRevParse(wtPath, "HEAD") if err != nil { return "", err } if head != want { ahead, err := agent.GitAheadCount(wtPath, remoteRef, "HEAD") if err != nil { return "", err } if ahead > 0 { return fmt.Sprintf("branch %s (local, %s not on %s, left at %s)", branch, commitCount(ahead), remoteRef, shortSHA(head)), nil } if err := agent.GitMergeFFOnly(wtPath, remoteRef); err != nil { return "", err } _, _ = fmt.Fprintf(out, "fast-forwarded stale %s to %s\n", branch, remoteRef) } if err := agent.GitSetUpstream(wtPath, branch, remoteRef); err != nil { return "", err } return fmt.Sprintf("branch %s (tracking %s at %s)", branch, remoteRef, shortSHA(want)), nil } func shortSHA(sha string) string { if len(sha) > 7 { return sha[:7] } return sha } // --- 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 "/.git/worktrees/"; 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 "__" 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 ", 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 ", 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, } }