From b44d6c298c38ce21bdc533079508496ab898dfad Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 21:51:48 +0800 Subject: [PATCH] feat(infra/git): worktree add/remove/list with porcelain parser --- internal/infra/git/worktree.go | 85 +++++++++++++++++++++++++++++ internal/infra/git/worktree_test.go | 51 +++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/infra/git/worktree.go create mode 100644 internal/infra/git/worktree_test.go diff --git a/internal/infra/git/worktree.go b/internal/infra/git/worktree.go new file mode 100644 index 0000000..93ce748 --- /dev/null +++ b/internal/infra/git/worktree.go @@ -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 +// branch refs/heads/ (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 +} diff --git a/internal/infra/git/worktree_test.go b/internal/infra/git/worktree_test.go new file mode 100644 index 0000000..0027520 --- /dev/null +++ b/internal/infra/git/worktree_test.go @@ -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) +}