Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions src/github/operations/restore-config.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions test/restore-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});