From 2430ce69b00e18fe7f552182c33281bad4a1cea0 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Fri, 21 Aug 2026 17:54:20 +0000 Subject: [PATCH] FN-112: capture workspace merge evidence from task worktrees Capture workspace merge evidence from immutable acquisition baselines to persisted task branches so real linked task worktrees land reviewed changes reliably. - Compare review fingerprints and landing ranges against the persisted task branch rather than the live worktree HEAD. - Add linked-task-worktree regression coverage for per-repository landing proof and exactly-once finalization. - Document the regression and publish the workspace merge fix in a patch changeset. Files changed: .../fn-112-workspace-task-worktree-evidence.md | 7 ++++ docs/architecture.md | 1 + ...e-empty-merge-boundary-finalization-livelock.md | 8 +++- .../engine/src/__tests__/_workspace-fixture.ts | 15 ++++++++ .../engine/src/__tests__/workspace-e2e.test.ts | 31 ++++++++++++--- .../__tests__/workspace-merger-scope-gates.test.ts | 2 +- .../engine/src/__tests__/workspace-merger.test.ts | 45 +++++++++++++++++++++- packages/engine/src/merge/merger-ai.ts | 29 ++++++++++++-- 8 files changed, 125 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-112 Fusion-Task-Lineage: 66e8498e-13f2-461b-ae38-bf750383115b Co-authored-by: Fusion --- ...fn-112-workspace-task-worktree-evidence.md | 7 +++ docs/architecture.md | 1 + ...ty-merge-boundary-finalization-livelock.md | 8 +++- .../src/__tests__/_workspace-fixture.ts | 15 +++++++ .../src/__tests__/workspace-e2e.test.ts | 31 ++++++++++--- .../workspace-merger-scope-gates.test.ts | 2 +- .../src/__tests__/workspace-merger.test.ts | 45 ++++++++++++++++++- packages/engine/src/merge/merger-ai.ts | 29 ++++++++++-- 8 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-112-workspace-task-worktree-evidence.md diff --git a/.changeset/fn-112-workspace-task-worktree-evidence.md b/.changeset/fn-112-workspace-task-worktree-evidence.md new file mode 100644 index 0000000000..256af5aa46 --- /dev/null +++ b/.changeset/fn-112-workspace-task-worktree-evidence.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix workspace auto-merge for work in linked task worktrees. +category: fix +dev: Capture merge evidence from each acquisition baseline to its persisted task branch. diff --git a/docs/architecture.md b/docs/architecture.md index 50f2db9f4c..57edd80542 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -629,6 +629,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - **Workspace acquisition shape:** per-repo acquisition persists only `workspaceWorktrees`; it never exposes a sub-repo path or branch through singular `task.worktree`/`task.branch`, including if the final workspace-state write fails. This preserves workspace classification for dashboard rendering, self-healing, and executor dispatch. - **No root task worktree (FN-034):** when `.fusion/workspace.json` declares repositories, planning, execution, graph nodes, code/plan review, pause/resume, restart, and worktree-session recovery acquire or reuse only the declared repositories' worktrees. The workspace root and `/.worktrees/` are never task checkout or reviewer cwd. Historical singular `worktree`, `branch`, execution-base, and root-session routing metadata is cleared through an advisory-locked, project-scoped mutation that preserves every `workspaceWorktrees` entry and completed-step progress; recovery never deletes or recreates a root checkout. - **Workspace entry mutation (FN-9052):** every per-repository `workspaceWorktrees` update goes through `TaskStore.mergeWorkspaceWorktreeEntry`, which holds the per-task PostgreSQL advisory transaction lock and merges one key under the composite project/task scope. Per-repo callers must never wholesale-replace the map, because a concurrent sibling acquisition, landing, failure, or teardown mutation would otherwise lose its entry. + - **Workspace merge-boundary evidence (FN-112):** landing compares the immutable per-repository acquisition baseline (`baseCommitSha`) to the persisted task branch for changed files, binary review fingerprints, and ahead-but-net-zero evidence. A live linked task worktree normally has `HEAD === entry.branch`, so comparing its `HEAD` to `entry.branch` is a self-comparison that falsely reports no work. Legacy entries without a captured SHA resolve their recorded base target and derive its merge base against the task branch. Code Review and landing therefore approve and consume the same range; unreadable bases, missing scope evidence, and unexplained empty ranges still fail closed before leases, intents, ref advancement, or pushes. Main-checkout fixture stand-ins can hide this defect because their `HEAD` is the integration branch, so real-Git lifecycle coverage keeps a registered linked task worktree alive. - **Durable multi-node workspace coordination (FN-9059):** `project.workspace_coordination_leases`, serialized under a project/resource advisory transaction lock, owns sub-repository acquire exclusivity, per-repository land, workspace liveness/recovery guards, and merge-dispatch admission across engine processes. The owner triple `(taskId, nodeId, incarnationId)` makes only same-process re-entry idempotent; the same task from another node or process incarnation contends. Fresh/reclaimed claims receive a monotonically increasing fence token, while re-entry and renewal retain it. Git-writing tenancies publish once per tenancy—on acquire/reclaimed-expired, not re-entry—the per-repository `refs/fusion/workspace-lease/` or per-merge `refs/fusion/merge-dispatch/` fence ref. A workspace merge dispatches its deterministic per-merge pin to every target sub-repository remote before any workspace land begins; the workspace root need not be a git checkout and is never used as a substitute remote. A re-entrant claimant reuses its pin, except to repair a claim-to-publish gap with no pin; acquire-kind leases have no fence ref. A land push atomically CASes the target ref observed by the tenancy plus its repository fence and enclosing merge-dispatch fence refs in one push; a merge-only push CASes its target and dispatch pin. Thus a superseded owner is rejected even when the target tip has not moved. Lease-protected durable writes run through `withValidWorkspaceLeaseAsync`, and lease mutation is owner-and-fence conditional; no validate-then-act path is valid. - **Workspace merge and land crash boundaries (FN-9059):** merge dispatch claims at body dispatch rather than enqueue; a losing claimant benignly drops. Each live per-repository land lease renews before its five-minute TTL throughout dependency sync, AI merge/review, retry, push, and intent resolution. A refused or failed renewal aborts the land body before its next durable or remote commit point; renewal only maintains liveness, while the current owner/fence handle remains the write authority. The body re-proves its fence at admission, the target-plus-fence atomic push, subsequent PR/branch/status effects, and terminal outcome persistence. Non-CASable effects are idempotent and follow the fenced push, so mid-merge expiry stops a superseded body at its next commit point; a pushed result whose outcome cannot persist is `merge-completed-unrecorded`, never a re-push. Before workspace land pushes, `project.workspace_land_intents` records the expected tip, intended SHA, remote/integration identity, and fence pin. The ordered protocol is intent → atomic push → lease-validated `landedSha` persist plus intent resolution. The node-independent reconciler lists pending intents project-wide, fetches the recorded remote, and resolves reachability on its integration ref. Only a live holder (its own/equal fence or a lower-fence predecessor) or recovery with no held unexpired lease can resolve an intent; stale fence matches, local tip equality, and local object availability are not authority. - **Workspace File Scope interpretation (FN-078):** squash gates, completion scope-leak checks, and main-checkout guards use one repo-local resolver. A matching repo-prefixed declaration has priority; only a declaration with no configured repository prefix can fall back as repo-local scope. If any declaration belongs to another configured repository, a repo with no matching prefix receives no fallback authority. This preserves whole-repo `repo` → `**`, normalized segment-aware nested keys, empty-scope behavior, and strict foreign-repository isolation. diff --git a/docs/solutions/reliability/workspace-empty-merge-boundary-finalization-livelock.md b/docs/solutions/reliability/workspace-empty-merge-boundary-finalization-livelock.md index 5ada9e90ab..361aa44589 100644 --- a/docs/solutions/reliability/workspace-empty-merge-boundary-finalization-livelock.md +++ b/docs/solutions/reliability/workspace-empty-merge-boundary-finalization-livelock.md @@ -35,6 +35,12 @@ Workspace review remediation stores only the scope revision, failing repository, | H12 optional deployment callback | Confirmed risk | Direct `landWorkspaceTask` performs its own canonical blocker check. | | H13 planning dispatch noise | Non-causal signal | Existing PostgreSQL planning-episode deduplication remains unchanged. | +## FN-112 linked-task-worktree regression + +FN-106 correctly rejected unexplained empty obligations, but its landing capture compared `HEAD` with `entry.branch` from `entry.worktreePath`. In production that path is the live linked task worktree, where both names normally resolve to the same task tip. The self-comparison made reviewed changes look empty and produced `Workspace merge has no evidenced landing obligations`. + +Landing now uses one range for all evidence: the immutable acquisition `baseCommitSha` to the persisted task branch. Legacy entries without that SHA resolve their recorded repository target and derive a merge base against that branch. This matches Code Review's acquisition-base-to-linked-`HEAD` fingerprint while retaining the fail-closed readiness rule for unreadable bases, stale review fingerprints, out-of-scope work, duplicates, net-zero branches, and unexplained emptiness. Real-Git lifecycle coverage keeps a registered linked task worktree alive rather than substituting the main checkout, and proves exactly-once integration advancement, durable per-repository and aggregate landing proof, and terminal finalization. + ## Regression coverage -Run the targeted engine workspace merger, self-healing, checkout-guard, review-routing, lease, real-Git slow, PostgreSQL workspace, CLI, and dashboard consumer tests listed in FN-106. The symptom fixture covers a pre-baseline same-ID commit, a previously landed scoped repository, an all-landed second pass, failed Code Review admission, and recreated recovery owners. +Run the targeted engine workspace merger, self-healing, checkout-guard, review-routing, lease, real-Git slow, PostgreSQL workspace, CLI, and dashboard consumer tests listed in FN-106. The symptom fixture covers a pre-baseline same-ID commit, a previously landed scoped repository, an all-landed second pass, failed Code Review admission, recreated recovery owners, and the live linked-task-worktree shape repaired by FN-112. diff --git a/packages/engine/src/__tests__/_workspace-fixture.ts b/packages/engine/src/__tests__/_workspace-fixture.ts index d671cf942b..144d8839ea 100644 --- a/packages/engine/src/__tests__/_workspace-fixture.ts +++ b/packages/engine/src/__tests__/_workspace-fixture.ts @@ -37,6 +37,8 @@ export interface WorkspaceFixture { repoPath(rel: string): string; /** Run a git command inside a sub-repo. */ git(rel: string, command: string): string; + /** Create a live linked task worktree and capture its immutable acquisition baseline. */ + createLinkedTaskWorktree(rel: string, branch: string): { worktreePath: string; baseCommitSha: string }; /** Remove all on-disk fixture state. */ cleanup(): void; } @@ -62,6 +64,19 @@ export async function createWorkspaceFixture( repos, repoPath: (rel: string) => path.join(rootDir, rel), git: (rel: string, command: string) => git(path.join(rootDir, rel), command), + /* + FNXC:WorkspaceMergeEvidence 2026-08-21-17:33: + FN-112 needs real-Git coverage where the persisted path is the live task worktree and HEAD + equals its task branch. Main-checkout stand-ins hid the invalid self-comparison that erased + merge evidence, so this fixture captures the acquisition baseline before the task commit. + */ + createLinkedTaskWorktree: (rel: string, branch: string) => { + const repoDir = path.join(rootDir, rel); + const worktreePath = path.join(rootDir, `.task-worktree-${rel.replace(/[^a-z0-9]+/gi, "-")}`); + const baseCommitSha = git(repoDir, "git rev-parse HEAD"); + git(repoDir, `git worktree add -b ${branch} ${worktreePath} HEAD`); + return { worktreePath, baseCommitSha }; + }, cleanup: () => rmSync(rootDir, { recursive: true, force: true }), }; } diff --git a/packages/engine/src/__tests__/workspace-e2e.test.ts b/packages/engine/src/__tests__/workspace-e2e.test.ts index f3e602e934..d80604dfb4 100644 --- a/packages/engine/src/__tests__/workspace-e2e.test.ts +++ b/packages/engine/src/__tests__/workspace-e2e.test.ts @@ -120,7 +120,7 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"], extra: Partial .filter(([repo]) => scopedRepositories.includes(repo)) .map(([repo, entry]) => { const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); - const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }); return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; })); const task = { @@ -201,6 +201,19 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s fx.git(repoRel, `git worktree remove --force ${wt}`); } +function addLinkedTaskWorktreeWithEdit( + fx: WorkspaceFixture, + repoRel: string, + content: string, +): { worktreePath: string; branch: string; baseCommitSha: string } { + const linked = fx.createLinkedTaskWorktree(repoRel, BRANCH); + configureIdentity(linked.worktreePath); + writeFileSync(path.join(linked.worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: linked.worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): linked task worktree feature in ${repoRel}"`, { cwd: linked.worktreePath, stdio: "pipe" }); + return { ...linked, branch: BRANCH }; +} + /** Make a sub-repo's integration tip and the task branch BOTH edit README so the squash conflicts. */ function makeConflictingRepo(fx: WorkspaceFixture, repoRel: string): void { const repoDir = fx.repoPath(repoRel); @@ -249,8 +262,8 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase fx = await createWorkspaceFixture(["repo-a", "repo-b"]); const originA = addOriginRemote(fx, "repo-a"); const originB = addOriginRemote(fx, "repo-b"); - addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); - addRepoBranchWithEdit(fx, "repo-b", "b feature\n"); + const repoA = addLinkedTaskWorktreeWithEdit(fx, "repo-a", "a feature\n"); + const repoB = addLinkedTaskWorktreeWithEdit(fx, "repo-b", "b feature\n"); const tipABefore = fx.git("repo-a", "git rev-parse refs/heads/main"); const tipBBefore = fx.git("repo-b", "git rev-parse refs/heads/main"); @@ -263,8 +276,8 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase const store = createStore([ makeTask({ - "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH }, - "repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH }, + "repo-a": repoA, + "repo-b": repoB, }), ]); const task = store.tasks.get(TASK_ID)!; @@ -289,6 +302,12 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase expect(persisted["repo-b"].landedSha).toBeTruthy(); expect(persisted["repo-a"].landedSha).toBe(fx.git("repo-a", "git rev-parse refs/heads/main")); expect(persisted["repo-b"].landedSha).toBe(fx.git("repo-b", "git rev-parse refs/heads/main")); + const finalizedTask = store.tasks.get(TASK_ID)!; + expect(finalizedTask.mergeDetails?.workspaceLandedShas).toEqual({ + "repo-a": persisted["repo-a"].landedSha, + "repo-b": persisted["repo-b"].landedSha, + }); + expect(finalizedTask.mergeDetails?.mergeConfirmed).toBe(true); // Finalize EXACTLY once. expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); @@ -387,7 +406,7 @@ describeIfGit("workspace e2e — merge (no-push) + partial-land recovery (Phase const repoB = recoveringTask.workspaceWorktrees!["repo-b"]; const repoBMergeBase = execSync(`git merge-base HEAD ${repoB.branch}`, { cwd: repoB.worktreePath, encoding: "utf8" }).trim(); recoveringTask.repositoryScope!.reviewEvidence!["repo-b"] = { - fingerprint: createHash("sha256").update(execSync(`git diff --binary ${repoB.baseCommitSha ?? repoBMergeBase}..HEAD`, { cwd: repoB.worktreePath, encoding: "utf8" })).digest("hex"), + fingerprint: createHash("sha256").update(execSync(`git diff --binary ${repoB.baseCommitSha ?? repoBMergeBase}..${repoB.branch}`, { cwd: repoB.worktreePath, encoding: "utf8" })).digest("hex"), approvedAt: new Date().toISOString(), }; diff --git a/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts index 13b74f33ce..fe7c645aca 100644 --- a/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts @@ -53,7 +53,7 @@ function storeFor(task: Task, scope: string[]): TaskStore & { updates: Array): NonNullable["reviewEvidence"] { return Object.fromEntries(Object.entries(workspaceWorktrees).map(([repo, entry]) => { const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); - const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }); return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; })); } diff --git a/packages/engine/src/__tests__/workspace-merger.test.ts b/packages/engine/src/__tests__/workspace-merger.test.ts index dcad9d8736..83f69ac765 100644 --- a/packages/engine/src/__tests__/workspace-merger.test.ts +++ b/packages/engine/src/__tests__/workspace-merger.test.ts @@ -104,6 +104,24 @@ function addRepoBranchWithEdit(fx: WorkspaceFixture, repoRel: string, content: s fx.git(repoRel, `git worktree remove --force ${worktreePath}`); } +/** Create production-shaped evidence: the persisted path remains a live task worktree at its branch tip. */ +function addRegisteredTaskWorktreeWithEdit( + fx: WorkspaceFixture, + repoRel: string, + content: string, +): { worktreePath: string; baseCommitSha: string } { + const repoDir = fx.repoPath(repoRel); + // Keep the linked checkout outside its main checkout so the production dirty-root guard stays meaningful. + const worktreePath = path.join(fx.rootDir, `.task-worktree-${repoRel}`); + const baseCommitSha = fx.git(repoRel, "git rev-parse HEAD"); + fx.git(repoRel, `git worktree add -b ${BRANCH} ${worktreePath} HEAD`); + configureIdentity(worktreePath); + writeFileSync(path.join(worktreePath, "feature.txt"), content, "utf-8"); + execSync("git add feature.txt", { cwd: worktreePath, stdio: "pipe" }); + execSync(`git commit -m "feat(${TASK_ID}): linked task worktree feature"`, { cwd: worktreePath, stdio: "pipe" }); + return { worktreePath, baseCommitSha }; +} + /** * FN-8141 shape: a `fusion/` branch that committed work then REVERTED it — AHEAD of the * integration tip (two real commits) but net-zero, so its tip is NOT an ancestor of main and the @@ -184,7 +202,7 @@ function makeTask(workspaceWorktrees: Task["workspaceWorktrees"]): Task { // merge-boundary fingerprint that production requires from Code Review. reviewEvidence: Object.fromEntries(Object.entries(workspaceWorktrees ?? {}).map(([repo, entry]) => { const mergeBase = execSync(`git merge-base HEAD ${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }).trim(); - const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..HEAD`, { cwd: entry.worktreePath, encoding: "utf8" }); + const diff = execSync(`git diff --binary ${entry.baseCommitSha ?? mergeBase}..${entry.branch}`, { cwd: entry.worktreePath, encoding: "utf8" }); return [repo, { fingerprint: createHash("sha256").update(diff).digest("hex"), approvedAt: new Date().toISOString() }]; })), }, @@ -240,6 +258,31 @@ describeIfGit("landWorkspaceTask — per-repo merge loop (Phase C U1)", () => { expect(store.emitted.filter((e) => e.event === "task:merged")).toHaveLength(1); }); + it("lands reviewed work from a live linked task worktree baseline", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const linked = addRegisteredTaskWorktreeWithEdit(fx, "repo-a", "linked feature\n"); + const integrationTipBefore = fx.git("repo-a", "git rev-parse refs/heads/main"); + const store = createStore(); + const task = makeTask({ + "repo-a": { worktreePath: linked.worktreePath, branch: BRANCH, baseCommitSha: linked.baseCommitSha }, + }); + + // The production shape that regressed: HEAD and the branch name resolve to the same task tip. + expect(execSync("git rev-parse HEAD", { cwd: linked.worktreePath, encoding: "utf8" }).trim()) + .toBe(execSync(`git rev-parse ${BRANCH}`, { cwd: linked.worktreePath, encoding: "utf8" }).trim()); + + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH), + reviewAgent: approveReviewAgent, + }); + + expect(result.repos[0]?.error).toBeUndefined(); + expect(result.repos).toMatchObject([{ repo: "repo-a", status: "landed" }]); + expect(result.allLanded).toBe(true); + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).not.toBe(integrationTipBefore); + expect(store.moveTaskCalls).toEqual([{ id: TASK_ID, column: "done" }]); + }); + it("reuses reconciled review findings for a workspace sub-repository", async () => { fx = await createWorkspaceFixture(["repo-a"]); addRepoBranchWithEdit(fx, "repo-a", "a feature\n"); diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index a329c611e4..57c8041f70 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -2152,11 +2152,32 @@ export async function landWorkspaceTask( .sort(([left], [right]) => left.localeCompare(right))) { if (!entry.branch) throw new Error(`Workspace repository ${repoRel} has no task branch for fresh merge evidence`); try { - const { stdout: mergeBase } = await execFileAsync("git", ["merge-base", "HEAD", entry.branch], { cwd: entry.worktreePath, encoding: "utf8" }); - const { stdout } = await execFileAsync("git", ["diff", "--name-only", `${mergeBase.trim()}..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" }); + /* + FNXC:WorkspaceMergeEvidence 2026-08-21-17:33: + FN-112 requires landing evidence to compare the immutable acquisition baseline with the + persisted task branch. A linked task worktree has HEAD checked out at entry.branch, so the + former HEAD-to-entry.branch comparison self-compared the same commit and incorrectly erased + reviewed work. The range below intentionally matches Code Review's baseCommitSha..HEAD range. + */ + const comparisonBase = entry.baseCommitSha + ? await git(["rev-parse", "--verify", `${entry.baseCommitSha}^{commit}`], entry.worktreePath) + : await (async () => { + const baseResolution = await resolveWorkspaceRepoBaseBranch({ + mode: "recorded", + repoRootDir: join(workspaceRootDir, repoRel), + repoRelPath: repoRel, + task, + settings, + recordedBaseBranch: entry.baseBranch, + }); + return git(["merge-base", baseResolution.branch, entry.branch!], entry.worktreePath); + })(); + if (!comparisonBase) throw new Error("comparison base is empty"); + const comparisonRange = `${comparisonBase}..${entry.branch}`; + const { stdout } = await execFileAsync("git", ["diff", "--name-only", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" }); const files = stdout.split("\n").map((file) => file.trim()).filter(Boolean); if (files.length > 0) { - const { stdout: diffContent } = await execFileAsync("git", ["diff", "--binary", `${entry.baseCommitSha ?? mergeBase.trim()}..HEAD`], { cwd: entry.worktreePath, encoding: "utf8" }); + const { stdout: diffContent } = await execFileAsync("git", ["diff", "--binary", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" }); mergeBoundaryFingerprints[repoRel] = createHash("sha256").update(diffContent).digest("hex"); } if (files.length > 0 && !confirmedScope?.has(repoRel)) { @@ -2165,7 +2186,7 @@ export async function landWorkspaceTask( if (files.length > 0) mergeBoundaryModifiedRepositories.add(repoRel); mergeBoundaryModifiedFiles.push(...files.map((file) => `${repoRel}/${file}`)); if (files.length === 0) { - const { stdout: aheadCount } = await execFileAsync("git", ["rev-list", "--count", `HEAD..${entry.branch}`], { cwd: entry.worktreePath, encoding: "utf8" }); + const { stdout: aheadCount } = await execFileAsync("git", ["rev-list", "--count", comparisonRange], { cwd: entry.worktreePath, encoding: "utf8" }); if (Number(aheadCount.trim()) > 0) netZeroBranchRepositories.add(repoRel); } }