Files
agent-tools/cmd/agentws/main.go
T
unkin-agent 61e73ada51
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
agentws: fix golangci-lint findings
2026-08-15 12:54:22 +10:00

459 lines
13 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 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,
}
}