Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .devlog/workpad.md
Original file line number Diff line number Diff line change
@@ -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 <baseBranch>` +
`rev-parse --verify origin/<baseBranch>`,成功返回 `origin/<baseBranch>`;任一步失败
(离线 / 无 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 ✅
113 changes: 113 additions & 0 deletions src/core/__tests__/worktree-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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/<branch> 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/<defaultBranch>, 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 });
}
});
57 changes: 50 additions & 7 deletions src/core/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,64 @@ export async function listWorktrees(projectId?: string): Promise<Worktree[]> {
return entries;
}

export async function createWorktree(
/**
* Fetches origin/<baseBranch> so new worktrees start from the remote tip
* instead of a stale local branch (chained subtasks otherwise conflict on
* every link). Returns "origin/<baseBranch>" 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<string> {
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<Worktree> {
const repoRoot = getRepoRoot(projectId);
baseBranch?: string
): Promise<string> {
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<Worktree> {
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");
Expand Down