Skip to content
Merged
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
50 changes: 48 additions & 2 deletions actions/setup/js/checkout_pr_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isShallowRepository() runs on every depthArgs() call with no memoization, and it silently falls back to false when git rev-parse --is-shallow-repository exits non-zero (i.e., Git < 2.15). On a genuinely shallow clone with an older git binary, --depth is therefore omitted, recreating the original bug.

Consider two improvements:

  1. Cache the result: memoize at module level so the git rev-parse subprocess runs at most once.
  2. Portable fallback: when exitCode !== 0, fall back to fs.existsSync(path.join(process.cwd(), ".git", "shallow")) — purely local, no credentials, and already used for this purpose in generate_git_patch.cjs.

@copilot please address this.

*
* @returns {Promise<boolean>} 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<string[]>}
*/
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
*/
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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}`);
Expand Down
24 changes: 24 additions & 0 deletions actions/setup/js/checkout_pr_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The second test modifies mockContext.eventName but relies on beforeEach/module cache reset (via vi.resetModules) to clean up. If the reset order changes, this mutation leaks into subsequent tests. Explicitly set mockContext.eventName back in the test or in afterEach to make the test self-contained.

💡 Minimal fix

At the top of the pull_request_target test, save and restore:

const originalEvent = mockContext.eventName;
mockContext.eventName = 'pull_request_target';
// ... assertions ...
mockContext.eventName = originalEvent;

Or confirm vi.resetModules() in beforeEach fully re-creates mockContext (add a comment stating this intent).

@copilot please address this.

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;
Expand Down
26 changes: 25 additions & 1 deletion actions/setup/js/generate_git_patch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Shallow detection here uses fs.existsSync('.git/shallow'), but checkout_pr_branch.cjs uses git rev-parse --is-shallow-repository — these diverge on edge cases: git worktrees have .git as a file (not a directory) so the path.join(cwd, '.git', 'shallow') join silently fails; also git fetch --unshallow does not always delete .git/shallow immediately, giving false positives.

💡 Suggested fix

Extract isShallowRepository() into a shared helper (e.g. git_helpers.cjs) and call it in both files:

const shallow = await isShallowRepository({ cwd });
if (shallow) {
  throw new Error(`${ERR_SYSTEM}: Could not compute merge-base ... shallow clone.`);
}
throw mergeBaseError;

This is also more testable — no real .git/shallow file needed.

@copilot please address this.

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."
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No test covers the new merge-base error path in generate_git_patch.cjs. Without a regression test, future refactors can silently remove the improved error message and nobody will notice.

💡 Suggested test shape

In generate_git_patch.test.cjs, add a test that stubs execGitSync to throw on merge-base and asserts the resulting error message contains 'shallow clone' and ERR_SYSTEM:

it('reports shallow-clone error when merge-base fails and .git/shallow exists', async () => {
  mockExecGitSync.mockImplementationOnce((args) => {
    if (args[0] === 'merge-base') throw new Error('fatal: no merge base');
  });
  mockFsExistsSync.mockReturnValue(true); // simulate .git/shallow present
  await expect(generateGitPatch('branch', 'main', { mode: 'full' }))
    .rejects.toThrow(/shallow clone/);
});

@copilot please address this.

}
throw mergeBaseError;
Comment on lines +261 to +267
}
debugLog(`Strategy 1 (full): Computed merge-base: ${baseRef}`);
} else {
// No remote refs available - fall through to Strategy 2
Expand Down Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions actions/setup/js/generate_git_patch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down