You've already forked agentic-coding-workflow
feat(infra/git): worktree add/remove/list with porcelain parser
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorktreeAdd 在 dir(main worktree)创建一个支线 worktree。
|
||||||
|
//
|
||||||
|
// path: 绝对路径,由 workspace 包计算
|
||||||
|
// branch: 用 -B 强制创建/重置 — 已存在的分支会被 reset 到 origin/HEAD
|
||||||
|
// (本地未推送 commit 会丢失,可从 reflog 找回);--track 让分支
|
||||||
|
// 默认 track origin/HEAD(通常是 main),首次 push 时由 service
|
||||||
|
// 层 set-upstream 到对应远端分支。
|
||||||
|
//
|
||||||
|
// 失败时如果 stderr 含 "already exists" / "is already used by worktree",
|
||||||
|
// errors.IsConflict 返回 true,调用方应映射为 WORKTREE_BRANCH_CONFLICT。
|
||||||
|
func (r *DefaultRunner) WorktreeAdd(ctx context.Context, dir, branch, path string) error {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := []string{"worktree", "add", "--track", "-B", branch, path}
|
||||||
|
_, _, err := r.exec(ctx, dir, nil, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorktreeRemove 删除 path 这个 worktree(包含磁盘目录)。
|
||||||
|
// 即使 worktree 当前不存在 / 已被外部 rm 掉,调用 git worktree remove --force 也会成功
|
||||||
|
// 或返回特定 stderr,调用方据此决定是否吞错。
|
||||||
|
func (r *DefaultRunner) WorktreeRemove(ctx context.Context, dir, path string) error {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := r.exec(ctx, dir, nil, "worktree", "remove", "--force", path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorktreeList 返回 dir 下所有 worktree 的元数据(含 main)。
|
||||||
|
func (r *DefaultRunner) WorktreeList(ctx context.Context, dir string) ([]Worktree, error) {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
out, _, err := r.exec(ctx, dir, nil, "worktree", "list", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWorktreeList(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWorktreeList 解析 git worktree list --porcelain 输出。
|
||||||
|
// 每个 worktree 由若干 KV 行组成,记录间空行分隔:
|
||||||
|
//
|
||||||
|
// worktree /abs/path
|
||||||
|
// HEAD <sha>
|
||||||
|
// branch refs/heads/<name> (or "detached")
|
||||||
|
// bare (only for bare main)
|
||||||
|
func parseWorktreeList(buf []byte) []Worktree {
|
||||||
|
var out []Worktree
|
||||||
|
var cur Worktree
|
||||||
|
flush := func() {
|
||||||
|
if cur.Path != "" {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
cur = Worktree{}
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(buf))
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if line == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "worktree "):
|
||||||
|
cur.Path = strings.TrimPrefix(line, "worktree ")
|
||||||
|
case strings.HasPrefix(line, "HEAD "):
|
||||||
|
cur.HEAD = strings.TrimPrefix(line, "HEAD ")
|
||||||
|
case strings.HasPrefix(line, "branch "):
|
||||||
|
cur.Branch = strings.TrimPrefix(strings.TrimPrefix(line, "branch "), "refs/heads/")
|
||||||
|
case line == "bare":
|
||||||
|
cur.Bare = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorktree_AddListRemove(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gitAvailable(t)
|
||||||
|
bare := makeBareRepo(t)
|
||||||
|
r := newTestRunner(t)
|
||||||
|
main := filepath.Join(t.TempDir(), "main")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
require.NoError(t, r.Clone(ctx, main, bare, "main", nil))
|
||||||
|
|
||||||
|
feat := filepath.Join(filepath.Dir(main), "feat-x")
|
||||||
|
require.NoError(t, r.WorktreeAdd(ctx, main, "feat-x", feat))
|
||||||
|
|
||||||
|
list, err := r.WorktreeList(ctx, main)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(list), 2)
|
||||||
|
var found bool
|
||||||
|
for _, w := range list {
|
||||||
|
if w.Branch == "feat-x" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, found, "feat-x worktree not in list")
|
||||||
|
|
||||||
|
require.NoError(t, r.WorktreeRemove(ctx, main, feat))
|
||||||
|
list, err = r.WorktreeList(ctx, main)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, w := range list {
|
||||||
|
require.NotEqual(t, "feat-x", w.Branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWorktreeList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
buf := []byte("worktree /m\nHEAD aaa\nbranch refs/heads/main\n\nworktree /f\nHEAD bbb\nbranch refs/heads/feat-x\n\n")
|
||||||
|
got := parseWorktreeList(buf)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
require.Equal(t, "main", got[0].Branch)
|
||||||
|
require.Equal(t, "feat-x", got[1].Branch)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user