Add agentws worktree-management binary
agentws manages per-branch git worktrees for the unkin-agent user: it clones repos into the source root (~/src/prodenv/<repo>) so branches are visible in Ben's main checkout, and creates isolated worktrees under the worktree root (~/.cache/agentws/<repo>__<branch>). - New internal/agent/git.go: small, testable git helpers shelling out to the git binary (clone/fetch/worktree add/remove/list/prune, branch + config ops, porcelain parsing, path sanitizing). No go-git dependency. - New cmd/agentws: new / list / rm / clean / token / credential subcommands. Auth uses an ephemeral git credential helper (agentws credential get) so the ~1h Gitea token is never persisted in a remote URL or config; per-worktree config keeps the shared checkout's identity untouched. - Wire agentws into Makefile, scripts/build-rpm.sh, packaging/nfpm.yaml (binary + bash/zsh/fish completions), .woodpecker/release.yaml (cross-compile + assets) and .gitignore. - Tests: table tests for parsing/sanitizing/dir-naming, a real temp-git repo for the worktree lifecycle, and hermetic cmd tests (bad input + credential-helper host guard) that never touch the network. - Document agentws in README.md and AGENTS.md.
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
// Command agentws (agentic workspace) manages git worktrees for the unkin-agent
|
||||
// user so agents can work on isolated branches without disturbing Ben's shared
|
||||
// checkouts.
|
||||
//
|
||||
// Repositories are cloned into the source root (default ~/src/prodenv/<repo>) so
|
||||
// branches created here are visible in the main checkout too. Worktrees live
|
||||
// under the worktree root (default ~/.cache/agentws/<repo>__<branch>). Auth for
|
||||
// clone/fetch/push comes from a short-lived Gitea token minted from Vault via
|
||||
// agent.GiteaToken(); it is supplied through an ephemeral git credential helper
|
||||
// (`agentws credential get`) rather than being persisted in any remote URL or
|
||||
// config, since the tokens expire in about an hour.
|
||||
//
|
||||
// agentws new <repo> [--branch benvin/<name>] [--from <base-branch>]
|
||||
// agentws list
|
||||
// agentws rm <path-or-branch> [--delete-branch]
|
||||
// agentws clean
|
||||
// agentws token
|
||||
// agentws credential get # git credential-helper protocol on stdin
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/agent-tools/internal/agent"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// newRootCmd builds the agentws command tree. Separated from main so tests can
|
||||
// invoke Execute and assert behaviour without spawning a process.
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "agentws",
|
||||
Short: "Manage git worktrees for the unkin-agent user.",
|
||||
Long: "agentws manages per-branch git worktrees for unkin-agent. Repos are cloned into\nthe source root (~/src/prodenv) and worktrees live under the worktree root\n(~/.cache/agentws), authenticated by an ephemeral Vault-minted Gitea token.",
|
||||
Version: version,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.SetVersionTemplate("{{.Version}}\n")
|
||||
root.AddCommand(
|
||||
newNewCmd(),
|
||||
newListCmd(),
|
||||
newRmCmd(),
|
||||
newCleanCmd(),
|
||||
newTokenCmd(),
|
||||
newCredentialCmd(),
|
||||
newVersionCmd(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
// --- configuration (env-overridable) --------------------------------------
|
||||
|
||||
// srcRoot is where source-of-truth checkouts live (default ~/src/prodenv).
|
||||
func srcRoot() (string, error) {
|
||||
if v := os.Getenv("AGENTWS_SRC_ROOT"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "src", "prodenv"), nil
|
||||
}
|
||||
|
||||
// worktreeRoot is where managed worktrees live (default ~/.cache/agentws).
|
||||
func worktreeRoot() (string, error) {
|
||||
if v := os.Getenv("AGENTWS_ROOT"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
cache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(cache, "agentws"), nil
|
||||
}
|
||||
|
||||
// owner is the Gitea org that owns the repos (default unkin).
|
||||
func owner() string {
|
||||
if v := os.Getenv("AGENTWS_OWNER"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "unkin"
|
||||
}
|
||||
|
||||
// cloneURL builds the (token-free) HTTPS clone URL for a repo.
|
||||
func cloneURL(repo string) string {
|
||||
return strings.TrimRight(agent.GiteaURL(), "/") + "/" + owner() + "/" + repo + ".git"
|
||||
}
|
||||
|
||||
// credentialHelperArgs returns git global args that wire this binary as an
|
||||
// ephemeral credential helper, so clone/fetch/push authenticate without
|
||||
// persisting a token anywhere.
|
||||
func credentialHelperArgs() []string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil || exe == "" {
|
||||
exe = "agentws"
|
||||
}
|
||||
return []string{"-c", "credential.helper=!" + exe + " credential"}
|
||||
}
|
||||
|
||||
// --- new ------------------------------------------------------------------
|
||||
|
||||
func newNewCmd() *cobra.Command {
|
||||
var branch, from string
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <repo>",
|
||||
Short: "Clone (if needed) and create a worktree for a branch",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
repo := strings.TrimSpace(args[0])
|
||||
if repo == "" || strings.Contains(repo, "/") {
|
||||
return fmt.Errorf("repo must be a bare repository name (owner comes from AGENTWS_OWNER, default %q)", owner())
|
||||
}
|
||||
if branch == "" {
|
||||
return fmt.Errorf("--branch is required (e.g. benvin/<name>)")
|
||||
}
|
||||
|
||||
sr, err := srcRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wr, err := worktreeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcDir := filepath.Join(sr, repo)
|
||||
auth := credentialHelperArgs()
|
||||
|
||||
// a. Clone the source-of-truth checkout if missing.
|
||||
if _, statErr := os.Stat(srcDir); statErr != nil {
|
||||
if !os.IsNotExist(statErr) {
|
||||
return statErr
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "cloning %s into %s\n", cloneURL(repo), srcDir)
|
||||
if err := agent.GitClone(cloneURL(repo), srcDir, auth...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// b. Refresh so the base branch is current.
|
||||
if err := agent.GitFetch(srcDir, "origin", auth...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// c. Base branch: --from or the remote default.
|
||||
base := from
|
||||
if base == "" {
|
||||
base, err = agent.GitRemoteDefaultBranch(srcDir, "origin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// d. Create the worktree FROM the source checkout so the branch is
|
||||
// visible in the main checkout too.
|
||||
wtPath := filepath.Join(wr, agent.WorktreeDirName(repo, branch))
|
||||
if _, statErr := os.Stat(wtPath); statErr == nil {
|
||||
return fmt.Errorf("worktree already exists at %s", wtPath)
|
||||
}
|
||||
if err := agent.GitWorktreeAdd(srcDir, wtPath, branch, "origin/"+base); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// e. Set the agent identity + auth WITHOUT polluting the shared
|
||||
// checkout: per-worktree config only.
|
||||
if err := agent.GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.GitConfigSet(wtPath, true, "user.name", agent.AgentLogin()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.GitConfigSet(wtPath, true, "user.email", agent.AgentLogin()+"@unkin.net"); err != nil {
|
||||
return err
|
||||
}
|
||||
exe, _ := os.Executable()
|
||||
if exe == "" {
|
||||
exe = "agentws"
|
||||
}
|
||||
if err := agent.GitConfigSet(wtPath, true, "credential.helper", "!"+exe+" credential"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// f. Report the worktree path and branch.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", wtPath)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "branch %s (from origin/%s)\n", branch, base)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&branch, "branch", "", "Branch to check out/create (e.g. benvin/<name>) (required)")
|
||||
f.StringVar(&from, "from", "", "Base branch to branch from (default: remote default branch)")
|
||||
_ = cmd.MarkFlagRequired("branch")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// --- list -----------------------------------------------------------------
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List managed worktrees under the worktree root",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
managed, err := managedWorktrees()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if len(managed) == 0 {
|
||||
fmt.Fprintln(out, "no managed worktrees")
|
||||
return nil
|
||||
}
|
||||
for _, w := range managed {
|
||||
fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, w.branch, w.path)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// managedWt describes one worktree living under the worktree root.
|
||||
type managedWt struct {
|
||||
repo string
|
||||
branch string
|
||||
path string
|
||||
srcDir string
|
||||
}
|
||||
|
||||
// managedWorktrees scans the worktree root and resolves each entry's repo and
|
||||
// branch from git so branch names are accurate (not the sanitized dir name).
|
||||
func managedWorktrees() ([]managedWt, error) {
|
||||
wr, err := worktreeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(wr)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []managedWt
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(wr, e.Name())
|
||||
branch, err := agent.GitCurrentBranch(path)
|
||||
if err != nil {
|
||||
continue // not a git worktree; skip
|
||||
}
|
||||
srcDir, err := agent.SourceRepoDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, managedWt{
|
||||
repo: filepath.Base(srcDir),
|
||||
branch: branch,
|
||||
path: path,
|
||||
srcDir: srcDir,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- rm -------------------------------------------------------------------
|
||||
|
||||
func newRmCmd() *cobra.Command {
|
||||
var deleteBranch bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm <path-or-branch>",
|
||||
Short: "Remove a managed worktree and refresh its source repo",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := strings.TrimSpace(args[0])
|
||||
wt, err := resolveWorktree(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Also delete the local branch after removing the worktree")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveWorktree finds a managed worktree by exact path or by branch name.
|
||||
func resolveWorktree(target string) (managedWt, error) {
|
||||
managed, err := managedWorktrees()
|
||||
if err != nil {
|
||||
return managedWt{}, err
|
||||
}
|
||||
abs, _ := filepath.Abs(target)
|
||||
for _, w := range managed {
|
||||
if w.path == target || w.path == abs || w.branch == target {
|
||||
return w, nil
|
||||
}
|
||||
}
|
||||
return managedWt{}, fmt.Errorf("no managed worktree matching %q (try `agentws list`)", target)
|
||||
}
|
||||
|
||||
func removeWorktree(out io.Writer, wt managedWt, deleteBranch bool) error {
|
||||
if err := agent.GitWorktreeRemove(wt.srcDir, wt.path, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "removed worktree %s\n", wt.path)
|
||||
if deleteBranch {
|
||||
if err := agent.GitDeleteBranch(wt.srcDir, wt.branch, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "deleted branch %s\n", wt.branch)
|
||||
}
|
||||
// Refresh the source repo's default branch, then prune.
|
||||
if err := agent.GitFetch(wt.srcDir, "origin", credentialHelperArgs()...); err != nil {
|
||||
return err
|
||||
}
|
||||
return agent.GitWorktreePrune(wt.srcDir)
|
||||
}
|
||||
|
||||
// --- clean ----------------------------------------------------------------
|
||||
|
||||
func newCleanCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "clean",
|
||||
Short: "Remove all managed worktrees and prune their source repos",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
managed, err := managedWorktrees()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if len(managed) == 0 {
|
||||
fmt.Fprintln(out, "no managed worktrees")
|
||||
return nil
|
||||
}
|
||||
for _, w := range managed {
|
||||
if err := removeWorktree(out, w, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- token ----------------------------------------------------------------
|
||||
|
||||
func newTokenCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print a fresh unkin-agent Gitea token",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
tok, err := agent.GiteaToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), tok)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- credential (git credential-helper protocol) --------------------------
|
||||
|
||||
func newCredentialCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "credential <get|store|erase>",
|
||||
Short: "git credential-helper: emit unkin-agent creds for git.unkin.net",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "get":
|
||||
return credentialGet(cmd.InOrStdin(), cmd.OutOrStdout())
|
||||
case "store", "erase":
|
||||
// Nothing to persist/erase for an ephemeral helper; git ignores
|
||||
// empty output and moves on.
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown credential action %q", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// credentialGet implements the `get` half of the git credential protocol: read
|
||||
// the key=value request on stdin and, for the configured Gitea host, emit a
|
||||
// username/password pair (unkin-agent + a fresh Vault-minted token).
|
||||
func credentialGet(stdin io.Reader, stdout io.Writer) error {
|
||||
req := map[string]string{}
|
||||
sc := bufio.NewScanner(stdin)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
req[k] = v
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Only answer for the configured Gitea host to avoid handing the token to
|
||||
// any other remote git might ask about.
|
||||
if host := req["host"]; host != "" && host != giteaHost() {
|
||||
return nil
|
||||
}
|
||||
tok, err := agent.GiteaToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "username=%s\n", agent.AgentLogin())
|
||||
fmt.Fprintf(stdout, "password=%s\n", tok)
|
||||
return nil
|
||||
}
|
||||
|
||||
// giteaHost returns the host portion of the configured Gitea URL.
|
||||
func giteaHost() string {
|
||||
u := agent.GiteaURL()
|
||||
u = strings.TrimPrefix(u, "https://")
|
||||
u = strings.TrimPrefix(u, "http://")
|
||||
if i := strings.IndexByte(u, '/'); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// --- version --------------------------------------------------------------
|
||||
|
||||
func newVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user