mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-07 03:38:30 +08:00
* Scope config snapshot to files inside the working tree * Record excluded snapshot entries as placeholders instead of links * Limit linked snapshot content to unmodified tracked files and tracked directories File targets reached through a link are included only when their content is unchanged from HEAD, and directory targets only when they contain tracked files; anything else is recorded as a single placeholder. Adds tests for a sensitive path that links to a tracked directory, links to untracked directories, and links to tracked files modified after checkout.
502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { execFileSync } from "child_process";
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "fs";
|
|
import { dirname, isAbsolute, join } from "path";
|
|
import { restoreConfigFromBase } from "../src/github/operations/restore-config";
|
|
|
|
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
|
|
|
describe("restoreConfigFromBase", () => {
|
|
let originalCwd: string;
|
|
let tempDir = "";
|
|
let repoDir: string;
|
|
let remoteDir: string;
|
|
|
|
beforeEach(() => {
|
|
originalCwd = process.cwd();
|
|
tempDir = mkdtempSync(join("/tmp", "restore-config-"));
|
|
repoDir = join(tempDir, "repo");
|
|
remoteDir = join(tempDir, "origin.git");
|
|
|
|
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
|
git(["checkout", "-b", "main"]);
|
|
git(["config", "user.email", "test@example.com"]);
|
|
git(["config", "user.name", "Test User"]);
|
|
|
|
writeRepoFile("CLAUDE.md", "base claude instructions\n");
|
|
writeRepoFile(
|
|
".claude/settings.json",
|
|
`${JSON.stringify({ source: "base" })}\n`,
|
|
);
|
|
writeRepoFile("src/index.ts", "export const base = true;\n");
|
|
|
|
git(["add", "CLAUDE.md", ".claude/settings.json", "src/index.ts"]);
|
|
git(["commit", "-m", "base config"]);
|
|
git(["remote", "add", "origin", remoteDir]);
|
|
git(["push", "-u", "origin", "main"]);
|
|
|
|
git(["checkout", "-b", "pr"]);
|
|
writeRepoFile("CLAUDE.md", "pr claude instructions\n");
|
|
writeRepoFile(
|
|
".claude/settings.json",
|
|
`${JSON.stringify({ source: "pr" })}\n`,
|
|
);
|
|
git(["add", "CLAUDE.md", ".claude/settings.json"]);
|
|
git(["commit", "-m", "pr config"]);
|
|
|
|
process.chdir(repoDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
if (tempDir) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("preserves PR sensitive files while excluding .claude-pr from broad staging", () => {
|
|
const gitignoreExistedBefore = existsRepoFile(".gitignore");
|
|
const gitignoreContentsBefore = gitignoreExistedBefore
|
|
? readRepoFile(".gitignore")
|
|
: "";
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe(
|
|
"pr claude instructions\n",
|
|
);
|
|
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "pr" })}\n`,
|
|
);
|
|
expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n");
|
|
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "base" })}\n`,
|
|
);
|
|
expect(git(["check-ignore", ".claude-pr/CLAUDE.md"]).trim()).toBe(
|
|
".claude-pr/CLAUDE.md",
|
|
);
|
|
expect(countClaudePrExcludeEntries()).toBe(1);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(countClaudePrExcludeEntries()).toBe(1);
|
|
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
|
|
if (gitignoreExistedBefore) {
|
|
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
|
|
}
|
|
|
|
writeRepoFile("src/fix.ts", "export const fix = true;\n");
|
|
git(["add", "-A"]);
|
|
|
|
const stagedFiles = git(["diff", "--cached", "--name-only"])
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
expect(stagedFiles).toContain("src/fix.ts");
|
|
expect(stagedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
|
|
false,
|
|
);
|
|
|
|
git(["commit", "-m", "apply fix"]);
|
|
|
|
const committedFiles = git(["show", "--name-only", "--format=", "HEAD"])
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
expect(committedFiles).toContain("src/fix.ts");
|
|
expect(committedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
|
|
false,
|
|
);
|
|
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
|
|
if (gitignoreExistedBefore) {
|
|
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
|
|
}
|
|
});
|
|
|
|
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("records dangling links as placeholders, including top-level ones", () => {
|
|
setupSymlinkedMainBranch();
|
|
|
|
git(["checkout", "pr"]);
|
|
rmSync(join(repoDir, "AGENTS.md"), { force: true });
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr deletes agents file"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/CLAUDE.md");
|
|
expectPlaceholder(".claude-pr/.claude/CLAUDE.md");
|
|
expectNoLinksInSnapshot();
|
|
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "base" })}\n`,
|
|
);
|
|
});
|
|
|
|
test("snapshots links to tracked in-tree files as dereferenced content", () => {
|
|
setupSymlinkedMainBranch();
|
|
|
|
git(["checkout", "pr"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(lstatRepoFile(".claude-pr/CLAUDE.md").isFile()).toBe(true);
|
|
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isFile()).toBe(true);
|
|
expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe(
|
|
"shared agent instructions\n",
|
|
);
|
|
expect(readRepoFile(".claude-pr/.claude/CLAUDE.md")).toBe(
|
|
"shared agent instructions\n",
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records CLAUDE.md links to targets outside the working tree as placeholders", () => {
|
|
const outsideFile = writeOutsideFile("notes.md", "outside notes\n");
|
|
|
|
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
|
|
symlinkRepoFile("CLAUDE.md", outsideFile);
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr links CLAUDE.md outside the repo"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/CLAUDE.md");
|
|
expect(readRepoFile(".claude-pr/CLAUDE.md")).not.toBe("outside notes\n");
|
|
expect(snapshotRegularFileContents()).not.toContain("outside notes\n");
|
|
expectNoLinksInSnapshot();
|
|
expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n");
|
|
});
|
|
|
|
test("records nested links to targets outside the working tree as placeholders", () => {
|
|
const outsideFile = writeOutsideFile(
|
|
"secret.txt",
|
|
"outside file content\n",
|
|
);
|
|
writeOutsideFile("dir/inner.txt", "outside dir content\n");
|
|
const outsideDir = join(tempDir, "outside", "dir");
|
|
|
|
symlinkRepoFile(".claude/linked-file.md", outsideFile);
|
|
symlinkRepoFile(".claude/linked-dir", outsideDir);
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr adds nested links outside the repo"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "pr" })}\n`,
|
|
);
|
|
expectPlaceholder(".claude-pr/.claude/linked-file.md");
|
|
expectPlaceholder(".claude-pr/.claude/linked-dir");
|
|
const contents = snapshotRegularFileContents();
|
|
expect(contents).not.toContain("outside file content\n");
|
|
expect(contents).not.toContain("outside dir content\n");
|
|
expectNoLinksInSnapshot();
|
|
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "base" })}\n`,
|
|
);
|
|
});
|
|
|
|
test("records links into git metadata as placeholders", () => {
|
|
symlinkRepoFile(".claude/git-config", "../.git/config");
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr links into git metadata"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/git-config");
|
|
expect(snapshotRegularFileContents()).not.toContain(
|
|
readRepoFile(".git/config"),
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records relative links that only resolve from inside the snapshot as placeholders", () => {
|
|
// Both targets dangle at their source location but would resolve to the
|
|
// repository's .git/config if re-created one directory deeper.
|
|
symlinkRepoFile(".claude/x", "../../.git/config");
|
|
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
|
|
symlinkRepoFile("CLAUDE.md", "../.git/config");
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr adds relative links"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
const gitConfig = readRepoFile(".git/config");
|
|
for (const path of [".claude-pr/.claude/x", ".claude-pr/CLAUDE.md"]) {
|
|
expectPlaceholder(path);
|
|
expect(readRepoFile(path)).not.toBe(gitConfig);
|
|
}
|
|
expect(snapshotRegularFileContents()).not.toContain(gitConfig);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records links into nested git metadata inside the working tree as placeholders", () => {
|
|
writeRepoFile("other/.git/config", "nested checkout config\n");
|
|
symlinkRepoFile(".claude/x", "../other/.git/config");
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/x");
|
|
expect(snapshotRegularFileContents()).not.toContain(
|
|
"nested checkout config\n",
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records links to untracked in-tree files as placeholders", () => {
|
|
writeRepoFile(".env", "untracked env contents\n");
|
|
symlinkRepoFile(".claude/env", "../.env");
|
|
git(["add", ".claude/env"]);
|
|
git(["commit", "-m", "pr links to an untracked file"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/env");
|
|
expect(snapshotRegularFileContents()).not.toContain(
|
|
"untracked env contents\n",
|
|
);
|
|
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "pr" })}\n`,
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records links to tracked files modified after checkout as placeholders", () => {
|
|
writeRepoFile(".env", "PLACEHOLDER=1\n");
|
|
symlinkRepoFile(".claude/env", "../.env");
|
|
git(["add", ".env", ".claude/env"]);
|
|
git(["commit", "-m", "pr links to a tracked file"]);
|
|
writeRepoFile(".env", "written after checkout\n");
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/env");
|
|
expect(snapshotRegularFileContents()).not.toContain(
|
|
"written after checkout\n",
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("snapshots a sensitive path that links to a tracked in-tree directory", () => {
|
|
rmSync(join(repoDir, ".claude"), { recursive: true, force: true });
|
|
writeRepoFile(
|
|
"config/claude/settings.json",
|
|
`${JSON.stringify({ source: "linked-dir" })}\n`,
|
|
);
|
|
writeRepoFile("config/claude/agents/reviewer.md", "reviewer agent\n");
|
|
writeRepoFile("docs/agents/writer.md", "writer agent\n");
|
|
symlinkRepoFile("config/claude/more-agents", "../../docs/agents");
|
|
symlinkRepoFile(".claude", "config/claude");
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr links .claude to a tracked directory"]);
|
|
writeRepoFile("config/claude/local.txt", "untracked file\n");
|
|
writeRepoFile("config/claude/cache/entry.txt", "untracked dir entry\n");
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(lstatRepoFile(".claude-pr/.claude").isDirectory()).toBe(true);
|
|
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "linked-dir" })}\n`,
|
|
);
|
|
expect(readRepoFile(".claude-pr/.claude/agents/reviewer.md")).toBe(
|
|
"reviewer agent\n",
|
|
);
|
|
expect(readRepoFile(".claude-pr/.claude/more-agents/writer.md")).toBe(
|
|
"writer agent\n",
|
|
);
|
|
expectPlaceholder(".claude-pr/.claude/local.txt");
|
|
expectPlaceholder(".claude-pr/.claude/cache");
|
|
const contents = snapshotRegularFileContents();
|
|
expect(contents).not.toContain("untracked file\n");
|
|
expect(contents).not.toContain("untracked dir entry\n");
|
|
expectNoLinksInSnapshot();
|
|
expect(lstatRepoFile(".claude").isDirectory()).toBe(true);
|
|
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "base" })}\n`,
|
|
);
|
|
});
|
|
|
|
test("records links to untracked in-tree directories as a single placeholder", () => {
|
|
writeRepoFile("build/out/a.js", "generated a\n");
|
|
writeRepoFile("build/out/b.js", "generated b\n");
|
|
symlinkRepoFile(".claude/build", "../build");
|
|
git(["add", ".claude/build"]);
|
|
git(["commit", "-m", "pr links to an untracked directory"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/build");
|
|
const contents = snapshotRegularFileContents();
|
|
expect(contents).not.toContain("generated a\n");
|
|
expect(contents).not.toContain("generated b\n");
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("records links back into a parent directory as placeholders", () => {
|
|
symlinkRepoFile(".claude/parent-dir", "..");
|
|
git(["add", "-A"]);
|
|
git(["commit", "-m", "pr adds a link back to the repo root"]);
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expectPlaceholder(".claude-pr/.claude/parent-dir");
|
|
expect(existsRepoFile(".claude-pr/.claude/parent-dir/src")).toBe(false);
|
|
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
|
`${JSON.stringify({ source: "pr" })}\n`,
|
|
);
|
|
expectNoLinksInSnapshot();
|
|
});
|
|
|
|
test("does not modify an existing .gitignore", () => {
|
|
writeRepoFile(".gitignore", "node_modules\n");
|
|
git(["add", ".gitignore"]);
|
|
git(["commit", "-m", "add gitignore"]);
|
|
|
|
const gitignoreBefore = readRepoFile(".gitignore");
|
|
|
|
restoreConfigFromBase("main");
|
|
|
|
expect(readRepoFile(".gitignore")).toBe(gitignoreBefore);
|
|
expect(countClaudePrExcludeEntries()).toBe(1);
|
|
});
|
|
|
|
function git(args: string[]): string {
|
|
return execFileSync("git", args, {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
function writeRepoFile(path: string, contents: string): void {
|
|
const fullPath = join(repoDir, path);
|
|
mkdirSync(dirname(fullPath), { recursive: true });
|
|
writeFileSync(fullPath, contents);
|
|
}
|
|
|
|
function readRepoFile(path: string): string {
|
|
return readFileSync(join(repoDir, path), "utf8");
|
|
}
|
|
|
|
function writeOutsideFile(path: string, contents: string): string {
|
|
const fullPath = join(tempDir, "outside", path);
|
|
mkdirSync(dirname(fullPath), { recursive: true });
|
|
writeFileSync(fullPath, contents);
|
|
return fullPath;
|
|
}
|
|
|
|
// Contents of every regular file recorded in the snapshot, without following
|
|
// links, so tests can assert what actually got copied into the repository.
|
|
function snapshotRegularFileContents(): string[] {
|
|
const contents: string[] = [];
|
|
const visit = (dir: string) => {
|
|
for (const entry of readdirSync(dir)) {
|
|
const entryPath = join(dir, entry);
|
|
const stats = lstatSync(entryPath);
|
|
if (stats.isDirectory()) {
|
|
visit(entryPath);
|
|
} else if (stats.isFile()) {
|
|
contents.push(readFileSync(entryPath, "utf8"));
|
|
}
|
|
}
|
|
};
|
|
visit(join(repoDir, ".claude-pr"));
|
|
return contents;
|
|
}
|
|
|
|
// The snapshot must never contain links: every entry is a regular file or a
|
|
// real directory.
|
|
function expectNoLinksInSnapshot(): void {
|
|
const visit = (dir: string) => {
|
|
for (const entry of readdirSync(dir)) {
|
|
const entryPath = join(dir, entry);
|
|
const stats = lstatSync(entryPath);
|
|
expect(stats.isSymbolicLink()).toBe(false);
|
|
if (stats.isDirectory()) {
|
|
visit(entryPath);
|
|
}
|
|
}
|
|
};
|
|
visit(join(repoDir, ".claude-pr"));
|
|
}
|
|
|
|
function expectPlaceholder(path: string): void {
|
|
const stats = lstatRepoFile(path);
|
|
expect(stats.isSymbolicLink()).toBe(false);
|
|
expect(stats.isFile()).toBe(true);
|
|
expect(readRepoFile(path)).toStartWith("Snapshot placeholder: ");
|
|
}
|
|
|
|
function existsRepoFile(path: string): boolean {
|
|
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/)
|
|
.filter((line) => line === CLAUDE_PR_EXCLUDE_PATTERN).length;
|
|
}
|
|
|
|
function getExcludePath(): string {
|
|
const gitPath = git(["rev-parse", "--git-path", "info/exclude"]).trim();
|
|
return isAbsolute(gitPath) ? gitPath : join(repoDir, gitPath);
|
|
}
|
|
});
|