Skip to content

Commit 8c9fbb2

Browse files
committed
fix: exclude .claude-pr snapshot from git staging
1 parent ef50f12 commit 8c9fbb2

2 files changed

Lines changed: 203 additions & 1 deletion

File tree

src/github/operations/restore-config.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { execFileSync } from "child_process";
2-
import { cpSync, existsSync, rmSync } from "fs";
2+
import {
3+
appendFileSync,
4+
cpSync,
5+
existsSync,
6+
mkdirSync,
7+
readFileSync,
8+
rmSync,
9+
} from "fs";
10+
import { dirname } from "path";
311

412
// Paths that are both PR-controllable and read from cwd at CLI startup.
513
//
@@ -20,6 +28,30 @@ const SENSITIVE_PATHS = [
2028
".husky",
2129
];
2230

31+
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
32+
33+
function ensureClaudePrExcludedFromGit(): void {
34+
const excludePath = execFileSync(
35+
"git",
36+
["rev-parse", "--git-path", "info/exclude"],
37+
{ encoding: "utf8" },
38+
).trim();
39+
40+
const excludeContents = existsSync(excludePath)
41+
? readFileSync(excludePath, "utf8")
42+
: "";
43+
44+
if (excludeContents.split(/\r?\n/).includes(CLAUDE_PR_EXCLUDE_PATTERN)) {
45+
return;
46+
}
47+
48+
mkdirSync(dirname(excludePath), { recursive: true });
49+
50+
const prefix =
51+
excludeContents.length === 0 || excludeContents.endsWith("\n") ? "" : "\n";
52+
appendFileSync(excludePath, `${prefix}${CLAUDE_PR_EXCLUDE_PATTERN}\n`);
53+
}
54+
2355
/**
2456
* Restores security-sensitive config paths from the PR base branch.
2557
*
@@ -61,6 +93,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
6193
console.log(
6294
"Preserved PR's sensitive paths → .claude-pr/ for review agents (not executed)",
6395
);
96+
ensureClaudePrExcludedFromGit();
6497
}
6598

6699
// Delete PR-controlled versions BEFORE fetching so the attacker-controlled

test/restore-config.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { execFileSync } from "child_process";
3+
import {
4+
existsSync,
5+
mkdtempSync,
6+
mkdirSync,
7+
readFileSync,
8+
rmSync,
9+
writeFileSync,
10+
} from "fs";
11+
import { dirname, isAbsolute, join } from "path";
12+
import { restoreConfigFromBase } from "../src/github/operations/restore-config";
13+
14+
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
15+
16+
describe("restoreConfigFromBase", () => {
17+
let originalCwd: string;
18+
let tempDir = "";
19+
let repoDir: string;
20+
let remoteDir: string;
21+
22+
beforeEach(() => {
23+
originalCwd = process.cwd();
24+
tempDir = mkdtempSync(join("/tmp", "restore-config-"));
25+
repoDir = join(tempDir, "repo");
26+
remoteDir = join(tempDir, "origin.git");
27+
28+
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
29+
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
30+
git(["checkout", "-b", "main"]);
31+
git(["config", "user.email", "test@example.com"]);
32+
git(["config", "user.name", "Test User"]);
33+
34+
writeRepoFile("CLAUDE.md", "base claude instructions\n");
35+
writeRepoFile(
36+
".claude/settings.json",
37+
`${JSON.stringify({ source: "base" })}\n`,
38+
);
39+
writeRepoFile("src/index.ts", "export const base = true;\n");
40+
41+
git(["add", "CLAUDE.md", ".claude/settings.json", "src/index.ts"]);
42+
git(["commit", "-m", "base config"]);
43+
git(["remote", "add", "origin", remoteDir]);
44+
git(["push", "-u", "origin", "main"]);
45+
46+
git(["checkout", "-b", "pr"]);
47+
writeRepoFile("CLAUDE.md", "pr claude instructions\n");
48+
writeRepoFile(
49+
".claude/settings.json",
50+
`${JSON.stringify({ source: "pr" })}\n`,
51+
);
52+
git(["add", "CLAUDE.md", ".claude/settings.json"]);
53+
git(["commit", "-m", "pr config"]);
54+
55+
process.chdir(repoDir);
56+
});
57+
58+
afterEach(() => {
59+
process.chdir(originalCwd);
60+
if (tempDir) {
61+
rmSync(tempDir, { recursive: true, force: true });
62+
}
63+
});
64+
65+
test("preserves PR sensitive files while excluding .claude-pr from broad staging", () => {
66+
const gitignoreExistedBefore = existsRepoFile(".gitignore");
67+
const gitignoreContentsBefore = gitignoreExistedBefore
68+
? readRepoFile(".gitignore")
69+
: "";
70+
71+
restoreConfigFromBase("main");
72+
73+
expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe(
74+
"pr claude instructions\n",
75+
);
76+
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
77+
`${JSON.stringify({ source: "pr" })}\n`,
78+
);
79+
expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n");
80+
expect(readRepoFile(".claude/settings.json")).toBe(
81+
`${JSON.stringify({ source: "base" })}\n`,
82+
);
83+
expect(git(["check-ignore", ".claude-pr/CLAUDE.md"]).trim()).toBe(
84+
".claude-pr/CLAUDE.md",
85+
);
86+
expect(countClaudePrExcludeEntries()).toBe(1);
87+
88+
restoreConfigFromBase("main");
89+
90+
expect(countClaudePrExcludeEntries()).toBe(1);
91+
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
92+
if (gitignoreExistedBefore) {
93+
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
94+
}
95+
96+
writeRepoFile("src/fix.ts", "export const fix = true;\n");
97+
git(["add", "-A"]);
98+
99+
const stagedFiles = git(["diff", "--cached", "--name-only"])
100+
.trim()
101+
.split(/\r?\n/)
102+
.filter(Boolean);
103+
expect(stagedFiles).toContain("src/fix.ts");
104+
expect(stagedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
105+
false,
106+
);
107+
108+
git(["commit", "-m", "apply fix"]);
109+
110+
const committedFiles = git(["show", "--name-only", "--format=", "HEAD"])
111+
.trim()
112+
.split(/\r?\n/)
113+
.filter(Boolean);
114+
expect(committedFiles).toContain("src/fix.ts");
115+
expect(committedFiles.some((file) => file.startsWith(".claude-pr/"))).toBe(
116+
false,
117+
);
118+
expect(existsRepoFile(".gitignore")).toBe(gitignoreExistedBefore);
119+
if (gitignoreExistedBefore) {
120+
expect(readRepoFile(".gitignore")).toBe(gitignoreContentsBefore);
121+
}
122+
});
123+
124+
test("does not modify an existing .gitignore", () => {
125+
writeRepoFile(".gitignore", "node_modules\n");
126+
git(["add", ".gitignore"]);
127+
git(["commit", "-m", "add gitignore"]);
128+
129+
const gitignoreBefore = readRepoFile(".gitignore");
130+
131+
restoreConfigFromBase("main");
132+
133+
expect(readRepoFile(".gitignore")).toBe(gitignoreBefore);
134+
expect(countClaudePrExcludeEntries()).toBe(1);
135+
});
136+
137+
function git(args: string[]): string {
138+
return execFileSync("git", args, {
139+
cwd: repoDir,
140+
encoding: "utf8",
141+
stdio: ["ignore", "pipe", "pipe"],
142+
});
143+
}
144+
145+
function writeRepoFile(path: string, contents: string): void {
146+
const fullPath = join(repoDir, path);
147+
mkdirSync(dirname(fullPath), { recursive: true });
148+
writeFileSync(fullPath, contents);
149+
}
150+
151+
function readRepoFile(path: string): string {
152+
return readFileSync(join(repoDir, path), "utf8");
153+
}
154+
155+
function existsRepoFile(path: string): boolean {
156+
return existsSync(join(repoDir, path));
157+
}
158+
159+
function countClaudePrExcludeEntries(): number {
160+
return readFileSync(getExcludePath(), "utf8")
161+
.split(/\r?\n/)
162+
.filter((line) => line === CLAUDE_PR_EXCLUDE_PATTERN).length;
163+
}
164+
165+
function getExcludePath(): string {
166+
const gitPath = git(["rev-parse", "--git-path", "info/exclude"]).trim();
167+
return isAbsolute(gitPath) ? gitPath : join(repoDir, gitPath);
168+
}
169+
});

0 commit comments

Comments
 (0)