diff --git a/scripts/pr-lib/worktree.sh b/scripts/pr-lib/worktree.sh index aced65d5ca39..0fae42bdb1d7 100644 --- a/scripts/pr-lib/worktree.sh +++ b/scripts/pr-lib/worktree.sh @@ -163,8 +163,10 @@ recover_review_transition() { fi validate_review_transition_state "$pr" "$source" "$target" || return 1 - if ! git diff --quiet "$source" "$target"; then - git diff --name-only --no-renames -z "$source" "$target" | + # Completed deletions are absent from both index and target, so replay only + # remaining entries rather than passing already-removed paths to restore. + if ! git diff --cached --quiet "$target"; then + git diff --cached --name-only --no-renames -z "$target" | git --literal-pathspecs restore --source="$target" --staged --worktree \ --pathspec-from-file=- --pathspec-file-nul || return 1 fi diff --git a/test/scripts/pr-worktree-containment.test.ts b/test/scripts/pr-worktree-containment.test.ts index 3c641fbbb308..5f0449b12c3a 100644 --- a/test/scripts/pr-worktree-containment.test.ts +++ b/test/scripts/pr-worktree-containment.test.ts @@ -76,6 +76,7 @@ function createReviewFixture(): ReviewFixture { writeFileSync(join(root, "transition-a.txt"), "pr-a\n"); writeFileSync(join(root, "transition-b.txt"), "pr-b\n"); writeFileSync(join(root, "overlap.txt"), "pr-a-overlap\n"); + writeFileSync(join(root, "pr-only.txt"), "removed on main\n"); git(root, "add", "."); git(root, "commit", "-m", "PR head A"); const prASha = git(root, "rev-parse", "HEAD"); @@ -256,6 +257,53 @@ describePosix("scripts/pr worktree containment", () => { expectCanonicalCheckoutUnchanged(fixture); }); + for (const mode of ["branch", "detached"] as const) { + it.each([ + { + name: "partially restored index", + setup: ['git restore --source="$target_sha" --staged --worktree -- pr-only.txt'], + }, + { + name: "fully restored index", + setup: ['git restore --source="$target_sha" --staged --worktree -- .'], + }, + { + name: "interrupted recovery checkout", + setup: [ + 'git() { if [ "${1:-}" = checkout ]; then return 73; fi; command git "$@"; }', + "if recover_review_transition 42; then exit 1; fi", + "unset -f git", + 'git diff --cached --quiet "$target_sha"', + ], + }, + ])(`recovers completed deletions after $name (${mode})`, ({ setup }) => { + const fixture = createReviewFixture(); + const worktree = join(fixture.root, ".worktrees", "pr-42"); + const result = runShell(fixture, [ + "review_init 42", + "review_checkout_pr 42", + 'printf "preserve me\\n" > .local/review-note', + "source_sha=$(git rev-parse HEAD)", + "target_sha=$(git rev-parse origin/main)", + `write_review_transition_journal 42 "$source_sha" "$target_sha" ${mode} temp/pr-42`, + ...setup, + "test ! -e pr-only.txt", + 'test "$(git rev-parse HEAD)" = "$source_sha"', + "test -f .local/review-transition.json", + "recover_review_transition 42", + ]); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(git(worktree, "rev-parse", "HEAD")).toBe(fixture.mainSha); + expect(git(worktree, "branch", "--show-current")).toBe(mode === "branch" ? "temp/pr-42" : ""); + expect(git(worktree, "status", "--porcelain=v1", "--untracked-files=no")).toBe(""); + expect(existsSync(join(worktree, "pr-only.txt"))).toBe(false); + expect(readFileSync(join(worktree, ".local", "review-note"), "utf8")).toBe("preserve me\n"); + expect(existsSync(join(worktree, ".local", "review-transition.json"))).toBe(false); + expectCanonicalCheckoutUnchanged(fixture); + }); + } + for (const testCase of [ { name: "staged",