mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
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
This commit is contained in:
parent
0f07aee435
commit
d060ddc963
@ -30,6 +30,25 @@ const SENSITIVE_PATHS = [
|
|||||||
|
|
||||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
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 {
|
function ensureClaudePrExcludedFromGit(): void {
|
||||||
const excludePath = execFileSync(
|
const excludePath = execFileSync(
|
||||||
"git",
|
"git",
|
||||||
@ -86,7 +105,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
|||||||
rmSync(".claude-pr", { recursive: true, force: true });
|
rmSync(".claude-pr", { recursive: true, force: true });
|
||||||
for (const p of SENSITIVE_PATHS) {
|
for (const p of SENSITIVE_PATHS) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
|
snapshotSensitivePath(p, `.claude-pr/${p}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existsSync(".claude-pr")) {
|
if (existsSync(".claude-pr")) {
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { dirname, isAbsolute, join } from "path";
|
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", () => {
|
test("does not modify an existing .gitignore", () => {
|
||||||
writeRepoFile(".gitignore", "node_modules\n");
|
writeRepoFile(".gitignore", "node_modules\n");
|
||||||
git(["add", ".gitignore"]);
|
git(["add", ".gitignore"]);
|
||||||
@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => {
|
|||||||
return existsSync(join(repoDir, path));
|
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 {
|
function countClaudePrExcludeEntries(): number {
|
||||||
return readFileSync(getExcludePath(), "utf8")
|
return readFileSync(getExcludePath(), "utf8")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user