Skip to content

Commit d060ddc

Browse files
authored
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
1 parent 0f07aee commit d060ddc

2 files changed

Lines changed: 87 additions & 1 deletion

File tree

‎src/github/operations/restore-config.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ const SENSITIVE_PATHS = [
3030

3131
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
3232

33+
function snapshotSensitivePath(src: string, dest: string): void {
34+
try {
35+
cpSync(src, dest, { recursive: true, dereference: true });
36+
} catch (error) {
37+
// Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md`
38+
// -> `../AGENTS.md` when the PR deleted the target) make dereferenced
39+
// copies throw ENOENT. Preserve the symlink for the review snapshot instead.
40+
if (
41+
error instanceof Error &&
42+
"code" in error &&
43+
error.code === "ENOENT"
44+
) {
45+
cpSync(src, dest, { recursive: true });
46+
return;
47+
}
48+
throw error;
49+
}
50+
}
51+
3352
function ensureClaudePrExcludedFromGit(): void {
3453
const excludePath = execFileSync(
3554
"git",
@@ -86,7 +105,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
86105
rmSync(".claude-pr", { recursive: true, force: true });
87106
for (const p of SENSITIVE_PATHS) {
88107
if (existsSync(p)) {
89-
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
108+
snapshotSensitivePath(p, `.claude-pr/${p}`);
90109
}
91110
}
92111
if (existsSync(".claude-pr")) {

‎test/restore-config.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import { execFileSync } from "child_process";
33
import {
44
existsSync,
5+
lstatSync,
56
mkdtempSync,
67
mkdirSync,
78
readFileSync,
89
rmSync,
10+
symlinkSync,
911
writeFileSync,
1012
} from "fs";
1113
import { dirname, isAbsolute, join } from "path";
@@ -121,6 +123,48 @@ describe("restoreConfigFromBase", () => {
121123
}
122124
});
123125

126+
test("restores symlinked CLAUDE.md paths from the PR base branch", () => {
127+
setupSymlinkedMainBranch();
128+
129+
git(["checkout", "pr"]);
130+
writeRepoFile(
131+
".claude/settings.json",
132+
`${JSON.stringify({ source: "pr-with-symlinks" })}\n`,
133+
);
134+
git(["add", ".claude/settings.json"]);
135+
git(["commit", "-m", "pr updates settings"]);
136+
137+
restoreConfigFromBase("main");
138+
139+
expect(lstatRepoFile("CLAUDE.md").isSymbolicLink()).toBe(true);
140+
expect(lstatRepoFile(".claude/CLAUDE.md").isSymbolicLink()).toBe(true);
141+
expect(readRepoFile("CLAUDE.md").trim()).toBe("shared agent instructions");
142+
expect(readRepoFile(".claude/CLAUDE.md").trim()).toBe(
143+
"shared agent instructions",
144+
);
145+
expect(readRepoFile(".claude/settings.json")).toBe(
146+
`${JSON.stringify({ source: "base" })}\n`,
147+
);
148+
});
149+
150+
test("snapshots symlinked sensitive paths even when the PR head target is missing", () => {
151+
setupSymlinkedMainBranch();
152+
153+
git(["checkout", "pr"]);
154+
rmSync(join(repoDir, "AGENTS.md"), { force: true });
155+
git(["add", "-A"]);
156+
git(["commit", "-m", "pr deletes agents file"]);
157+
158+
restoreConfigFromBase("main");
159+
160+
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe(
161+
true,
162+
);
163+
expect(readRepoFile(".claude/settings.json")).toBe(
164+
`${JSON.stringify({ source: "base" })}\n`,
165+
);
166+
});
167+
124168
test("does not modify an existing .gitignore", () => {
125169
writeRepoFile(".gitignore", "node_modules\n");
126170
git(["add", ".gitignore"]);
@@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => {
156200
return existsSync(join(repoDir, path));
157201
}
158202

203+
function symlinkRepoFile(path: string, target: string): void {
204+
const fullPath = join(repoDir, path);
205+
mkdirSync(dirname(fullPath), { recursive: true });
206+
symlinkSync(target, fullPath);
207+
}
208+
209+
function lstatRepoFile(path: string) {
210+
return lstatSync(join(repoDir, path));
211+
}
212+
213+
function setupSymlinkedMainBranch(): void {
214+
git(["checkout", "main"]);
215+
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
216+
writeRepoFile("AGENTS.md", "shared agent instructions\n");
217+
symlinkRepoFile("CLAUDE.md", "AGENTS.md");
218+
symlinkRepoFile(".claude/CLAUDE.md", "../AGENTS.md");
219+
git(["add", "AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"]);
220+
git(["commit", "-m", "add symlinked claude files"]);
221+
git(["push", "origin", "main"]);
222+
git(["branch", "-D", "pr"]);
223+
git(["checkout", "-b", "pr"]);
224+
}
225+
159226
function countClaudePrExcludeEntries(): number {
160227
return readFileSync(getExcludePath(), "utf8")
161228
.split(/\r?\n/)

0 commit comments

Comments
 (0)