fix: only limit fetch depth when the checkout is already shallow (#1647)

restoreConfigFromBase and setupBranch pass --depth to every git fetch. On a
checkout made with fetch-depth: 0 that does not just cap the download: it
truncates the history already present and marks the repository shallow, which
drops the merge base with the base branch. `git log origin/<base>..HEAD` then
silently includes commits that are already merged, and
`git diff origin/<base>...HEAD` fails with "no merge base" — the two commands
the prompt tells Claude to run to scope its work to the PR.

Gate the flag on `git rev-parse --is-shallow-repository`, so a checkout that is
already shallow (the fetch-depth: 1 default) keeps the same depth behaviour and
the fetch savings it was added for, while a full checkout stays full.

Fixes #1642
This commit is contained in:
Madan kumar
2026-08-14 16:31:26 -07:00
committed by GitHub
parent 05ee4b30d7
commit ed186becce
5 changed files with 200 additions and 7 deletions
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { setupBranch } from "../src/github/operations/branch";
import { fetchDepthArgs } from "../src/github/operations/fetch-depth";
import { createMockContext } from "./mockContext";
const octokits = {
rest: {
repos: { get: async () => ({ data: { default_branch: "main" } }) },
git: { getRef: async () => ({ data: { object: { sha: "abc1234" } } }) },
},
} as any;
const githubData = {
contextData: { title: "Add feature", labels: { nodes: [] } },
} as any;
describe("setupBranch fetch depth", () => {
let originalCwd: string;
let tempDir = "";
let repoDir: string;
beforeEach(() => {
originalCwd = process.cwd();
tempDir = mkdtempSync(join("/tmp", "fetch-depth-"));
repoDir = join(tempDir, "repo");
const 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"]);
for (const message of ["first", "second", "third"]) {
writeFileSync(join(repoDir, `${message}.txt`), `${message}\n`);
git(["add", "."]);
git(["commit", "-m", message]);
}
git(["remote", "add", "origin", remoteDir]);
git(["push", "-u", "origin", "main"]);
process.chdir(repoDir);
});
afterEach(() => {
process.chdir(originalCwd);
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});
for (const useCommitSigning of [false, true]) {
test(`keeps the full history of a complete checkout with use_commit_signing: ${useCommitSigning}`, async () => {
const context = createMockContext({
isPR: false,
entityNumber: 7,
inputs: { useCommitSigning, branchPrefix: "claude/" },
});
await setupBranch(octokits, githubData, context);
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe(
"false",
);
expect(git(["rev-list", "--count", "HEAD"]).trim()).toBe("3");
});
}
test("still limits the depth on an already shallow checkout", () => {
const shallowDir = join(tempDir, "shallow");
execFileSync(
"git",
[
"clone",
"--depth=1",
`file://${join(tempDir, "origin.git")}`,
shallowDir,
],
{ stdio: "pipe" },
);
process.chdir(shallowDir);
expect(
execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
cwd: shallowDir,
encoding: "utf8",
}).trim(),
).toBe("true");
expect(fetchDepthArgs(20)).toEqual(["--depth=20"]);
});
test("drops the depth limit on a complete checkout", () => {
expect(fetchDepthArgs(20)).toEqual([]);
});
function git(args: string[]): string {
return execFileSync("git", args, {
cwd: repoDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
});
+31
View File
@@ -394,6 +394,37 @@ describe("restoreConfigFromBase", () => {
expect(countClaudePrExcludeEntries()).toBe(1);
});
test("leaves a full checkout unshallow so base..HEAD stays scoped to the PR", () => {
// The damage only shows up once base has moved on since the PR branched:
// the merge base is then an older commit that a depth-limited fetch of base
// truncates away, and every base..HEAD comparison silently changes meaning.
git(["checkout", "main"]);
writeRepoFile("src/other.ts", "export const advanced = true;\n");
git(["add", "src/other.ts"]);
git(["commit", "-m", "base advance"]);
git(["push", "origin", "main"]);
git(["checkout", "pr"]);
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false");
const mergeBaseBefore = git(["merge-base", "origin/main", "HEAD"]).trim();
restoreConfigFromBase("main");
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false");
expect(git(["merge-base", "origin/main", "HEAD"]).trim()).toBe(
mergeBaseBefore,
);
// These are the two commands the prompt tells Claude to run to scope its
// work to the PR: the log range must not pick up already-merged commits,
// and the three-dot diff must still resolve a merge base at all.
expect(git(["log", "--format=%s", "origin/main..HEAD"]).trim()).toBe(
"pr config",
);
expect(
git(["diff", "--name-only", "origin/main...HEAD"]).trim().split("\n"),
).toEqual([".claude/settings.json", "CLAUDE.md"]);
});
function git(args: string[]): string {
return execFileSync("git", args, {
cwd: repoDir,