diff --git a/actions/setup/js/checkout_pr_branch.cjs b/actions/setup/js/checkout_pr_branch.cjs index f9302830222..87b195c6741 100644 --- a/actions/setup/js/checkout_pr_branch.cjs +++ b/actions/setup/js/checkout_pr_branch.cjs @@ -36,6 +36,48 @@ const { detectForkPR } = require("./pr_helpers.cjs"); const { ERR_API } = require("./error_codes.cjs"); const TRUSTED_CHECKOUT_PERMISSIONS = ["write", "maintain", "admin"]; +/** + * Determine whether the current repository is a shallow clone. + * + * A `--depth` fetch against an already-complete clone writes `.git/shallow` and + * grafts history, silently undoing an explicit `checkout: fetch-depth: 0`. That + * breaks later `git merge-base` calls (e.g. patch generation for + * create_pull_request). We therefore only pass `--depth` when the repository is + * already shallow; a complete clone already has the objects we need. + * + * @returns {Promise} true when the repository is shallow + */ +async function isShallowRepository() { + try { + const result = await exec.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], { + silent: true, + ignoreReturnCode: true, + }); + if (result.exitCode !== 0) { + return false; + } + return result.stdout.trim() === "true"; + } catch (e) { + core.warning(`Could not determine repository shallowness, assuming complete clone: ${getErrorMessage(e)}`); + return false; + } +} + +/** + * Build the optional `--depth=N` argument for a fetch, omitting it when the + * repository is a complete (non-shallow) clone. + * + * @param {number} fetchDepth + * @returns {Promise} + */ +async function depthArgs(fetchDepth) { + if (await isShallowRepository()) { + return [`--depth=${fetchDepth}`]; + } + core.info("Repository is not shallow (e.g. fetch-depth: 0), fetching without --depth to preserve full history"); + return []; +} + /** * Log detailed PR context information for debugging */ @@ -264,7 +306,9 @@ async function main() { logCheckoutStrategy(eventName, "git fetch + checkout", "pull_request event runs in merge commit context with PR branch available"); core.info(`Fetching branch: ${branchName} from origin (depth: ${fetchDepth} for ${commitCount} PR commit(s))`); - await exec.exec("git", ["fetch", "origin", branchName, `--depth=${fetchDepth}`]); + const fetchArgs = await depthArgs(fetchDepth); + core.info(fetchArgs.length > 0 ? `Fetching with ${fetchArgs.join(" ")}` : "Fetching without --depth (full history preserved)"); + await exec.exec("git", ["fetch", "origin", branchName, ...fetchArgs]); core.info(`Checking out branch: ${branchName}`); await exec.exec("git", ["checkout", branchName]); @@ -304,7 +348,9 @@ async function main() { const fetchDepth = (commitCount || 1) + 1; // +1 to include the merge base core.info(`Fetching PR #${prNumber} head via refs/pull/${prNumber}/head (depth: ${fetchDepth} for ${commitCount} PR commit(s))`); - await exec.exec("git", ["fetch", "origin", `+refs/pull/${prNumber}/head:refs/remotes/origin/pr-head`, `--depth=${fetchDepth}`]); + const prFetchArgs = await depthArgs(fetchDepth); + core.info(prFetchArgs.length > 0 ? `Fetching with ${prFetchArgs.join(" ")}` : "Fetching without --depth (full history preserved)"); + await exec.exec("git", ["fetch", "origin", `+refs/pull/${prNumber}/head:refs/remotes/origin/pr-head`, ...prFetchArgs]); const branchName = headRef || `pr-${prNumber}`; core.info(`Checking out branch: ${branchName}`); diff --git a/actions/setup/js/checkout_pr_branch.test.cjs b/actions/setup/js/checkout_pr_branch.test.cjs index 74558a15b15..e75a5e6cca5 100644 --- a/actions/setup/js/checkout_pr_branch.test.cjs +++ b/actions/setup/js/checkout_pr_branch.test.cjs @@ -26,6 +26,8 @@ describe("checkout_pr_branch.cjs", () => { // Mock exec mockExec = { exec: vi.fn().mockResolvedValue(0), + // Default: repository is shallow, so --depth is preserved + getExecOutput: vi.fn().mockResolvedValue({ stdout: "true\n", stderr: "", exitCode: 0 }), }; // Mock context @@ -238,6 +240,28 @@ If the pull request is still open, verify that: expect(mockCore.setFailed).not.toHaveBeenCalled(); }); + it("should omit --depth when the repository is not shallow", async () => { + mockExec.getExecOutput.mockResolvedValue({ stdout: "false\n", stderr: "", exitCode: 0 }); + + const script = require("./checkout_pr_branch.cjs"); + await script.main(); + + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch"]); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch", "--depth=2"]); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should omit --depth for refs/pull fetch when the repository is not shallow", async () => { + mockExec.getExecOutput.mockResolvedValue({ stdout: "false\n", stderr: "", exitCode: 0 }); + mockContext.eventName = "pull_request_target"; + + const script = require("./checkout_pr_branch.cjs"); + await script.main(); + + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", "+refs/pull/123/head:refs/remotes/origin/pr-head"]); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + describe("runtime checkout safety assertions", () => { it("should fail when runtime repository context is a fork", async () => { mockContext.payload.repository.fork = true; diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index f2d1a7e1a23..7d25975712b 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -251,7 +251,21 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } if (defaultBranchRef) { - baseRef = execGitSync(["merge-base", "--", defaultBranchRef, tipRef], { cwd }).trim(); + try { + baseRef = execGitSync(["merge-base", "--", defaultBranchRef, tipRef], { cwd }).trim(); + } catch (mergeBaseError) { + // A shallow clone (or a `--depth` fetch that grafted history onto an + // otherwise complete clone) can make the merge-base unreachable. + // Surface that explicitly instead of the misleading "branch does not + // exist locally" message. + if (fs.existsSync(path.join(cwd || process.cwd(), ".git", "shallow"))) { + throw new Error( + `${ERR_SYSTEM}: Could not compute merge-base between ${defaultBranchRef} and ${tipRef} because the repository is a shallow clone (.git/shallow exists). ` + + "Deepen the clone (checkout.fetch-depth: 0) so the common ancestor is reachable." + ); + } + throw mergeBaseError; + } debugLog(`Strategy 1 (full): Computed merge-base: ${baseRef}`); } else { // No remote refs available - fall through to Strategy 2 @@ -309,6 +323,16 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } catch (branchError) { // Branch does not exist locally (or pinnedSha failed) debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${getErrorMessage(branchError)}`); + // Shallow-clone diagnostics (ERR_SYSTEM errors thrown from the merge-base + // block above) must reach callers immediately — falling through to Strategy 2 + // or 3 would produce a misleading "No changes to commit" result instead. + if (getErrorMessage(branchError).startsWith(ERR_SYSTEM)) { + return { + success: false, + error: getErrorMessage(branchError), + patchPath: patchPath, + }; + } if (options.pinnedSha) { // SECURITY: When pinnedSha is set, fail closed — do not fall through to // other strategies that would resolve a different commit. diff --git a/actions/setup/js/generate_git_patch.test.cjs b/actions/setup/js/generate_git_patch.test.cjs index de9c62fe4f7..33bf67e3084 100644 --- a/actions/setup/js/generate_git_patch.test.cjs +++ b/actions/setup/js/generate_git_patch.test.cjs @@ -709,6 +709,80 @@ describe("generateGitPatch – full mode base ref (merge-base, not stale origin) }); }); +describe("generateGitPatch – shallow clone merge-base error surfaces to caller", () => { + let repoDir; + let originalEnv; + + beforeEach(() => { + originalEnv = { GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE, GITHUB_SHA: process.env.GITHUB_SHA }; + global.core = { debug: () => {}, info: () => {}, warning: () => {}, error: () => {} }; + + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-patch-shallow-")); + execSync("git init -b main", { cwd: repoDir }); + execSync('git config user.email "test@example.com"', { cwd: repoDir }); + execSync('git config user.name "Test"', { cwd: repoDir }); + + delete process.env.GITHUB_WORKSPACE; + delete process.env.GITHUB_SHA; + delete require.cache[require.resolve("./generate_git_patch.cjs")]; + }); + + afterEach(() => { + Object.entries(originalEnv).forEach(([k, v]) => { + if (v !== undefined) process.env[k] = v; + else delete process.env[k]; + }); + if (repoDir && fs.existsSync(repoDir)) { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + delete require.cache[require.resolve("./generate_git_patch.cjs")]; + delete global.core; + }); + + it("should return a shallow-clone diagnostic (not a generic error) when merge-base fails due to shallow clone", async () => { + // Set up remote + local repo + const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-patch-shallow-remote-")); + try { + execSync("git init --bare -b main", { cwd: remoteDir }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + + // Commit several times on main so there is depth + for (let i = 0; i < 5; i++) { + fs.writeFileSync(path.join(repoDir, `commit${i}.txt`), `content ${i}\n`); + execSync("git add .", { cwd: repoDir }); + execSync(`git commit -m "main commit ${i}"`, { cwd: repoDir }); + } + execSync("git push origin main", { cwd: repoDir }); + + // Create feature branch + execSync("git checkout -b feature", { cwd: repoDir }); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + execSync("git add .", { cwd: repoDir }); + execSync('git commit -m "feature commit"', { cwd: repoDir }); + + // Simulate a shallow clone by creating a .git/shallow file that grafts + // history so merge-base cannot be resolved + const tipSha = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim(); + fs.writeFileSync(path.join(repoDir, ".git", "shallow"), `${tipSha}\n`); + + // Also set up origin/main as if it were fetched + execSync("git fetch origin main:refs/remotes/origin/main", { cwd: repoDir }); + + const { generateGitPatch } = require("./generate_git_patch.cjs"); + const result = await generateGitPatch("feature", "main", { cwd: repoDir, mode: "full" }); + + // The error must surface to the caller with the shallow-clone explanation + expect(result.success).toBe(false); + expect(result.error).toMatch(/shallow clone/i); + expect(result.error).toMatch(/fetch-depth.*0|deepen/i); + } finally { + if (fs.existsSync(remoteDir)) { + fs.rmSync(remoteDir, { recursive: true, force: true }); + } + } + }); +}); + describe("generateGitPatch – Strategy 3 picks closest remote merge-base", () => { let repoDir; let originalEnv;