fix(merger): address code-review findings on autostash + observer
P0 — parsePorcelainZ rename/copy handling Git's -z porcelain emits `R <new>\0<old>\0` for renames (and C for copies). The naive split-and-slice treated <old> as an independent dirty path, which made runObservedDestructiveSyncOp warn about phantom "cleared paths" whenever a rename was in flight. Now we detect R/C status and skip the trailing entry. P1 — race-rescue loop unstages between attempts `git stash create` snapshots the index without clearing it, so iteration 2's `git add -A` would re-stage atop iteration 1's leftovers. Tree differences inside the loop then reflected stale staging rather than genuine new writes. Added a `git reset` at the top of each iteration so every attempt starts from a clean index baseline. P1 — writeActiveMergerStatus is now atomic Switched from in-place writeFileSync to temp-file + renameSync. POSIX guarantees rename atomicity on the same filesystem, so a reader can no longer catch the file mid-flush and return a false-negative "no merger active" advisory. P2 — Step regex em-dash clarity `[—\-:]` is functionally fine but obscures intent; switched to `(?:—|-|:)` so the em-dash branch is obvious. Added a test case for the em-dash separator. New tests: - parse-porcelain-z.test.ts (8 cases including renames + copies) - em-dash case added to derive-subject-summary.test.ts 247/247 merger-suite tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
.changeset/merger-review-fixes.md
Normal file
10
.changeset/merger-review-fixes.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Address code-review findings on the merger autostash work:
|
||||
|
||||
- `parsePorcelainZ` now correctly handles rename/copy entries (`R` / `C` status), which emit two NUL-separated entries for one logical change. Previously the old name was treated as an independent dirty path, causing `runObservedDestructiveSyncOp` to emit spurious "cleared N path(s)" warnings whenever a rename was in flight.
|
||||
- The race-rescue loop in `stashUnrelatedRootDirChanges` now runs `git reset` between attempts so each `git add -A` starts from a clean index, preventing iteration-2+ stashes from drifting due to stale staging rather than genuine new writes.
|
||||
- `writeActiveMergerStatus` now writes the advisory file via temp-path + atomic `renameSync` so dashboard readers can't observe a partial write.
|
||||
- `deriveDeterministicSubjectSummary`'s Step regex switched from `[—\-:]` to `(?:—|-|:)` — same matches, but the em-dash intent is obvious to anyone auditing.
|
||||
@@ -40,4 +40,12 @@ describe("deriveDeterministicSubjectSummary", () => {
|
||||
].join("\n");
|
||||
expect(deriveDeterministicSubjectSummary(log)).toBe("first thing (+1 more)");
|
||||
});
|
||||
|
||||
it("handles em-dash separator in Step lines", () => {
|
||||
const log = [
|
||||
"- feat: complete Step 2 — second thing",
|
||||
"- feat: complete Step 1 — first thing",
|
||||
].join("\n");
|
||||
expect(deriveDeterministicSubjectSummary(log)).toBe("first thing (+1 more)");
|
||||
});
|
||||
});
|
||||
|
||||
49
packages/engine/src/__tests__/parse-porcelain-z.test.ts
Normal file
49
packages/engine/src/__tests__/parse-porcelain-z.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePorcelainZ } from "../merger.js";
|
||||
|
||||
describe("parsePorcelainZ", () => {
|
||||
it("returns empty set for empty input", () => {
|
||||
expect(parsePorcelainZ("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses single modified file", () => {
|
||||
expect(parsePorcelainZ(" M src/foo.ts\0")).toEqual(new Set(["src/foo.ts"]));
|
||||
});
|
||||
|
||||
it("parses staged + unstaged modifications", () => {
|
||||
expect(parsePorcelainZ("M src/a.ts\0 M src/b.ts\0")).toEqual(
|
||||
new Set(["src/a.ts", "src/b.ts"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses untracked files", () => {
|
||||
expect(parsePorcelainZ("?? new.ts\0")).toEqual(new Set(["new.ts"]));
|
||||
});
|
||||
|
||||
it("treats a rename as a single path (the new name), not two", () => {
|
||||
// Format: `R <new>\0<old>\0`
|
||||
const raw = "R src/new.ts\0src/old.ts\0";
|
||||
const result = parsePorcelainZ(raw);
|
||||
expect(result).toEqual(new Set(["src/new.ts"]));
|
||||
expect(result.has("src/old.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a copy the same way (single new path, skip old)", () => {
|
||||
const raw = "C src/copy.ts\0src/original.ts\0";
|
||||
expect(parsePorcelainZ(raw)).toEqual(new Set(["src/copy.ts"]));
|
||||
});
|
||||
|
||||
it("handles a rename interleaved with regular modifications", () => {
|
||||
// Three logical changes: M src/a.ts, R src/old → src/new, M src/b.ts
|
||||
const raw = " M src/a.ts\0R src/new.ts\0src/old.ts\0 M src/b.ts\0";
|
||||
expect(parsePorcelainZ(raw)).toEqual(
|
||||
new Set(["src/a.ts", "src/new.ts", "src/b.ts"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths with spaces", () => {
|
||||
expect(parsePorcelainZ(" M src/file with spaces.ts\0")).toEqual(
|
||||
new Set(["src/file with spaces.ts"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export {
|
||||
type VerificationResult,
|
||||
} from "./verification-utils.js";
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { hostname } from "node:os";
|
||||
@@ -1101,7 +1101,14 @@ function writeActiveMergerStatus(rootDir: string, taskId: string): string | null
|
||||
hostname: hostname(),
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(statusPath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
// Atomic write via temp + rename. Without this, a reader that hits
|
||||
// existsSync() between `open` and the final flush sees a partial /
|
||||
// empty file. JSON.parse rejects partial writes so we'd just return
|
||||
// null, but that produces false "no merger active" advisories.
|
||||
// POSIX guarantees rename is atomic on the same filesystem.
|
||||
const tempPath = `${statusPath}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
renameSync(tempPath, statusPath);
|
||||
return statusPath;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1192,17 +1199,30 @@ function runObservedDestructiveSyncOp(
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse `git status -z --porcelain` into a Set of paths. Matches the same
|
||||
* three-class extraction as `snapshotDirtyFiles` (modified + staged +
|
||||
* untracked) but synchronously. Each entry is `XY <path>\0`. */
|
||||
function parsePorcelainZ(raw: string): Set<string> {
|
||||
/** Parse `git status -z --porcelain` into a Set of paths.
|
||||
*
|
||||
* Format per entry: `XY <space> <path>\0` where X = staged status, Y =
|
||||
* unstaged status. Renames and copies are special: they emit TWO
|
||||
* NUL-separated entries, `R <new>\0<old>\0` (or `C <new>\0<old>\0`).
|
||||
* We must consume the trailing `<old>` entry without treating it as a
|
||||
* separate path, otherwise observability code over-reports "cleared
|
||||
* paths" with the historical names of renames. */
|
||||
export function parsePorcelainZ(raw: string): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const entry of raw.split("\0")) {
|
||||
const entries = raw.split("\0");
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
// Format: "XY path" where X = staged status, Y = unstaged status
|
||||
if (entry.length < 4) continue;
|
||||
const status = entry.slice(0, 2);
|
||||
const path = entry.slice(3);
|
||||
if (path) paths.add(path);
|
||||
if (!path) continue;
|
||||
paths.add(path);
|
||||
// Rename/copy: the very next entry is the old path — skip it so it
|
||||
// isn't mistaken for an independent dirty path.
|
||||
if (status.charAt(0) === "R" || status.charAt(0) === "C") {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -1344,6 +1364,13 @@ async function stashUnrelatedRootDirChanges(
|
||||
const newlyDirty = [...currentDirty].filter((p) => !primaryStashPaths.has(p));
|
||||
if (newlyDirty.length === 0) break;
|
||||
const rescueLabel = `${AUTOSTASH_LABEL_PREFIX}${taskId}:race-rescue-${attempt}:${Date.now()}`;
|
||||
// Unstage before re-adding: `git stash create` snapshots the index
|
||||
// but does NOT clear it, so a second iteration's `git add -A` would
|
||||
// re-stage atop iteration-1 leftovers and produce a tree that
|
||||
// differs from current dirt for stale-staging reasons rather than
|
||||
// genuine new writes. The upcoming `git reset --hard HEAD` clears
|
||||
// it eventually, but inside this loop we want a clean baseline.
|
||||
await execAsync("git reset", { cwd: rootDir }).catch(() => undefined);
|
||||
await execAsync("git add -A", { cwd: rootDir });
|
||||
const { stdout: rescueOut } = await execAsync("git stash create", {
|
||||
cwd: rootDir,
|
||||
@@ -1836,7 +1863,10 @@ export function deriveDeterministicSubjectSummary(commitLog: string): string | n
|
||||
l.replace(/^[a-z]+(?:\([^)]+\))?!?:\s*/i, "").trim();
|
||||
const cleaned = lines.map((l) => stripConventional(stripBullet(l)));
|
||||
|
||||
const stepRe = /^complete Step (\d+)\s*[—\-:]\s*(.+)$/i;
|
||||
// Separator is em-dash (U+2014), ASCII hyphen, or colon. Spelled with
|
||||
// explicit alternation rather than a character class so the em-dash
|
||||
// intent is obvious to anyone auditing this regex.
|
||||
const stepRe = /^complete Step (\d+)\s*(?:—|-|:)\s*(.+)$/i;
|
||||
let bestStep: { n: number; summary: string } | null = null;
|
||||
for (const c of cleaned) {
|
||||
const m = c.match(stepRe);
|
||||
|
||||
Reference in New Issue
Block a user