From acf23d12ced41eaa39fc27e517e4758e513afe9b Mon Sep 17 00:00:00 2001 From: Christian Van Date: Wed, 29 Apr 2026 19:30:18 -0400 Subject: [PATCH] fix: exclude .claude-pr snapshot from git staging --- src/github/operations/restore-config.ts | 37 +++++- test/restore-config.test.ts | 169 ++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 test/restore-config.test.ts diff --git a/src/github/operations/restore-config.ts b/src/github/operations/restore-config.ts index f4fffe671..1d4205a16 100644 --- a/src/github/operations/restore-config.ts +++ b/src/github/operations/restore-config.ts @@ -1,5 +1,13 @@ import { execFileSync } from "child_process"; -import { cpSync, existsSync, rmSync } from "fs"; +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, +} from "fs"; +import { dirname } from "path"; // Paths that are both PR-controllable and read from cwd at CLI startup. // @@ -20,6 +28,30 @@ const SENSITIVE_PATHS = [ ".husky", ]; +const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/"; + +function ensureClaudePrExcludedFromGit(): void { + const excludePath = execFileSync( + "git", + ["rev-parse", "--git-path", "info/exclude"], + { encoding: "utf8" }, + ).trim(); + + const excludeContents = existsSync(excludePath) + ? readFileSync(excludePath, "utf8") + : ""; + + if (excludeContents.split(/\r?\n/).includes(CLAUDE_PR_EXCLUDE_PATTERN)) { + return; + } + + mkdirSync(dirname(excludePath), { recursive: true }); + + const prefix = + excludeContents.length === 0 || excludeContents.endsWith("\n") ? "" : "\n"; + appendFileSync(excludePath, `${prefix}${CLAUDE_PR_EXCLUDE_PATTERN}\n`); +} + /** * Restores security-sensitive config paths from the PR base branch. * @@ -59,8 +91,9 @@ export function restoreConfigFromBase(baseBranch: string): void { } if (existsSync(".claude-pr")) { console.log( - "Preserved PR's sensitive paths → .claude-pr/ for review agents (not executed)", + "Preserved PR's sensitive paths -> .claude-pr/ for review agents (not executed)", ); + ensureClaudePrExcludedFromGit(); } // Delete PR-controlled versions BEFORE fetching so the attacker-controlled diff --git a/test/restore-config.test.ts b/test/restore-config.test.ts new file mode 100644 index 000000000..80439ead9 --- /dev/null +++ b/test/restore-config.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + 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("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 existsRepoFile(path: string): boolean { + return existsSync(join(repoDir, path)); + } + + 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); + } +});