fix(#4758): rescue resolves a relative worktree_path against repoRoot, not process.cwd() (#4872)

* test(#4758): failing-first — rescue must resolve a relative worktree_path against repoRoot

* fix(#4758): rescue resolves a relative worktree_path against repoRoot, not process.cwd()

* test(#4758): review fold-ins — t.after cleanup pattern, post-resolution reader contract comment

* chore(#4758): changeset fragment

* chore(#4758): backfill changeset PR number (4872)

* test(#4758): windows lanes key rescue fakes on resolved path identity, not verbatim strings

win32 path.resolve rewrites driveless-absolute POSIX-style fixture values to the
current drive, so the rescue's (correct) resolved-path handoff stopped matching
verbatim string keys: #3804/#245/#2556/B7/#2852 fakes silently skipped the
rescue and my seam test compared against a POSIX literal. Fakes now key on
path.resolve(repoRoot, …) identity — the same semantics the code and git -C
use — so every rescue test exercises the rescue on every platform.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-19 09:41:51 -04:00
committed by GitHub
parent 63edc777e6
commit d36514b816
3 changed files with 270 additions and 70 deletions

View File

@@ -926,7 +926,19 @@ function rescueSummaryArtifacts(
const mkdirSync = deps.mkdirSync || ((d: string, o?: { recursive?: boolean }) => fs.mkdirSync(d, o));
const copyFileSync = deps.copyFileSync || fs.copyFileSync;
const summaryPaths = findSummaryFiles(worktreePath);
// #4758: resolve the manifest's worktree_path against repoRoot once, at this
// boundary, so every reader of the field here — the fs walker below and the
// `git -C` calls — resolves it the way the caller's git consumers already do
// (`-C <path>` with `{ cwd: repoRoot }`). A relative value passed to the
// walker verbatim made `path.join` emit a relative directory whose reads
// resolved against process.cwd(), silently walking the wrong tree.
// With this resolution, the remaining deps-injectable readers (existsSync,
// readFileSync, mkdirSync, copyFileSync) only ever see absolute paths:
// `dest` is built from repoRoot, `absPath` from the walker's join off this
// resolved base.
const resolvedWorktreePath = path.resolve(repoRoot, worktreePath);
const summaryPaths = findSummaryFiles(resolvedWorktreePath);
const rescuedRelPaths = new Set<string>();
const failures: Array<{ relPath: string; error: string }> = [];
@@ -934,7 +946,7 @@ function rescueSummaryArtifacts(
// relPath is the path relative to the worktree root (e.g. ".planning/q1-SUMMARY.md")
// Normalize to forward slashes so the Set comparison against `git status --porcelain`
// output works on Windows too (git always emits forward slashes in porcelain output).
const relPath = posixNormalize(absPath.slice(worktreePath.length).replace(/^[/\\]/, ''));
const relPath = posixNormalize(absPath.slice(resolvedWorktreePath.length).replace(/^[/\\]/, ''));
// #706: skip rescue when the SUMMARY is already committed on the branch.
// Use `git cat-file -e HEAD:<relPath>` (not `ls-files --error-unmatch`) so
@@ -953,7 +965,7 @@ function rescueSummaryArtifacts(
// copy when the main tree already holds identical content (which is also what
// guards the #706 merge collision). A divergent dest is overwritten, but only
// uncommitted main-tree content could be lost (committed content is git-recoverable).
const catFileResult = execGit(['-C', worktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot });
const catFileResult = execGit(['-C', resolvedWorktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot });
if (catFileResult.exitCode === 0) {
// exit 0 → the SUMMARY is committed on HEAD; the merge will carry it, so skip rescue.
continue;