62aeaf063b
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
749 lines
23 KiB
Go
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,
|
|
}
|
|
}
|