6a82b88947
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.
459 lines
13 KiB
Go
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,
|
|
}
|
|
}
|