diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index 0c6c88affbc..0132898f905 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -244,6 +244,89 @@ function ensureOriginRemoteTrackingRef(branch, options) { } } +/** + * Maximum number of commits in a `base..head` range before it is considered + * implausible for a shallow checkout. A shallow clone with `fetch-depth: 1` + * will report the entire local history as the range when the base ref is not + * reachable from the shallow grafts, producing a count in the tens-of-thousands + * even for a single-commit branch. This threshold guards against that case. + * + * Callers can override the threshold via the `maxCommits` option on the + * functions that accept it. + */ +const SHALLOW_RANGE_MAX_COMMITS = 100; + +/** + * Detect whether a commit range is implausibly large for a shallow checkout. + * + * In a shallow clone (`fetch-depth: 1`) the base ref (e.g. `origin/main`) has + * no traversable ancestry, so `git rev-list base..HEAD` cannot exclude anything + * and returns essentially the entire repository history. A count far above what + * a typical PR branch would contain almost certainly means the base ref is not + * reachable from the shallow grafts rather than representing real branch work. + * + * When the range size exceeds `options.maxCommits` (default + * `SHALLOW_RANGE_MAX_COMMITS`) **and** the repository is shallow, this function + * emits a `core.warning` with an actionable message and returns + * `{ implausible: true, commitCount }`. Otherwise it returns + * `{ implausible: false, commitCount }`. + * + * All git failures are treated as non-fatal so callers are never blocked. + * If `git rev-list` fails, returns `{ implausible: false, commitCount: 0 }`. + * If `git rev-list` succeeds but `git rev-parse --is-shallow-repository` fails, + * returns `{ implausible: false, commitCount }` with the measured commit count. + * + * @param {string} baseRef - The base ref (exclusive). Example: "origin/main". + * @param {string} headRef - The head ref (inclusive). Example: "HEAD" or a branch name. + * @param {Object} [options] + * @param {string} [options.cwd] - Working directory for git commands. + * @param {number} [options.maxCommits] - Override the implausibility threshold. + * @returns {{ implausible: boolean, commitCount: number }} + */ +function checkImplausibleShallowRange(baseRef, headRef, options = {}) { + const { maxCommits = SHALLOW_RANGE_MAX_COMMITS, cwd } = options; + if (!baseRef || !headRef) return { implausible: false, commitCount: 0 }; + + let commitCount = 0; + try { + const countOut = execGitSync(["rev-list", "--count", `${baseRef}..${headRef}`], { + cwd, + suppressLogs: true, + }); + commitCount = parseInt(countOut.trim(), 10) || 0; + } catch { + return { implausible: false, commitCount: 0 }; + } + + if (!Number.isFinite(commitCount) || commitCount <= maxCommits) { + return { implausible: false, commitCount }; + } + + // Count is suspiciously large; only flag it as implausible when the repo is + // actually shallow — a large honest branch in a full clone is fine. + let isShallow = false; + try { + const shallowOut = execGitSync(["rev-parse", "--is-shallow-repository"], { + cwd, + suppressLogs: true, + }); + isShallow = shallowOut.trim() === "true"; + } catch { + return { implausible: false, commitCount }; + } + + if (isShallow) { + core.warning( + `Shallow checkout produced an implausible commit range of ${commitCount} commits for ${baseRef}..${headRef}. ` + + `This usually means ${baseRef} is not reachable from the shallow grafts. ` + + `Increase fetch-depth in your workflow checkout step (e.g. fetch-depth: 0) to resolve this.` + ); + return { implausible: true, commitCount }; + } + + return { implausible: false, commitCount }; +} + /** * Check whether a commit range contains any merge commits. * @@ -257,14 +340,26 @@ function ensureOriginRemoteTrackingRef(branch, options) { * "unknown" as "no merge commits detected" so that a detection failure never * blocks the normal patch path. * + * Also returns `false` when the range is implausibly large for a shallow + * checkout (see `checkImplausibleShallowRange`). In that case a warning is + * already emitted by the range check, so forcing bundle transport based on + * phantom merge-commit detections in a huge, unreliable range is avoided. + * * @param {string} baseRef - The base ref (exclusive). Example: "origin/feature". * @param {string} headRef - The head ref (inclusive). Example: "feature". * @param {Object} [options] * @param {string} [options.cwd] - Working directory for the git command. + * @param {number} [options.maxCommits] - Override the implausibility threshold. * @returns {boolean} True if at least one merge commit exists in baseRef..headRef. */ function hasMergeCommitsInRange(baseRef, headRef, options = {}) { if (!baseRef || !headRef) return false; + + // Guard: if the range is implausibly large for a shallow checkout, treat as + // no merge commits so a false-positive does not force bundle transport. + const { implausible } = checkImplausibleShallowRange(baseRef, headRef, options); + if (implausible) return false; + try { const out = execGitSync(["rev-list", "--merges", "--count", `${baseRef}..${headRef}`], { cwd: options.cwd, @@ -615,15 +710,50 @@ async function backfillCommitObjects(execApi, commitShas, options = {}) { * When omitted, exec calls are made without additional options. * @param {string[]} [opts.commitFlags] - Extra flags prepended before `-m` in the `git commit` * invocation (e.g. `["--allow-empty", "--no-verify"]`). + * @param {number} [opts.maxCommits] - Override the implausibility threshold (default + * `SHALLOW_RANGE_MAX_COMMITS`). Set to `Infinity` to disable the shallow guard. * @returns {Promise} The new HEAD SHA after the rewrite. - * @throws {Error} If the soft reset, staged-changes validation, or recommit fails. + * @throws {Error} If the soft reset, staged-changes validation, or recommit fails, or if a + * shallow checkout produces an implausible commit range. */ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}) { - const { gitOpts, commitFlags = [] } = opts; + const { gitOpts, commitFlags = [], maxCommits = SHALLOW_RANGE_MAX_COMMITS } = opts; // Spread gitOpts into exec calls only when it is explicitly provided — passing // `undefined` as a third argument changes the arity seen by mocks in tests. const execArgs = gitOpts !== undefined ? [gitOpts] : []; + // Guard against implausibly large commit ranges in shallow checkouts. + // In a shallow clone with fetch-depth:1 the base ref has no traversable + // ancestry, so git rev-list cannot exclude anything and returns the entire + // local history. Synthesizing a rewrite commit from tens-of-thousands of + // commits is almost certainly wrong and could produce an oversized payload. + { + let rangeCommitCount = 0; + try { + const { stdout: countOut } = await execApi.getExecOutput("git", ["rev-list", "--count", `${baseRef}..HEAD`], ...execArgs); + rangeCommitCount = parseInt(countOut.trim(), 10) || 0; + } catch { + rangeCommitCount = 0; + } + if (Number.isFinite(rangeCommitCount) && rangeCommitCount > maxCommits) { + let isShallow = false; + try { + const shallowOpts = gitOpts !== undefined ? { ...gitOpts, ignoreReturnCode: true } : { ignoreReturnCode: true }; + const { stdout: shallowOut } = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], shallowOpts); + isShallow = shallowOut.trim() === "true"; + } catch { + // Non-fatal: if the shallow probe fails, do not block the rewrite. + } + if (isShallow) { + throw new Error( + `Refusing to linearize an implausible commit range: ${rangeCommitCount} commits in ${baseRef}..HEAD in a shallow checkout. ` + + `This likely means ${baseRef} is not reachable from the shallow grafts. ` + + `Increase fetch-depth in your workflow checkout step (e.g. fetch-depth: 0) to resolve this.` + ); + } + } + } + const { stdout: originalHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"], ...execArgs); const originalHead = originalHeadOut.trim(); if (!originalHead) { @@ -654,6 +784,7 @@ module.exports = { ensureSafeDirectoryTrust, execGitSync, backfillCommitObjects, + checkImplausibleShallowRange, ensureFullHistoryForBundle, ensureOriginRemoteTrackingRef, extractBundlePrerequisiteCommits, @@ -662,4 +793,5 @@ module.exports = { hasMergeCommitsInRange, isShallowOrSparseCheckout, linearizeRangeAsCommit, + SHALLOW_RANGE_MAX_COMMITS, }; diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index 4a23ca158b2..c6060e3d6e9 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -1100,6 +1100,580 @@ describe("git_helpers.cjs", () => { expect(err.cause).toBe(cause); }); + + it("should throw before any git state change when commit range is implausibly large in a shallow checkout", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + // Simulate a shallow checkout returning 61000 commits in the range + return Promise.resolve({ stdout: "61000\n" }); + } + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ stdout: "true\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + await expect(linearizeRangeAsCommit("origin/main", "msg", execApi)).rejects.toThrow(/Refusing to linearize an implausible commit range/); + + // No git state mutation should have occurred + expect(execApi.exec).not.toHaveBeenCalled(); + }); + + it("should include the commit count and base ref in the implausible range error message", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + return Promise.resolve({ stdout: "500\n" }); + } + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ stdout: "true\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + const err = await linearizeRangeAsCommit("origin/feature", "msg", execApi).catch(e => e); + + expect(err.message).toMatch(/500/); + expect(err.message).toMatch(/origin\/feature/); + expect(err.message).toMatch(/fetch-depth/); + }); + + it("should not throw when commit range is large but repo is not shallow", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const ORIGINAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let headCallCount = 0; + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + return Promise.resolve({ stdout: "500\n" }); + } + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ stdout: "false\n" }); + } + if (args[0] === "rev-parse" && args[1] === "HEAD") { + headCallCount += 1; + return Promise.resolve({ stdout: headCallCount === 1 ? `${ORIGINAL_HEAD}\n` : `${NEW_HEAD}\n` }); + } + if (args[0] === "diff" && args[1] === "--cached") { + return Promise.resolve({ stdout: "README.md\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + const result = await linearizeRangeAsCommit("origin/main", "msg", execApi); + expect(result).toBe(NEW_HEAD); + }); + + it("should not throw when commit range count is within the default threshold", async () => { + const { linearizeRangeAsCommit, SHALLOW_RANGE_MAX_COMMITS } = await import("./git_helpers.cjs"); + const ORIGINAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let headCallCount = 0; + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + // Exactly at threshold — not implausible + return Promise.resolve({ stdout: `${SHALLOW_RANGE_MAX_COMMITS}\n` }); + } + if (args[0] === "rev-parse" && args[1] === "HEAD") { + headCallCount += 1; + return Promise.resolve({ stdout: headCallCount === 1 ? `${ORIGINAL_HEAD}\n` : `${NEW_HEAD}\n` }); + } + if (args[0] === "diff" && args[1] === "--cached") { + return Promise.resolve({ stdout: "README.md\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + const result = await linearizeRangeAsCommit("origin/main", "msg", execApi); + expect(result).toBe(NEW_HEAD); + }); + + it("should proceed normally when the rev-list count command fails", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const ORIGINAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let headCallCount = 0; + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + return Promise.reject(new Error("rev-list failed")); + } + if (args[0] === "rev-parse" && args[1] === "HEAD") { + headCallCount += 1; + return Promise.resolve({ stdout: headCallCount === 1 ? `${ORIGINAL_HEAD}\n` : `${NEW_HEAD}\n` }); + } + if (args[0] === "diff" && args[1] === "--cached") { + return Promise.resolve({ stdout: "README.md\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + // A rev-list failure should be non-fatal — linearization proceeds normally + const result = await linearizeRangeAsCommit("origin/main", "msg", execApi); + expect(result).toBe(NEW_HEAD); + }); + + it("should proceed normally when the shallow probe fails in the guard", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const ORIGINAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let headCallCount = 0; + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + // Large count to trigger shallow probe + return Promise.resolve({ stdout: "500\n" }); + } + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + // Shallow probe fails + return Promise.reject(new Error("not a git repo")); + } + if (args[0] === "rev-parse" && args[1] === "HEAD") { + headCallCount += 1; + return Promise.resolve({ stdout: headCallCount === 1 ? `${ORIGINAL_HEAD}\n` : `${NEW_HEAD}\n` }); + } + if (args[0] === "diff" && args[1] === "--cached") { + return Promise.resolve({ stdout: "README.md\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + // Shallow probe failure is non-fatal — linearization proceeds normally + const result = await linearizeRangeAsCommit("origin/main", "msg", execApi); + expect(result).toBe(NEW_HEAD); + }); + + it("should respect a custom maxCommits threshold via opts", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + + const execApi = { + getExecOutput: vi.fn().mockImplementation((_cmd, args) => { + if (args[0] === "rev-list" && args[1] === "--count") { + return Promise.resolve({ stdout: "10\n" }); + } + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ stdout: "true\n" }); + } + return Promise.resolve({ stdout: "" }); + }), + exec: vi.fn().mockResolvedValue(0), + }; + + // maxCommits: 5 means 10 commits is implausible for a shallow repo + await expect(linearizeRangeAsCommit("origin/main", "msg", execApi, { maxCommits: 5 })).rejects.toThrow(/Refusing to linearize an implausible commit range/); + expect(execApi.exec).not.toHaveBeenCalled(); + }); + }); + + describe("checkImplausibleShallowRange", () => { + it("should return implausible:false and commitCount:0 for empty baseRef", async () => { + const { checkImplausibleShallowRange } = await import("./git_helpers.cjs"); + expect(checkImplausibleShallowRange("", "HEAD")).toEqual({ implausible: false, commitCount: 0 }); + }); + + it("should return implausible:false and commitCount:0 for empty headRef", async () => { + const { checkImplausibleShallowRange } = await import("./git_helpers.cjs"); + expect(checkImplausibleShallowRange("origin/main", "")).toEqual({ implausible: false, commitCount: 0 }); + }); + + it("should return implausible:false when git rev-list fails (non-existent refs)", async () => { + const { checkImplausibleShallowRange } = await import("./git_helpers.cjs"); + // Using a clearly non-existent ref so git fails + const result = checkImplausibleShallowRange("refs/nonexistent/base", "refs/nonexistent/head"); + expect(result.implausible).toBe(false); + expect(result.commitCount).toBe(0); + }); + + it("should return implausible:false for a small commit range (integration)", async () => { + const { checkImplausibleShallowRange } = await import("./git_helpers.cjs"); + // The test environment is a non-shallow full clone; HEAD..HEAD has 0 commits + const result = checkImplausibleShallowRange("HEAD", "HEAD"); + expect(result.implausible).toBe(false); + }); + + it("should export SHALLOW_RANGE_MAX_COMMITS as a positive number", async () => { + const { SHALLOW_RANGE_MAX_COMMITS } = await import("./git_helpers.cjs"); + expect(typeof SHALLOW_RANGE_MAX_COMMITS).toBe("number"); + expect(SHALLOW_RANGE_MAX_COMMITS).toBeGreaterThan(0); + }); + + it("should respect the shallow status of the current repo when range exceeds threshold", async () => { + const { checkImplausibleShallowRange, execGitSync } = await import("./git_helpers.cjs"); + // Use maxCommits:0 to force the shallow probe to run even on a 1-commit range — + // this exercises the --is-shallow-repository branch regardless of environment. + // The expected implausible flag depends on whether the current clone is shallow. + let isShallow = false; + try { + isShallow = execGitSync(["rev-parse", "--is-shallow-repository"], { suppressLogs: true }).trim() === "true"; + } catch { + // If the shallow probe fails, treat as non-shallow for this test + } + // HEAD^ may not be reachable in a depth-1 shallow clone (e.g. PR checkout with + // fetch-depth: 1). checkImplausibleShallowRange swallows the git error and + // returns {commitCount: 0} rather than throwing, so we pre-validate here and + // skip instead of asserting on a zero count. + try { + execGitSync(["rev-parse", "--verify", "HEAD^"], { suppressLogs: true }); + } catch { + return; // HEAD^ unreachable in this environment — skip + } + const result = checkImplausibleShallowRange("HEAD^", "HEAD", { maxCommits: 0 }); + // Count is >0 (above threshold of 0); implausible iff the clone is shallow + expect(result.commitCount).toBeGreaterThan(0); + expect(result.implausible).toBe(isShallow); + }); + }); + + describe("hasMergeCommitsInRange", () => { + it("should return false for empty baseRef", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + expect(hasMergeCommitsInRange("", "HEAD")).toBe(false); + }); + + it("should return false for empty headRef", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + expect(hasMergeCommitsInRange("origin/main", "")).toBe(false); + }); + + it("should return false when git rev-list fails (non-existent refs)", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + const result = hasMergeCommitsInRange("refs/nonexistent/base", "refs/nonexistent/head"); + expect(result).toBe(false); + }); + + it("should return false for HEAD..HEAD (empty range, no merge commits)", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + // HEAD..HEAD is an empty range; merges --count returns 0 + const result = hasMergeCommitsInRange("HEAD", "HEAD"); + expect(result).toBe(false); + }); + + it("should respect the shallow status of the current repo when range exceeds threshold", async () => { + const { hasMergeCommitsInRange, execGitSync } = await import("./git_helpers.cjs"); + // maxCommits:0 forces the shallow probe for a 1-commit range. + // In a shallow clone the range is implausible → returns false (no false-positive merges). + // In a full clone the range is not implausible → proceeds to check for merges + // (HEAD^..HEAD has no merge commits → still returns false). + let isShallow = false; + try { + isShallow = execGitSync(["rev-parse", "--is-shallow-repository"], { suppressLogs: true }).trim() === "true"; + } catch { + // treat as non-shallow + } + let result; + try { + result = hasMergeCommitsInRange("HEAD^", "HEAD", { maxCommits: 0 }); + } catch { + // If HEAD^ does not exist (initial commit), skip + return; + } + // Either path (shallow→early false, or non-shallow→no merge commits found→false) + // yields false for a linear single-commit range. + expect(result).toBe(false); + void isShallow; // used above to document expected behavior + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — real temporary git repositories +// --------------------------------------------------------------------------- + +import { execSync, spawnSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { createRequire } from "module"; + +const requireLocal = createRequire(import.meta.url); + +/** + * Build a minimal execApi shim backed by real child_process calls so that + * `linearizeRangeAsCommit` can be exercised end-to-end against a real repo. + */ +function makeRealExecApi(defaultCwd) { + return { + async exec(cmd, args, opts = {}) { + const cwd = opts.cwd || defaultCwd; + const result = spawnSync(cmd, args, { cwd, stdio: "pipe", encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(" ")} exited ${result.status}: ${result.stderr || result.stdout}`); + } + return { exitCode: 0 }; + }, + async getExecOutput(cmd, args, opts = {}) { + const cwd = opts.cwd || defaultCwd; + const result = spawnSync(cmd, args, { cwd, stdio: "pipe", encoding: "utf8" }); + if (result.status !== 0 && !opts.ignoreReturnCode) { + throw new Error(`${cmd} ${args.join(" ")} exited ${result.status}: ${result.stderr || result.stdout}`); + } + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + exitCode: result.status || 0, + }; + }, + }; +} + +/** + * Write a file and commit it in a given repo directory. + */ +function addCommit(repoDir, filename, content, message) { + fs.writeFileSync(path.join(repoDir, filename), content); + execSync(`git add ${filename}`, { cwd: repoDir, stdio: "pipe" }); + execSync(`git commit -m "${message}"`, { cwd: repoDir, stdio: "pipe" }); +} + +describe("git_helpers.cjs - integration (real git repo)", () => { + let repoDir; + let remoteDir; + + beforeEach(() => { + // Provide a no-op core stub (warning spy is added per-test as needed). + global.core = { + debug: () => {}, + info: () => {}, + warning: vi.fn(), + error: () => {}, + setFailed: () => {}, + }; + + // Bare remote. + remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-remote-")); + execSync("git init --bare -b main", { cwd: remoteDir, stdio: "pipe" }); + + // Working repo wired to the bare remote. + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-repo-")); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.name "Test User"', { cwd: repoDir, stdio: "pipe" }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir, stdio: "pipe" }); + + // Initial commit on main then push so origin/main exists. + addCommit(repoDir, "README.md", "# Init\n", "init"); + execSync("git push origin main", { cwd: repoDir, stdio: "pipe" }); + }); + + afterEach(() => { + delete global.core; + if (repoDir && fs.existsSync(repoDir)) fs.rmSync(repoDir, { recursive: true, force: true }); + if (remoteDir && fs.existsSync(remoteDir)) fs.rmSync(remoteDir, { recursive: true, force: true }); + // Clear module cache so each test group gets a fresh import. + delete requireLocal.cache[requireLocal.resolve("./git_helpers.cjs")]; + }); + + // ------------------------------------------------------------------------- + describe("checkImplausibleShallowRange - real repos", () => { + it("returns implausible:false and the correct count for a small range in a full clone", async () => { + const { checkImplausibleShallowRange } = requireLocal("./git_helpers.cjs"); + + execSync("git checkout -b feature", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "a.txt", "A\n", "add a"); + addCommit(repoDir, "b.txt", "B\n", "add b"); + addCommit(repoDir, "c.txt", "C\n", "add c"); + + const result = checkImplausibleShallowRange("origin/main", "HEAD", { cwd: repoDir }); + expect(result.implausible).toBe(false); + expect(result.commitCount).toBe(3); + }); + + it("returns implausible:false for a large range in a non-shallow clone", async () => { + const { checkImplausibleShallowRange } = requireLocal("./git_helpers.cjs"); + + execSync("git checkout -b feature", { cwd: repoDir, stdio: "pipe" }); + // Add 150 commits — above the default SHALLOW_RANGE_MAX_COMMITS (100). + for (let i = 1; i <= 150; i++) { + addCommit(repoDir, `f${i}.txt`, `${i}\n`, `commit ${i}`); + } + + const result = checkImplausibleShallowRange("origin/main", "HEAD", { cwd: repoDir }); + // Full clone → never implausible regardless of range size. + expect(result.implausible).toBe(false); + expect(result.commitCount).toBeGreaterThan(100); + }); + + it("returns implausible:true for a shallow clone when range exceeds threshold", async () => { + const shallowDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-shallow-")); + try { + // Use file:// so that --depth is honoured even for local repositories. + execSync(`git clone --depth=1 --branch main "file://${remoteDir}" ${shallowDir}`, { stdio: "pipe" }); + execSync('git config user.email "test@example.com"', { cwd: shallowDir, stdio: "pipe" }); + execSync('git config user.name "Test User"', { cwd: shallowDir, stdio: "pipe" }); + + // Add one commit on top so the range origin/main..HEAD is non-empty. + addCommit(shallowDir, "extra.txt", "extra\n", "local change"); + + const { checkImplausibleShallowRange } = requireLocal("./git_helpers.cjs"); + // maxCommits:0 means any non-empty range is implausible — ensures the + // shallow probe runs even with a single-commit range. + const result = checkImplausibleShallowRange("origin/main", "HEAD", { cwd: shallowDir, maxCommits: 0 }); + expect(result.implausible).toBe(true); + expect(result.commitCount).toBeGreaterThan(0); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Shallow checkout produced an implausible commit range")); + } finally { + fs.rmSync(shallowDir, { recursive: true, force: true }); + } + }); + }); + + // ------------------------------------------------------------------------- + describe("hasMergeCommitsInRange - real repos", () => { + it("returns false for a linear range with no merge commits", async () => { + const { hasMergeCommitsInRange } = requireLocal("./git_helpers.cjs"); + + execSync("git checkout -b linear-feature", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "x.txt", "x\n", "add x"); + addCommit(repoDir, "y.txt", "y\n", "add y"); + + expect(hasMergeCommitsInRange("origin/main", "HEAD", { cwd: repoDir })).toBe(false); + }); + + it("returns true when the range contains a merge commit", async () => { + const { hasMergeCommitsInRange } = requireLocal("./git_helpers.cjs"); + + // Create a side branch diverging from main. + execSync("git checkout -b side", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "side.txt", "side\n", "side commit"); + + // Add a commit on main to ensure divergence, then merge side with --no-ff. + execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "main-extra.txt", "extra\n", "main commit"); + execSync("git push origin main", { cwd: repoDir, stdio: "pipe" }); + execSync('git merge side --no-ff -m "merge side"', { cwd: repoDir, stdio: "pipe" }); + + expect(hasMergeCommitsInRange("origin/main", "HEAD", { cwd: repoDir })).toBe(true); + }); + + it("returns false for a shallow clone with a range that exceeds the threshold", async () => { + const shallowDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-shallow-merge-")); + try { + // Use file:// so that --depth is honoured even for local repositories. + execSync(`git clone --depth=1 --branch main "file://${remoteDir}" ${shallowDir}`, { stdio: "pipe" }); + execSync('git config user.email "test@example.com"', { cwd: shallowDir, stdio: "pipe" }); + execSync('git config user.name "Test User"', { cwd: shallowDir, stdio: "pipe" }); + addCommit(shallowDir, "local.txt", "local\n", "local change"); + + const { hasMergeCommitsInRange } = requireLocal("./git_helpers.cjs"); + // maxCommits:0 forces the implausible-range guard to fire for any non-empty range. + const result = hasMergeCommitsInRange("origin/main", "HEAD", { cwd: shallowDir, maxCommits: 0 }); + // Guard fires → returns false rather than a phantom merge-commit detection. + expect(result).toBe(false); + } finally { + fs.rmSync(shallowDir, { recursive: true, force: true }); + } + }); + }); + + // ------------------------------------------------------------------------- + describe("linearizeRangeAsCommit - real repos", () => { + it("collapses multiple feature commits into a single commit on top of origin/main", async () => { + const { linearizeRangeAsCommit } = requireLocal("./git_helpers.cjs"); + + execSync("git checkout -b feature", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "p.txt", "P\n", "add p"); + addCommit(repoDir, "q.txt", "Q\n", "add q"); + addCommit(repoDir, "r.txt", "R\n", "add r"); + + const execApi = makeRealExecApi(repoDir); + const gitOpts = { cwd: repoDir }; + + const newSha = await linearizeRangeAsCommit("origin/main", "Squash: p q r", execApi, { gitOpts }); + + // Must return a 40-hex SHA. + expect(newSha).toMatch(/^[0-9a-f]{40}$/); + + // Exactly one commit on top of origin/main after linearization. + const countOut = spawnSync("git", ["rev-list", "--count", "origin/main..HEAD"], { cwd: repoDir, encoding: "utf8" }); + expect(countOut.stdout.trim()).toBe("1"); + + // The commit message must match what was supplied. + const msgOut = spawnSync("git", ["log", "-1", "--format=%s"], { cwd: repoDir, encoding: "utf8" }); + expect(msgOut.stdout.trim()).toBe("Squash: p q r"); + + // All three files from the feature branch must be present. + expect(fs.existsSync(path.join(repoDir, "p.txt"))).toBe(true); + expect(fs.existsSync(path.join(repoDir, "q.txt"))).toBe(true); + expect(fs.existsSync(path.join(repoDir, "r.txt"))).toBe(true); + }); + + it("throws before any git state mutation for a shallow+implausible range", async () => { + const shallowDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-shallow-lin-")); + try { + // Use file:// so that --depth is honoured even for local repositories. + execSync(`git clone --depth=1 --branch main "file://${remoteDir}" ${shallowDir}`, { stdio: "pipe" }); + execSync('git config user.email "test@example.com"', { cwd: shallowDir, stdio: "pipe" }); + execSync('git config user.name "Test User"', { cwd: shallowDir, stdio: "pipe" }); + addCommit(shallowDir, "local.txt", "local\n", "local change"); + + const { linearizeRangeAsCommit } = requireLocal("./git_helpers.cjs"); + const execApi = makeRealExecApi(shallowDir); + const gitOpts = { cwd: shallowDir }; + + const headBefore = spawnSync("git", ["rev-parse", "HEAD"], { cwd: shallowDir, encoding: "utf8" }).stdout.trim(); + + // maxCommits:0 ensures the guard triggers for any non-empty range. + await expect(linearizeRangeAsCommit("origin/main", "Should not commit", execApi, { gitOpts, maxCommits: 0 })).rejects.toThrow(/Refusing to linearize an implausible commit range/); + + // HEAD must be unchanged — the guard fires before any reset. + const headAfter = spawnSync("git", ["rev-parse", "HEAD"], { cwd: shallowDir, encoding: "utf8" }).stdout.trim(); + expect(headAfter).toBe(headBefore); + } finally { + fs.rmSync(shallowDir, { recursive: true, force: true }); + } + }); + + it("restores original HEAD when the commit step fails", async () => { + const { linearizeRangeAsCommit } = requireLocal("./git_helpers.cjs"); + + execSync("git checkout -b feature-fail", { cwd: repoDir, stdio: "pipe" }); + addCommit(repoDir, "s.txt", "S\n", "add s"); + + const headBefore = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim(); + + // A broken execApi whose commit call always fails. + const brokenApi = { + async exec(cmd, args, opts) { + if (args[0] === "reset") { + // Allow the soft reset so the index changes. + return makeRealExecApi(repoDir).exec(cmd, args, opts); + } + throw new Error("commit failed intentionally"); + }, + async getExecOutput(cmd, args, opts) { + return makeRealExecApi(repoDir).getExecOutput(cmd, args, opts); + }, + }; + + await expect(linearizeRangeAsCommit("origin/main", "Should fail", brokenApi, { gitOpts: { cwd: repoDir } })).rejects.toThrow(/Failed to linearize/); + + // HEAD must be rolled back to what it was before the attempt. + const headAfter = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim(); + expect(headAfter).toBe(headBefore); + }); }); describe("getBundlePrerequisites (integration with real git)", () => { diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 710fa97cbbe..84df5de2e68 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -110,6 +110,10 @@ The generated checkout step uses `persist-credentials: false`, so the git creden Fetch everything the workflow needs at checkout time using `fetch-depth` and [`fetch:`](#fetching-additional-refs), and write changes through safe-output tools such as [`push-to-pull-request-branch`](/gh-aw/reference/safe-outputs-pull-requests/) rather than a direct `git push`. The agent is instructed not to configure credential helpers or run `git credential fill`, because authentication cannot succeed; credential errors are reported as a limitation instead of worked around. +:::note[Shallow checkout and push-to-pull-request-branch] +`push-to-pull-request-branch` inspects the commit range `origin/..` in the agent's workspace to detect merge commits and select the appropriate transport. With the default shallow clone (`fetch-depth: 1`), `origin/` has no traversable ancestry, so `git rev-list` reports the entire local history as the range. On large monorepos (thousands of commits) this can falsely trigger bundle or rewrite paths. When the range is implausibly large in a shallow checkout, merge-commit detection returns false (with a warning) to avoid incorrect transport selection; if the range later reaches the signed-push linearization step, that step is refused with a clear error. Set `fetch-depth: 0` to ensure the correct range is visible. +::: + ## Disabling Checkout (`checkout: false`) Set `checkout: false` to suppress both the default `actions/checkout` step and the PR-specific "Checkout PR branch" step entirely. Use this for workflows that access repositories through MCP servers or other mechanisms that do not require a local clone: diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index be187dfda18..7baa2dcf336 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -120,6 +120,19 @@ If the base branch advances between agent start and `safe_outputs` apply, the PR An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution. +:::caution[Shallow checkout and large monorepos] +The merge-commit detection that auto-selects bundle transport inspects the commit range `origin/..` in the **agent's** workspace. With the default shallow checkout (`fetch-depth: 1`) `origin/` has no traversable ancestry, so `git rev-list` cannot exclude any commits and will report the entire local history as the range. On large monorepos this produces a count of tens of thousands of commits, which falsely appears to contain merge commits and can trigger an incorrect rewrite. + +The safe_outputs push job guards against this: if the commit range contains more than 100 commits **and** the repository is shallow, merge-commit detection emits a warning and returns false (preventing an incorrect bundle-transport selection). If the same implausible range then reaches the signed-push linearization step, that step throws with a clear error that includes the commit count. To resolve this, increase `fetch-depth` in your workflow checkout step: + +```yaml wrap +checkout: + fetch-depth: 0 # fetch full history so merge-commit detection sees the correct range +``` + +Alternatively, set an explicit `fetch-depth` large enough to cover the branch history. The threshold is a best-effort guard — for very active branches on a full clone the depth-0 option is the most reliable workaround. +::: + :::note[Cross-repo targets] The `safe_outputs` job always mirrors the agent job's checkout layout. When a `checkout:` entry places a repository in a subdirectory (a `path:` is set), `safe_outputs` checks out **every** repository to the same location the agent used — the workflow repository at the workspace root plus each cross-repo checkout at its `path:` — regardless of whether `target-repo` names a specific repository or the wildcard `"*"`. This lets a specific `target-repo` (and the two-or-more cross-repo case) operate against an identical layout. When the target repository is checked out at the workspace root (no `path:`), it is checked out there in both jobs. :::