From f0864e2c8438b5b266a286772e4dc84a10a5030d Mon Sep 17 00:00:00 2001 From: loop2zero Date: Thu, 11 Jun 2026 09:32:29 +0800 Subject: [PATCH] fix(core): fetch origin/ before creating task worktrees Chained subtasks merge remotely while the local default branch never advances, so each new worktree was cut from a stale base and every PR in the chain conflicted (ARC-104 PR #10, ARC-105 PR #11). createWorktree now fetches origin/ and bases the new branch on the remote tip via resolveWorktreeBase; when the fetch fails (offline or no remote) it logs a warning and degrades to the local branch so dispatch is never blocked. createWorktreeAt is the repo-root-level core, extracted so tests can run against a temp repo with a local bare remote. ARC-108 Co-Authored-By: Claude Fable 5 --- .devlog/workpad.md | 34 ++++++ src/core/__tests__/worktree-manager.test.ts | 113 ++++++++++++++++++++ src/core/worktree-manager.ts | 57 ++++++++-- 3 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 .devlog/workpad.md create mode 100644 src/core/__tests__/worktree-manager.test.ts diff --git a/.devlog/workpad.md b/.devlog/workpad.md new file mode 100644 index 0000000..06c9a65 --- /dev/null +++ b/.devlog/workpad.md @@ -0,0 +1,34 @@ +# Workpad — ARC-108: dispatcher 开 worktree 前先 fetch 远端默认分支 + +## 问题 + +`createWorktree`(`src/core/worktree-manager.ts`)基于**本地** defaultBranch 切新分支,从不 `git fetch`。 +链式子任务每合并一环,远端 main 前进一次,下一环的 worktree 必然基于陈旧基底 → PR 必冲突 +(实测:ARC-104 PR #10 workpad add/add 冲突;ARC-105 PR #11 三文件冲突)。 + +## 方案 + +最小改动,全部收在 `worktree-manager.ts`,公共 API(`createWorktree` 签名)不变: + +1. `resolveWorktreeBase(repoRoot, baseBranch)` — `git fetch origin ` + + `rev-parse --verify origin/`,成功返回 `origin/`;任一步失败 + (离线 / 无 remote)打 `console.warn` 降级返回本地 `baseBranch`,不阻塞派发。 +2. `createWorktreeAt(repoRoot, name, branch, baseBranch?)` — repo-root 级的 worktree 创建, + 有 baseBranch 时先过 `resolveWorktreeBase`。抽出这层是为了单测不依赖 + `devlog.config.json`(`getRepoRoot` 走 cwd 配置,测试无法注入)。 +3. `createWorktree` 保持原签名,委托给 `createWorktreeAt(getRepoRoot(projectId), …)`。 + `task-execution.ts` 零改动。 + +## TDD 过程 + +- 先写 `src/core/__tests__/worktree-manager.test.ts`(node:test + tsx,跟随既有风格), + 4 个用例,跑出 import 失败(函数不存在)→ 红。 +- 测试夹具:bare remote + 两个 clone,writer clone 推进远端,local clone 保持陈旧, + 复现"本地基底落后远端"的派发场景。 +- 实现后 4/4 绿。 + +## 验证证据 + +- 有远端:`createWorktreeAt` 产出的 worktree `HEAD` == 远端 tip(≠ 陈旧本地 tip,前置断言保证)✅ +- 无远端:fetch 失败仍创建 worktree,基底为本地 tip(降级 + warn 日志)✅ +- `bun run typecheck` ✅;`TZ=Asia/Shanghai bun run test` → 350 pass / 0 fail ✅ diff --git a/src/core/__tests__/worktree-manager.test.ts b/src/core/__tests__/worktree-manager.test.ts new file mode 100644 index 0000000..62cf3fd --- /dev/null +++ b/src/core/__tests__/worktree-manager.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createWorktreeAt, resolveWorktreeBase } from "../worktree-manager"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function initRepo(dir: string): void { + git(dir, "init", "-b", "main"); + git(dir, "config", "user.email", "test@devlog.local"); + git(dir, "config", "user.name", "DevLog Test"); +} + +function commitFile(dir: string, name: string, content: string, message: string): string { + writeFileSync(join(dir, name), content); + git(dir, "add", name); + git(dir, "commit", "-m", message); + return git(dir, "rev-parse", "HEAD"); +} + +/** + * Builds: a bare "remote", a "local" clone whose origin/main is BEHIND the + * remote (a second clone pushed a newer commit). Mirrors the dispatcher + * scenario where chained subtasks merge remotely while the local default + * branch never advances. + */ +function makeStaleLocalRepo(root: string): { local: string; remoteTip: string } { + const remote = join(root, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remote], { encoding: "utf8" }); + + const local = join(root, "local"); + execFileSync("git", ["clone", remote, local], { encoding: "utf8" }); + git(local, "config", "user.email", "test@devlog.local"); + git(local, "config", "user.name", "DevLog Test"); + commitFile(local, "a.txt", "a", "commit A"); + git(local, "push", "origin", "main"); + + const writer = join(root, "writer"); + execFileSync("git", ["clone", remote, writer], { encoding: "utf8" }); + git(writer, "config", "user.email", "test@devlog.local"); + git(writer, "config", "user.name", "DevLog Test"); + const remoteTip = commitFile(writer, "b.txt", "b", "commit B (remote ahead)"); + git(writer, "push", "origin", "main"); + + return { local, remoteTip }; +} + +test("resolveWorktreeBase fetches and returns origin/ when a remote exists", async () => { + const root = mkdtempSync(join(tmpdir(), "devlog-worktree-fetch-")); + try { + const { local, remoteTip } = makeStaleLocalRepo(root); + + const base = await resolveWorktreeBase(local, "main"); + + assert.equal(base, "origin/main"); + // The fetch must have advanced origin/main to the remote tip. + assert.equal(git(local, "rev-parse", "origin/main"), remoteTip); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveWorktreeBase falls back to the local branch when fetch fails", async () => { + const root = mkdtempSync(join(tmpdir(), "devlog-worktree-nofetch-")); + try { + const local = join(root, "local"); + execFileSync("mkdir", ["-p", local]); + initRepo(local); + commitFile(local, "a.txt", "a", "commit A"); + // No remote configured at all — fetch must fail, dispatch must not block. + const base = await resolveWorktreeBase(local, "main"); + assert.equal(base, "main"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("createWorktreeAt bases the new branch on origin/, not the stale local branch", async () => { + const root = mkdtempSync(join(tmpdir(), "devlog-worktree-create-")); + try { + const { local, remoteTip } = makeStaleLocalRepo(root); + const localTip = git(local, "rev-parse", "main"); + assert.notEqual(localTip, remoteTip, "precondition: local main must be stale"); + + const worktreePath = await createWorktreeAt(local, "wt-fresh", "task/fresh", "main"); + + assert.equal(git(worktreePath, "rev-parse", "HEAD"), remoteTip); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("createWorktreeAt still creates the worktree from the local base when fetch fails", async () => { + const root = mkdtempSync(join(tmpdir(), "devlog-worktree-degraded-")); + try { + const local = join(root, "local"); + execFileSync("mkdir", ["-p", local]); + initRepo(local); + const localTip = commitFile(local, "a.txt", "a", "commit A"); + + const worktreePath = await createWorktreeAt(local, "wt-degraded", "task/degraded", "main"); + + assert.equal(git(worktreePath, "rev-parse", "HEAD"), localTip); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/core/worktree-manager.ts b/src/core/worktree-manager.ts index e03513e..c1f6278 100644 --- a/src/core/worktree-manager.ts +++ b/src/core/worktree-manager.ts @@ -44,21 +44,64 @@ export async function listWorktrees(projectId?: string): Promise { return entries; } -export async function createWorktree( +/** + * Fetches origin/ so new worktrees start from the remote tip + * instead of a stale local branch (chained subtasks otherwise conflict on + * every link). Returns "origin/" on success; falls back to the + * local branch when the fetch fails (offline / no remote) so dispatch is + * never blocked. + */ +export async function resolveWorktreeBase( + repoRoot: string, + baseBranch: string +): Promise { + try { + await execFileAsync("git", ["fetch", "origin", baseBranch], { cwd: repoRoot }); + await execFileAsync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], { + cwd: repoRoot, + }); + return `origin/${baseBranch}`; + } catch (err) { + console.warn( + `[worktree] fetch origin/${baseBranch} failed; falling back to local '${baseBranch}': ${(err as Error).message}` + ); + return baseBranch; + } +} + +export async function createWorktreeAt( + repoRoot: string, name: string, branch: string, - baseBranch?: string, - projectId?: string -): Promise { - const repoRoot = getRepoRoot(projectId); + baseBranch?: string +): Promise { const worktreePath = path.join(repoRoot, ".worktrees", name); if (baseBranch) { - await git(projectId, "worktree", "add", "-b", branch, worktreePath, baseBranch); + const base = await resolveWorktreeBase(repoRoot, baseBranch); + await execFileAsync( + "git", + ["worktree", "add", "-b", branch, worktreePath, base], + { cwd: repoRoot } + ); } else { - await git(projectId, "worktree", "add", "-b", branch, worktreePath); + await execFileAsync("git", ["worktree", "add", "-b", branch, worktreePath], { + cwd: repoRoot, + }); } + return worktreePath; +} + +export async function createWorktree( + name: string, + branch: string, + baseBranch?: string, + projectId?: string +): Promise { + const repoRoot = getRepoRoot(projectId); + const worktreePath = await createWorktreeAt(repoRoot, name, branch, baseBranch); + const worktrees = await listWorktrees(projectId); const created = worktrees.find((w) => w.path === worktreePath); if (!created) throw new Error("Worktree created but not found in list");