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,230 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ensureDir creates dir (and parents) if it does not already exist.
|
||||
func ensureDir(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
// Worktree is one entry from `git worktree list --porcelain`.
|
||||
type Worktree struct {
|
||||
Path string
|
||||
Head string
|
||||
Branch string // short branch name ("" when detached or bare)
|
||||
Bare bool
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// runGit runs git with args, using dir as the working directory (empty = the
|
||||
// process cwd). It returns trimmed stdout, or an error that includes stderr so
|
||||
// failures like "branch already checked out" surface verbatim.
|
||||
func runGit(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// GitClone clones url into dir. Any globalArgs (e.g. "-c",
|
||||
// "credential.helper=...") are passed before the clone subcommand so auth can be
|
||||
// supplied without persisting it in the resulting checkout's config.
|
||||
func GitClone(url, dir string, globalArgs ...string) error {
|
||||
if err := ensureDir(filepath.Dir(dir)); err != nil {
|
||||
return err
|
||||
}
|
||||
args := append(append([]string{}, globalArgs...), "clone", url, dir)
|
||||
_, err := runGit(filepath.Dir(dir), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitFetch runs `git fetch <remote>` in repoDir. globalArgs are passed before
|
||||
// the subcommand (used to inject an ephemeral credential helper).
|
||||
func GitFetch(repoDir, remote string, globalArgs ...string) error {
|
||||
args := append(append([]string{}, globalArgs...), "fetch", remote)
|
||||
_, err := runGit(repoDir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitRemoteDefaultBranch returns the short name of remote's default branch
|
||||
// (e.g. "main") by resolving refs/remotes/<remote>/HEAD.
|
||||
func GitRemoteDefaultBranch(repoDir, remote string) (string, error) {
|
||||
out, err := runGit(repoDir, "rev-parse", "--abbrev-ref", remote+"/HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimPrefix(out, remote+"/"), nil
|
||||
}
|
||||
|
||||
// GitBranchExists reports whether a local branch exists.
|
||||
func GitBranchExists(repoDir, branch string) bool {
|
||||
_, err := runGit(repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GitWorktreeAdd adds a worktree at path checked out to branch. When the branch
|
||||
// already exists it is reused; otherwise it is created from startPoint.
|
||||
func GitWorktreeAdd(repoDir, path, branch, startPoint string) error {
|
||||
if err := ensureDir(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
var args []string
|
||||
if GitBranchExists(repoDir, branch) {
|
||||
args = []string{"worktree", "add", path, branch}
|
||||
} else {
|
||||
args = []string{"worktree", "add", path, "-b", branch, startPoint}
|
||||
}
|
||||
_, err := runGit(repoDir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitWorktreeRemove removes the worktree at path (force skips the dirty check).
|
||||
func GitWorktreeRemove(repoDir, path string, force bool) error {
|
||||
args := []string{"worktree", "remove", path}
|
||||
if force {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
_, err := runGit(repoDir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitWorktreePrune prunes stale worktree administrative entries.
|
||||
func GitWorktreePrune(repoDir string) error {
|
||||
_, err := runGit(repoDir, "worktree", "prune")
|
||||
return err
|
||||
}
|
||||
|
||||
// GitWorktreeList returns the worktrees registered for repoDir.
|
||||
func GitWorktreeList(repoDir string) ([]Worktree, error) {
|
||||
out, err := runGit(repoDir, "worktree", "list", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseWorktreeList(out), nil
|
||||
}
|
||||
|
||||
// GitDeleteBranch deletes a local branch (force uses -D).
|
||||
func GitDeleteBranch(repoDir, branch string, force bool) error {
|
||||
flag := "-d"
|
||||
if force {
|
||||
flag = "-D"
|
||||
}
|
||||
_, err := runGit(repoDir, "branch", flag, branch)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitConfigSet sets a config key in repoDir. When worktree is true the value is
|
||||
// written to the per-worktree config (extensions.worktreeConfig must be enabled)
|
||||
// so it does not touch the shared checkout's config.
|
||||
func GitConfigSet(repoDir string, worktree bool, key, value string) error {
|
||||
args := []string{"config"}
|
||||
if worktree {
|
||||
args = append(args, "--worktree")
|
||||
}
|
||||
args = append(args, key, value)
|
||||
_, err := runGit(repoDir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GitCommonDir returns the absolute path to the shared .git directory for the
|
||||
// checkout at dir (a worktree's common dir points back at its source repo).
|
||||
func GitCommonDir(dir string) (string, error) {
|
||||
out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GitCurrentBranch returns the short branch name checked out at dir.
|
||||
func GitCurrentBranch(dir string) (string, error) {
|
||||
return runGit(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}
|
||||
|
||||
// SourceRepoDir maps a worktree checkout to its source repo directory by walking
|
||||
// from the shared .git common dir up to the repo root.
|
||||
func SourceRepoDir(worktreeDir string) (string, error) {
|
||||
common, err := GitCommonDir(worktreeDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// common is ".../<repo>/.git"; the repo dir is its parent.
|
||||
return filepath.Dir(common), nil
|
||||
}
|
||||
|
||||
// ParseWorktreeList parses the output of `git worktree list --porcelain`.
|
||||
func ParseWorktreeList(out string) []Worktree {
|
||||
var wts []Worktree
|
||||
var cur *Worktree
|
||||
flush := func() {
|
||||
if cur != nil {
|
||||
wts = append(wts, *cur)
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
key, val, _ := strings.Cut(line, " ")
|
||||
switch key {
|
||||
case "worktree":
|
||||
flush()
|
||||
cur = &Worktree{Path: val}
|
||||
case "HEAD":
|
||||
if cur != nil {
|
||||
cur.Head = val
|
||||
}
|
||||
case "branch":
|
||||
if cur != nil {
|
||||
cur.Branch = strings.TrimPrefix(val, "refs/heads/")
|
||||
}
|
||||
case "bare":
|
||||
if cur != nil {
|
||||
cur.Bare = true
|
||||
}
|
||||
case "detached":
|
||||
if cur != nil {
|
||||
cur.Detached = true
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return wts
|
||||
}
|
||||
|
||||
// SanitizeBranch turns a branch name into a filesystem-safe path segment by
|
||||
// replacing separators that would otherwise create nested directories.
|
||||
func SanitizeBranch(branch string) string {
|
||||
r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-")
|
||||
return r.Replace(strings.TrimSpace(branch))
|
||||
}
|
||||
|
||||
// WorktreeDirName is the directory name (under the worktree root) for a repo's
|
||||
// branch worktree: "<repo>__<sanitized-branch>".
|
||||
func WorktreeDirName(repo, branch string) string {
|
||||
return repo + "__" + SanitizeBranch(branch)
|
||||
}
|
||||
Reference in New Issue
Block a user