From d060ddc96310109df573e19c7c458b84816c6af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=B2=B3=E5=B3=B0?= <132282304+syf2211@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:38:23 +0800 Subject: [PATCH] fix(restore): handle symlinked CLAUDE.md paths during config snapshot (#1441) When snapshotting PR-authored sensitive paths into .claude-pr/, cpSync with dereference:true throws ENOENT if a symlink target is missing on the PR head (e.g. .claude/CLAUDE.md -> ../AGENTS.md). Fall back to copying the symlink itself so restoreConfigFromBase can continue and restore trusted base versions. Fixes #1398 --- src/github/operations/restore-config.ts | 21 +++++++- test/restore-config.test.ts | 67 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/github/operations/restore-config.ts b/src/github/operations/restore-config.ts index b847cf32..09ec9f7b 100644 --- a/src/github/operations/restore-config.ts +++ b/src/github/operations/restore-config.ts @@ -30,6 +30,25 @@ const SENSITIVE_PATHS = [ const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/"; +function snapshotSensitivePath(src: string, dest: string): void { + try { + cpSync(src, dest, { recursive: true, dereference: true }); + } catch (error) { + // Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md` + // -> `../AGENTS.md` when the PR deleted the target) make dereferenced + // copies throw ENOENT. Preserve the symlink for the review snapshot instead. + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + cpSync(src, dest, { recursive: true }); + return; + } + throw error; + } +} + function ensureClaudePrExcludedFromGit(): void { const excludePath = execFileSync( "git", @@ -86,7 +105,7 @@ export function restoreConfigFromBase(baseBranch: string): void { rmSync(".claude-pr", { recursive: true, force: true }); for (const p of SENSITIVE_PATHS) { if (existsSync(p)) { - cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true }); + snapshotSensitivePath(p, `.claude-pr/${p}`); } } if (existsSync(".claude-pr")) { diff --git a/test/restore-config.test.ts b/test/restore-config.test.ts index 80439ead..43dbf751 100644 --- a/test/restore-config.test.ts +++ b/test/restore-config.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execFileSync } from "child_process"; import { existsSync, + lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "fs"; import { dirname, isAbsolute, join } from "path"; @@ -121,6 +123,48 @@ describe("restoreConfigFromBase", () => { } }); + test("restores symlinked CLAUDE.md paths from the PR base branch", () => { + setupSymlinkedMainBranch(); + + git(["checkout", "pr"]); + writeRepoFile( + ".claude/settings.json", + `${JSON.stringify({ source: "pr-with-symlinks" })}\n`, + ); + git(["add", ".claude/settings.json"]); + git(["commit", "-m", "pr updates settings"]); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile("CLAUDE.md").isSymbolicLink()).toBe(true); + expect(lstatRepoFile(".claude/CLAUDE.md").isSymbolicLink()).toBe(true); + expect(readRepoFile("CLAUDE.md").trim()).toBe("shared agent instructions"); + expect(readRepoFile(".claude/CLAUDE.md").trim()).toBe( + "shared agent instructions", + ); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + + test("snapshots symlinked sensitive paths even when the PR head target is missing", () => { + setupSymlinkedMainBranch(); + + git(["checkout", "pr"]); + rmSync(join(repoDir, "AGENTS.md"), { force: true }); + git(["add", "-A"]); + git(["commit", "-m", "pr deletes agents file"]); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe( + true, + ); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + test("does not modify an existing .gitignore", () => { writeRepoFile(".gitignore", "node_modules\n"); git(["add", ".gitignore"]); @@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => { return existsSync(join(repoDir, path)); } + function symlinkRepoFile(path: string, target: string): void { + const fullPath = join(repoDir, path); + mkdirSync(dirname(fullPath), { recursive: true }); + symlinkSync(target, fullPath); + } + + function lstatRepoFile(path: string) { + return lstatSync(join(repoDir, path)); + } + + function setupSymlinkedMainBranch(): void { + git(["checkout", "main"]); + rmSync(join(repoDir, "CLAUDE.md"), { force: true }); + writeRepoFile("AGENTS.md", "shared agent instructions\n"); + symlinkRepoFile("CLAUDE.md", "AGENTS.md"); + symlinkRepoFile(".claude/CLAUDE.md", "../AGENTS.md"); + git(["add", "AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"]); + git(["commit", "-m", "add symlinked claude files"]); + git(["push", "origin", "main"]); + git(["branch", "-D", "pr"]); + git(["checkout", "-b", "pr"]); + } + function countClaudePrExcludeEntries(): number { return readFileSync(getExcludePath(), "utf8") .split(/\r?\n/)