From 48b8965fda51db919d8ea5185655d7039b40b367 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:33:58 +0000 Subject: [PATCH 1/5] Initial plan From ae45e486c54e8f3eaa4dbd67f42a33ea1994709f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:51:36 +0000 Subject: [PATCH 2/5] fix: use bundle prerequisite SHA in rewriteBundleBranchAsSingleCommit to avoid base-branch drift When the base branch advances after the agent's checkout, soft-resetting to origin/ tip computes diff(new_tip_tree, agent_head_tree) which reverses unrelated base commits in the synthesized commit. Fix: export getBundlePrerequisites from git_helpers.cjs, accept bundleFilePath in rewriteBundleBranchAsSingleCommit, and use the bundle's prerequisite SHA as the linearization base when exactly one prerequisite is recorded. Falls back to origin/ for bundles with zero or multiple prerequisites. Also export rewriteBundleBranchAsSingleCommit for direct testing, and add 7 unit tests for getBundlePrerequisites plus an integration test that simulates a moving-base-branch scenario with a merge commit forcing the rewrite path. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 38 ++++-- ...e_pull_request_bundle_integration.test.cjs | 111 ++++++++++++++++++ actions/setup/js/git_helpers.cjs | 1 + actions/setup/js/git_helpers.test.cjs | 106 +++++++++++++++++ 4 files changed, 249 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 373245a58fd..157910e11e3 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -36,7 +36,7 @@ const { isStagedMode } = require("./safe_output_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); +const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, getBundlePrerequisites, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); const { resolveTransportPaths } = require("./resolve_transport_paths.cjs"); const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs"); @@ -322,15 +322,39 @@ async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBran } /** - * Rewrites the current branch to a single non-merge commit relative to origin/. - * This is used as a recovery path when signed commit replay rejects merge commit topology. + * Rewrites the current branch to a single non-merge commit relative to the bundle's + * actual base commit (the prerequisite SHA recorded in the bundle). Falls back to + * origin/ when the bundle prerequisite cannot be determined. + * + * Using the bundle's prerequisite SHA instead of the current origin/ tip + * ensures the linearized commit only contains the agent's actual changes and does not + * revert or absorb commits that were added to the base branch after the agent's checkout. * * @param {string} baseBranch * @param {{ exec: Function, getExecOutput: Function }} execApi + * @param {string} [bundleFilePath] - Optional path to the bundle file; used to extract the + * precise base commit the agent worked from. * @returns {Promise} */ -async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi) { - const baseRef = `origin/${baseBranch}`; +async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFilePath) { + const fallbackBaseRef = `origin/${baseBranch}`; + let baseRef = fallbackBaseRef; + + if (bundleFilePath) { + try { + const prereqs = await getBundlePrerequisites(execApi, bundleFilePath); + if (prereqs.length === 1) { + baseRef = prereqs[0]; + core.info(`Using bundle prerequisite commit ${baseRef} as linearization base (avoids including base-branch drift)`); + } else if (prereqs.length > 1) { + core.info(`Bundle has ${prereqs.length} prerequisite commits; falling back to ${fallbackBaseRef} as linearization base`); + } else { + core.info(`Bundle declares no prerequisites; falling back to ${fallbackBaseRef} as linearization base`); + } + } catch (prereqError) { + core.warning(`Could not extract bundle prerequisites: ${getErrorMessage(prereqError)}; falling back to ${fallbackBaseRef}`); + } + } let commitHeadline = "Apply bundled create_pull_request changes"; try { @@ -1678,7 +1702,7 @@ async function main(config = {}) { if (isSignedMergeReplayRefusal) { core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push"); try { - await rewriteBundleBranchAsSingleCommit(baseBranch, exec); + await rewriteBundleBranchAsSingleCommit(baseBranch, exec, bundleFilePath); const runRetryPush = async () => pushSignedCommits({ githubClient: pushGithubClient, @@ -2744,4 +2768,4 @@ ${patchPreview}`; }; // End of handleCreatePullRequest } // End of main -module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch }; +module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch, rewriteBundleBranchAsSingleCommit }; diff --git a/actions/setup/js/create_pull_request_bundle_integration.test.cjs b/actions/setup/js/create_pull_request_bundle_integration.test.cjs index 5b7972c2603..27a3e301b50 100644 --- a/actions/setup/js/create_pull_request_bundle_integration.test.cjs +++ b/actions/setup/js/create_pull_request_bundle_integration.test.cjs @@ -435,4 +435,115 @@ describe("create_pull_request bundle integration", () => { expect(rawPushError).toContain("@org/team"); core.info("[reconcile-spark] push error captured — ready for sanitization and fallback issue embedding"); }); + + it("rewriteBundleBranchAsSingleCommit uses bundle prerequisite SHA to avoid including base-branch drift", async () => { + // ─── Why this test exists ──────────────────────────────────────────────── + // + // When base branch advances after the agent's checkout, the naive + // soft-reset to origin/ absorbs those new base commits into the diff + // and makes the synthesized commit appear to delete files that were added + // to the base branch after checkout. + // + // The fix uses the bundle's prerequisite SHA (the exact commit the agent + // started from) as the linearization base, so only the agent's actual + // changes appear in the synthesized commit. + // + // Setup: + // 1. Bare remote with an initial commit on main. + // 2. Agent clones, checks out main, creates feature branch. + // 3. Agent adds "agent-file.txt". + // 4. Meanwhile, a collaborator adds "base-drift.txt" to main. + // 5. Agent (unaware of drift) creates a merge commit. + // 6. Agent creates a bundle: main..HEAD (prereq = initial main commit). + // 7. Safe-outputs repo clones the *updated* origin/main (which includes + // "base-drift.txt"), applies the bundle. + // 8. rewriteBundleBranchAsSingleCommit is called with bundleFilePath. + // 9. The synthesized commit MUST contain "agent-file.txt". + // 10. The synthesized commit MUST NOT delete "base-drift.txt" (drift). + // ───────────────────────────────────────────────────────────────────────── + + const bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-moving-base-bare-")); + const agentRepo = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-moving-base-agent-")); + const safeOutputsRepo = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-moving-base-so-")); + tempDirs.push(bareRemote, agentRepo, safeOutputsRepo); + + // 1. Initialize bare remote and seed with initial commit. + execGit(["init", "--bare", "-b", "main"], { cwd: bareRemote }); + execGit(["clone", bareRemote, "."], { cwd: agentRepo }); + execGit(["config", "user.name", "Agent"], { cwd: agentRepo }); + execGit(["config", "user.email", "agent@example.com"], { cwd: agentRepo }); + fs.writeFileSync(path.join(agentRepo, "README.md"), "# Project\n"); + execGit(["add", "README.md"], { cwd: agentRepo }); + execGit(["commit", "-m", "Initial commit"], { cwd: agentRepo }); + execGit(["branch", "-M", "main"], { cwd: agentRepo }); + execGit(["push", "-u", "origin", "main"], { cwd: agentRepo }); + + // Capture the base commit SHA that the agent starts from. + const agentBaseCommit = execGit(["rev-parse", "HEAD"], { cwd: agentRepo }).stdout.trim(); + + // 2. Agent creates feature branch and adds a file. + const branchName = "feature/moving-base-test"; + execGit(["checkout", "-b", branchName], { cwd: agentRepo }); + fs.writeFileSync(path.join(agentRepo, "agent-file.txt"), "agent change\n"); + execGit(["add", "agent-file.txt"], { cwd: agentRepo }); + execGit(["commit", "-m", "feat: agent adds a file"], { cwd: agentRepo }); + + // 3. Collaborator advances main with a new commit (base-branch drift). + execGit(["checkout", "main"], { cwd: agentRepo }); + fs.writeFileSync(path.join(agentRepo, "base-drift.txt"), "base drift added after agent checkout\n"); + execGit(["add", "base-drift.txt"], { cwd: agentRepo }); + execGit(["commit", "-m", "chore: add base-drift file"], { cwd: agentRepo }); + execGit(["push", "origin", "main"], { cwd: agentRepo }); + + // 4. Agent (unaware of drift) merges main to reconcile — creates merge commit. + execGit(["checkout", branchName], { cwd: agentRepo }); + execGit(["merge", "--no-ff", "main", "-m", "reconcile: merge updated main"], { cwd: agentRepo }); + + // 5. Create bundle with a range: prerequisite is agentBaseCommit (the exact base the agent + // started from). Using main..HEAD ensures the bundle records agentBaseCommit as a prereq. + const bundlePath = path.join(agentRepo, "feature.bundle"); + execGit(["bundle", "create", bundlePath, `${agentBaseCommit}..refs/heads/${branchName}`], { cwd: agentRepo }); + + // Verify the bundle's prerequisite is agentBaseCommit. + const verifyOutput = execGit(["bundle", "verify", bundlePath], { cwd: agentRepo, allowFailure: true }); + const combinedVerify = `${verifyOutput.stdout || ""}\n${verifyOutput.stderr || ""}`; + expect(combinedVerify).toMatch(new RegExp(agentBaseCommit.slice(0, 8), "i")); + + // 6. Safe-outputs runner clones the *updated* origin/main (includes base-drift.txt). + execGit(["clone", bareRemote, "."], { cwd: safeOutputsRepo }); + execGit(["config", "user.name", "Runner"], { cwd: safeOutputsRepo }); + execGit(["config", "user.email", "runner@example.com"], { cwd: safeOutputsRepo }); + const newOriginMainSha = execGit(["rev-parse", "origin/main"], { cwd: safeOutputsRepo }).stdout.trim(); + expect(newOriginMainSha).not.toBe(agentBaseCommit); // Confirms base has moved. + + // 7. Apply the bundle to the safe-outputs repo. + const { applyBundleToBranch, rewriteBundleBranchAsSingleCommit } = require("./create_pull_request.cjs"); + await applyBundleToBranch(bundlePath, branchName, `refs/heads/${branchName}`, createExecApi(safeOutputsRepo), "main"); + + // 8. Call rewriteBundleBranchAsSingleCommit with bundleFilePath. + await rewriteBundleBranchAsSingleCommit("main", createExecApi(safeOutputsRepo), bundlePath); + + // 9 & 10. Verify the synthesized commit. + // - The commit tree must contain "agent-file.txt" (agent's actual change). + // - "base-drift.txt" must be present in the working tree (NOT deleted by the rewrite). + // - The diff relative to the commit's parent must NOT contain a deletion of "base-drift.txt". + const agentFilePresent = fs.existsSync(path.join(safeOutputsRepo, "agent-file.txt")); + const baseDriftPresent = fs.existsSync(path.join(safeOutputsRepo, "base-drift.txt")); + + expect(agentFilePresent).toBe(true); + expect(baseDriftPresent).toBe(true); + + // The HEAD commit (linearized) should show "agent-file.txt" as added. + const headStat = execGit(["show", "--stat", "HEAD"], { cwd: safeOutputsRepo }).stdout; + expect(headStat).toContain("agent-file.txt"); + + // The diff introduced by HEAD must NOT show base-drift.txt as deleted. + const diffStat = execGit(["diff", "--name-status", "HEAD^", "HEAD"], { cwd: safeOutputsRepo }).stdout; + expect(diffStat).not.toMatch(/^D\s+base-drift\.txt/m); + + // Verify the commit has exactly one parent (linearized, not a merge commit). + const parentLine = execGit(["log", "-1", "--format=%P", "HEAD"], { cwd: safeOutputsRepo }).stdout.trim(); + const parentShas = parentLine.split(/\s+/).filter(Boolean); + expect(parentShas).toHaveLength(1); + }); }); diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index ecd14f41291..0c6c88affbc 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -657,6 +657,7 @@ module.exports = { ensureFullHistoryForBundle, ensureOriginRemoteTrackingRef, extractBundlePrerequisiteCommits, + getBundlePrerequisites, getGitAuthEnv, hasMergeCommitsInRange, isShallowOrSparseCheckout, diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index aa6574b8bb1..5e126f24299 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -735,6 +735,112 @@ describe("git_helpers.cjs", () => { }); }); + describe("getBundlePrerequisites", () => { + const PREREQ_SHA = "172f87a830f57a29470efe7646d141069434a893"; + const ANOTHER_SHA = "aabbccddee1122334455667788990011aabbccdd"; + + it("should be exported from git_helpers.cjs", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + expect(typeof getBundlePrerequisites).toBe("function"); + }); + + it("should return single prerequisite SHA from bundle verify output", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ + stdout: `The bundle requires this ref:\n ${PREREQ_SHA} \nThe bundle contains this ref:\n`, + stderr: "", + }), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); + + expect(result).toEqual([PREREQ_SHA]); + expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "verify", "/tmp/test.bundle"], { ignoreReturnCode: true, silent: true }); + }); + + it("should return multiple prerequisite SHAs when bundle has several", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ + stdout: `The bundle requires these refs:\n ${PREREQ_SHA} \n ${ANOTHER_SHA} \nThe bundle contains these refs:\n`, + stderr: "", + }), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); + + expect(result).toContain(PREREQ_SHA); + expect(result).toContain(ANOTHER_SHA); + expect(result).toHaveLength(2); + }); + + it("should return empty array when bundle has no prerequisites (self-contained)", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ + stdout: "The bundle contains this ref:\n abcdef1234567890abcdef1234567890abcdef12 refs/heads/main\n", + stderr: "", + }), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); + + expect(result).toEqual([]); + }); + + it("should return empty array and not throw when git bundle verify fails", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockRejectedValue(new Error("not a bundle file")), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/not-a-bundle"); + + expect(result).toEqual([]); + }); + + it("should pick up prerequisites from 'Repository lacks these prerequisite commits' block in stderr", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ + stdout: "", + stderr: `error: Repository lacks these prerequisite commits:\nerror: ${PREREQ_SHA}`, + }), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); + + expect(result).toEqual([PREREQ_SHA]); + }); + + it("should deduplicate SHAs that appear in both verify output sections", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ + stdout: `The bundle requires this ref:\n ${PREREQ_SHA} \n`, + stderr: `error: Repository lacks these prerequisite commits:\nerror: ${PREREQ_SHA}`, + }), + }; + + const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); + + expect(result).toEqual([PREREQ_SHA]); + }); + + it("should pass additional options to getExecOutput", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + }; + const options = { cwd: "/repo" }; + + await getBundlePrerequisites(execApi, "/tmp/test.bundle", options); + + expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "verify", "/tmp/test.bundle"], { cwd: "/repo", ignoreReturnCode: true, silent: true }); + }); + }); + describe("linearizeRangeAsCommit", () => { const ORIGINAL_HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; From a135f4c134414b36c3005d531b7f7f11136f2f96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:18:17 +0000 Subject: [PATCH 3/5] fix: strengthen bundle linearizer tests and add SHA reachability guard - Fix integration test to model the production race faithfully: bundle is now created BEFORE main advances (not after agent merges drift), so the bundle's prereq SHA accurately reflects the agent's actual base commit. - Strengthen drift-absorption assertion: changed from checking base-drift.txt is not deleted to checking it does not appear in the diff at all. The old assertion was trivially satisfied when base drift was absorbed as an addition rather than a deletion. - Add SHA reachability guard in rewriteBundleBranchAsSingleCommit: before using the bundle prereq SHA as the linearization base, verify the commit object is accessible locally (git cat-file -e). Falls back to origin/ when the prereq is not available (e.g., in a shallow clone). - Add direct unit tests for hasMergeCommitsInRange in git_helpers.test.cjs: covers null/empty inputs, git failure (unreachable refs), a range with no merge commits, and a range containing a merge commit (uses a real temp repo). Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 19 +++- ...e_pull_request_bundle_integration.test.cjs | 105 ++++++++++-------- actions/setup/js/git_helpers.test.cjs | 84 ++++++++++++++ 3 files changed, 159 insertions(+), 49 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 157910e11e3..b91abbb1937 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -344,8 +344,23 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile try { const prereqs = await getBundlePrerequisites(execApi, bundleFilePath); if (prereqs.length === 1) { - baseRef = prereqs[0]; - core.info(`Using bundle prerequisite commit ${baseRef} as linearization base (avoids including base-branch drift)`); + // Guard: verify the prerequisite SHA is accessible in the local repository + // before using it as a linearization base. In a shallow clone the commit + // may not have been fetched, causing `git reset --soft ` to fail. + // We check reachability here — before synthesizing any commit — so we can + // fall back cleanly rather than letting linearizeRangeAsCommit abort mid-run. + const prereqSha = prereqs[0]; + try { + const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${prereqSha}^{commit}`], { ignoreReturnCode: true, silent: true }); + if (exitCode === 0) { + baseRef = prereqSha; + core.info(`Using bundle prerequisite commit ${baseRef} as linearization base (avoids including base-branch drift)`); + } else { + core.info(`Bundle prerequisite ${prereqSha} not accessible locally; falling back to ${fallbackBaseRef} as linearization base`); + } + } catch { + core.info(`Could not verify bundle prerequisite accessibility; falling back to ${fallbackBaseRef} as linearization base`); + } } else if (prereqs.length > 1) { core.info(`Bundle has ${prereqs.length} prerequisite commits; falling back to ${fallbackBaseRef} as linearization base`); } else { diff --git a/actions/setup/js/create_pull_request_bundle_integration.test.cjs b/actions/setup/js/create_pull_request_bundle_integration.test.cjs index 27a3e301b50..5a12a14b97e 100644 --- a/actions/setup/js/create_pull_request_bundle_integration.test.cjs +++ b/actions/setup/js/create_pull_request_bundle_integration.test.cjs @@ -439,27 +439,30 @@ describe("create_pull_request bundle integration", () => { it("rewriteBundleBranchAsSingleCommit uses bundle prerequisite SHA to avoid including base-branch drift", async () => { // ─── Why this test exists ──────────────────────────────────────────────── // - // When base branch advances after the agent's checkout, the naive - // soft-reset to origin/ absorbs those new base commits into the diff - // and makes the synthesized commit appear to delete files that were added - // to the base branch after checkout. + // Production race: the agent creates its bundle while main is at commit A. + // Between bundle creation and safe-outputs applying it, a collaborator + // advances main to commit B (adding "base-drift.txt"). The safe-outputs + // runner clones origin/main at B, applies the bundle, then calls + // rewriteBundleBranchAsSingleCommit. // - // The fix uses the bundle's prerequisite SHA (the exact commit the agent - // started from) as the linearization base, so only the agent's actual - // changes appear in the synthesized commit. + // With the naive fix (soft-reset to origin/main = B), the staged diff is + // {agent-file.txt added, base-drift.txt deleted}, so the synthesized commit + // incorrectly deletes base-drift.txt. // - // Setup: - // 1. Bare remote with an initial commit on main. - // 2. Agent clones, checks out main, creates feature branch. - // 3. Agent adds "agent-file.txt". - // 4. Meanwhile, a collaborator adds "base-drift.txt" to main. - // 5. Agent (unaware of drift) creates a merge commit. - // 6. Agent creates a bundle: main..HEAD (prereq = initial main commit). - // 7. Safe-outputs repo clones the *updated* origin/main (which includes - // "base-drift.txt"), applies the bundle. - // 8. rewriteBundleBranchAsSingleCommit is called with bundleFilePath. - // 9. The synthesized commit MUST contain "agent-file.txt". - // 10. The synthesized commit MUST NOT delete "base-drift.txt" (drift). + // The correct fix: read the prerequisite SHA from the bundle (= commit A, + // the exact base the agent started from) and soft-reset to that SHA. + // Staged diff becomes {agent-file.txt added} only. + // + // This test models the production race faithfully: + // 1. Bare remote with initial commit A on main. + // 2. Agent clones at A, creates feature branch, adds "agent-file.txt". + // 3. Agent bundles while main is still at A → prereq = A. + // 4. Collaborator advances main to B ("base-drift.txt"). + // 5. Safe-outputs clones updated origin (B on main), applies bundle. + // 6. rewriteBundleBranchAsSingleCommit is called. + // 7. Synthesized commit MUST contain only "agent-file.txt" (from agent). + // 8. "base-drift.txt" MUST NOT appear anywhere in the synthesized diff + // (neither as added nor as deleted — it was never the agent's change). // ───────────────────────────────────────────────────────────────────────── const bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-moving-base-bare-")); @@ -467,7 +470,7 @@ describe("create_pull_request bundle integration", () => { const safeOutputsRepo = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-moving-base-so-")); tempDirs.push(bareRemote, agentRepo, safeOutputsRepo); - // 1. Initialize bare remote and seed with initial commit. + // 1. Initialize bare remote and seed with initial commit A. execGit(["init", "--bare", "-b", "main"], { cwd: bareRemote }); execGit(["clone", bareRemote, "."], { cwd: agentRepo }); execGit(["config", "user.name", "Agent"], { cwd: agentRepo }); @@ -478,68 +481,76 @@ describe("create_pull_request bundle integration", () => { execGit(["branch", "-M", "main"], { cwd: agentRepo }); execGit(["push", "-u", "origin", "main"], { cwd: agentRepo }); - // Capture the base commit SHA that the agent starts from. + // Capture commit A — the exact base the agent starts from. const agentBaseCommit = execGit(["rev-parse", "HEAD"], { cwd: agentRepo }).stdout.trim(); - // 2. Agent creates feature branch and adds a file. + // 2. Agent creates feature branch and adds a file (main is still at A here). const branchName = "feature/moving-base-test"; execGit(["checkout", "-b", branchName], { cwd: agentRepo }); fs.writeFileSync(path.join(agentRepo, "agent-file.txt"), "agent change\n"); execGit(["add", "agent-file.txt"], { cwd: agentRepo }); execGit(["commit", "-m", "feat: agent adds a file"], { cwd: agentRepo }); - // 3. Collaborator advances main with a new commit (base-branch drift). - execGit(["checkout", "main"], { cwd: agentRepo }); - fs.writeFileSync(path.join(agentRepo, "base-drift.txt"), "base drift added after agent checkout\n"); - execGit(["add", "base-drift.txt"], { cwd: agentRepo }); - execGit(["commit", "-m", "chore: add base-drift file"], { cwd: agentRepo }); - execGit(["push", "origin", "main"], { cwd: agentRepo }); - - // 4. Agent (unaware of drift) merges main to reconcile — creates merge commit. - execGit(["checkout", branchName], { cwd: agentRepo }); - execGit(["merge", "--no-ff", "main", "-m", "reconcile: merge updated main"], { cwd: agentRepo }); - - // 5. Create bundle with a range: prerequisite is agentBaseCommit (the exact base the agent - // started from). Using main..HEAD ensures the bundle records agentBaseCommit as a prereq. + // 3. Agent bundles while main is STILL at A (before any drift). + // The bundle prerequisite is therefore A — the agent's actual base. const bundlePath = path.join(agentRepo, "feature.bundle"); execGit(["bundle", "create", bundlePath, `${agentBaseCommit}..refs/heads/${branchName}`], { cwd: agentRepo }); - // Verify the bundle's prerequisite is agentBaseCommit. + // Confirm the bundle's prerequisite is recorded as agentBaseCommit (A). const verifyOutput = execGit(["bundle", "verify", bundlePath], { cwd: agentRepo, allowFailure: true }); const combinedVerify = `${verifyOutput.stdout || ""}\n${verifyOutput.stderr || ""}`; expect(combinedVerify).toMatch(new RegExp(agentBaseCommit.slice(0, 8), "i")); - // 6. Safe-outputs runner clones the *updated* origin/main (includes base-drift.txt). + // 4. AFTER bundling: collaborator advances main to B (base-branch drift). + execGit(["checkout", "main"], { cwd: agentRepo }); + fs.writeFileSync(path.join(agentRepo, "base-drift.txt"), "base drift added after agent checkout\n"); + execGit(["add", "base-drift.txt"], { cwd: agentRepo }); + execGit(["commit", "-m", "chore: add base-drift file"], { cwd: agentRepo }); + execGit(["push", "origin", "main"], { cwd: agentRepo }); + + // 5. Safe-outputs runner clones the *updated* origin/main (B — includes base-drift.txt). execGit(["clone", bareRemote, "."], { cwd: safeOutputsRepo }); execGit(["config", "user.name", "Runner"], { cwd: safeOutputsRepo }); execGit(["config", "user.email", "runner@example.com"], { cwd: safeOutputsRepo }); const newOriginMainSha = execGit(["rev-parse", "origin/main"], { cwd: safeOutputsRepo }).stdout.trim(); expect(newOriginMainSha).not.toBe(agentBaseCommit); // Confirms base has moved. - // 7. Apply the bundle to the safe-outputs repo. + // Confirm base-drift.txt is on origin/main (the drift file is present on the remote). + const driftOnOriginMain = execGit(["show", "origin/main:base-drift.txt"], { cwd: safeOutputsRepo, allowFailure: true }); + expect(driftOnOriginMain.status).toBe(0); + + // 6. Apply the bundle to the safe-outputs repo (bundle prereq = A, not B). const { applyBundleToBranch, rewriteBundleBranchAsSingleCommit } = require("./create_pull_request.cjs"); await applyBundleToBranch(bundlePath, branchName, `refs/heads/${branchName}`, createExecApi(safeOutputsRepo), "main"); - // 8. Call rewriteBundleBranchAsSingleCommit with bundleFilePath. + // 7. Call rewriteBundleBranchAsSingleCommit with the bundle path so it reads prereq A. await rewriteBundleBranchAsSingleCommit("main", createExecApi(safeOutputsRepo), bundlePath); - // 9 & 10. Verify the synthesized commit. - // - The commit tree must contain "agent-file.txt" (agent's actual change). - // - "base-drift.txt" must be present in the working tree (NOT deleted by the rewrite). - // - The diff relative to the commit's parent must NOT contain a deletion of "base-drift.txt". + // 8. Verify the synthesized commit. + // + // a) "agent-file.txt" must be in the working tree (agent's actual change was preserved). + // b) "base-drift.txt" must NOT be in the working tree — the rewrite was anchored at A + // (which predates the drift), so the feature branch tree never included it. + // c) The diff HEAD^..HEAD must show "agent-file.txt" as added. + // d) "base-drift.txt" must NOT appear in the diff at all — not deleted, not added. + // (With the naive soft-reset to origin/main=B, it would appear as "D base-drift.txt".) + // e) HEAD must have exactly one parent (linear, not a merge commit). const agentFilePresent = fs.existsSync(path.join(safeOutputsRepo, "agent-file.txt")); - const baseDriftPresent = fs.existsSync(path.join(safeOutputsRepo, "base-drift.txt")); + const baseDriftInWorkingTree = fs.existsSync(path.join(safeOutputsRepo, "base-drift.txt")); expect(agentFilePresent).toBe(true); - expect(baseDriftPresent).toBe(true); + // base-drift.txt belongs to origin/main (B), not to the feature branch anchored at A. + expect(baseDriftInWorkingTree).toBe(false); // The HEAD commit (linearized) should show "agent-file.txt" as added. const headStat = execGit(["show", "--stat", "HEAD"], { cwd: safeOutputsRepo }).stdout; expect(headStat).toContain("agent-file.txt"); - // The diff introduced by HEAD must NOT show base-drift.txt as deleted. + // The diff introduced by HEAD must NOT reference base-drift.txt at all. + // If the prereq-SHA path were bypassed and origin/main (B) were used instead, + // the diff would contain "D base-drift.txt" and this assertion would fail. const diffStat = execGit(["diff", "--name-status", "HEAD^", "HEAD"], { cwd: safeOutputsRepo }).stdout; - expect(diffStat).not.toMatch(/^D\s+base-drift\.txt/m); + expect(diffStat).not.toMatch(/base-drift\.txt/); // Verify the commit has exactly one parent (linearized, not a merge commit). const parentLine = execGit(["log", "-1", "--format=%P", "HEAD"], { cwd: safeOutputsRepo }).stdout.trim(); diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index 5e126f24299..62713a744de 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -735,6 +735,90 @@ describe("git_helpers.cjs", () => { }); }); + describe("hasMergeCommitsInRange", () => { + let tmpRepo; + + beforeEach(() => { + const { spawnSync } = require("child_process"); + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + + tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), "git-helpers-hasMerge-")); + const g = args => spawnSync("git", args, { cwd: tmpRepo, encoding: "utf8" }); + g(["init", "-b", "main"]); + g(["config", "user.name", "Test"]); + g(["config", "user.email", "test@test.com"]); + fs.writeFileSync(path.join(tmpRepo, "a.txt"), "a\n"); + g(["add", "a.txt"]); + g(["commit", "-m", "initial"]); + }); + + afterEach(() => { + const fs = require("fs"); + if (tmpRepo) { + fs.rmSync(tmpRepo, { recursive: true, force: true }); + tmpRepo = null; + } + }); + + it("should return false for empty or missing baseRef", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + expect(hasMergeCommitsInRange("", "HEAD")).toBe(false); + expect(hasMergeCommitsInRange(null, "HEAD")).toBe(false); + expect(hasMergeCommitsInRange(undefined, "HEAD")).toBe(false); + }); + + it("should return false for empty or missing headRef", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + expect(hasMergeCommitsInRange("origin/main", "")).toBe(false); + expect(hasMergeCommitsInRange("origin/main", null)).toBe(false); + expect(hasMergeCommitsInRange("origin/main", undefined)).toBe(false); + }); + + it("should return false when git command fails (unreachable refs or bad cwd)", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + // Non-existent cwd causes spawnSync to fail; the function should return false + // (detection failure → safe default = no merge commits). + const result = hasMergeCommitsInRange("origin/main", "HEAD", { cwd: "/nonexistent/path/that/does/not/exist" }); + expect(result).toBe(false); + }); + + it("should return false when range has no merge commits", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + + // Add a plain (non-merge) commit + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + fs.writeFileSync(path.join(tmpRepo, "b.txt"), "b\n"); + spawnSync("git", ["add", "b.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "plain commit"], { cwd: tmpRepo, encoding: "utf8" }); + + expect(hasMergeCommitsInRange(baseSha, "HEAD", { cwd: tmpRepo })).toBe(false); + }); + + it("should return true when range contains a merge commit", async () => { + const { hasMergeCommitsInRange } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + // Create a side branch and merge it back (creates merge commit on main) + spawnSync("git", ["checkout", "-b", "side", baseSha], { cwd: tmpRepo }); + fs.writeFileSync(path.join(tmpRepo, "c.txt"), "c\n"); + spawnSync("git", ["add", "c.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "side commit"], { cwd: tmpRepo, encoding: "utf8" }); + spawnSync("git", ["checkout", "main"], { cwd: tmpRepo }); + spawnSync("git", ["merge", "--no-ff", "side", "-m", "Merge side"], { cwd: tmpRepo, encoding: "utf8" }); + + expect(hasMergeCommitsInRange(baseSha, "HEAD", { cwd: tmpRepo })).toBe(true); + }); + }); + describe("getBundlePrerequisites", () => { const PREREQ_SHA = "172f87a830f57a29470efe7646d141069434a893"; const ANOTHER_SHA = "aabbccddee1122334455667788990011aabbccdd"; From 963873aba1ebd7a1e9d47cc614d908a3c5f4bcba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:02:50 +0000 Subject: [PATCH 4/5] chore: plan - add git integration tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/skills/agentic-workflows/SKILL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 6f24708a24e..6fb19019416 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -35,6 +35,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/evals.md` - `.github/aw/experiments.md` - `.github/aw/github-agentic-workflows.md` +- `.github/aw/github-mcp-server-pagination.md` - `.github/aw/github-mcp-server.md` - `.github/aw/instructions.md` - `.github/aw/linter-workflows.md` @@ -64,10 +65,12 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/subagents.md` - `.github/aw/syntax-agentic.md` - `.github/aw/syntax-core.md` +- `.github/aw/syntax-engine.md` - `.github/aw/syntax-tools-imports.md` - `.github/aw/syntax.md` - `.github/aw/test-coverage.md` - `.github/aw/test-expression.md` +- `.github/aw/token-optimization-caching-budgets.md` - `.github/aw/token-optimization.md` - `.github/aw/triggers.md` - `.github/aw/update-agentic-workflow.md` From 69ce248536ed568be8005fd9d141697b61394176 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:07:14 +0000 Subject: [PATCH 5/5] test: add git integration tests for getBundlePrerequisites and linearizeRangeAsCommit Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/git_helpers.test.cjs | 245 ++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index 62713a744de..4a23ca158b2 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -1101,4 +1101,249 @@ describe("git_helpers.cjs", () => { expect(err.cause).toBe(cause); }); }); + + describe("getBundlePrerequisites (integration with real git)", () => { + let tmpRepo; + + beforeEach(() => { + const { spawnSync } = require("child_process"); + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + + tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), "git-helpers-bundle-prereqs-")); + const g = args => spawnSync("git", args, { cwd: tmpRepo, encoding: "utf8" }); + g(["init", "-b", "main"]); + g(["config", "user.name", "Test"]); + g(["config", "user.email", "test@test.com"]); + fs.writeFileSync(path.join(tmpRepo, "a.txt"), "a\n"); + g(["add", "a.txt"]); + g(["commit", "-m", "initial"]); + }); + + afterEach(() => { + const fs = require("fs"); + if (tmpRepo) { + fs.rmSync(tmpRepo, { recursive: true, force: true }); + tmpRepo = null; + } + }); + + function makeExecApi() { + const { spawnSync } = require("child_process"); + return { + async getExecOutput(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0 && !options.ignoreReturnCode) { + throw new Error(result.stderr || result.stdout); + } + return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr }; + }, + }; + } + + it("extracts the prerequisite SHA from a real incremental bundle", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + const os = require("os"); + + // baseSha will become the bundle's prerequisite + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + spawnSync("git", ["checkout", "-b", "feature"], { cwd: tmpRepo }); + fs.writeFileSync(path.join(tmpRepo, "b.txt"), "b\n"); + spawnSync("git", ["add", "b.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "feature commit"], { cwd: tmpRepo }); + + // Incremental bundle: baseSha..HEAD — git records baseSha as a prerequisite + const bundlePath = path.join(os.tmpdir(), `git-helpers-prereqs-incr-${Date.now()}.bundle`); + try { + spawnSync("git", ["bundle", "create", bundlePath, `${baseSha}..HEAD`], { cwd: tmpRepo }); + + const prereqs = await getBundlePrerequisites(makeExecApi(), bundlePath); + + expect(prereqs).toHaveLength(1); + expect(prereqs[0]).toBe(baseSha); + } finally { + if (fs.existsSync(bundlePath)) fs.unlinkSync(bundlePath); + } + }); + + it("extracts prerequisites from a bundle created with a named range", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + const os = require("os"); + + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + // Two commits on top of baseSha + fs.writeFileSync(path.join(tmpRepo, "b.txt"), "b\n"); + spawnSync("git", ["add", "b.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "commit b"], { cwd: tmpRepo }); + fs.writeFileSync(path.join(tmpRepo, "c.txt"), "c\n"); + spawnSync("git", ["add", "c.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "commit c"], { cwd: tmpRepo }); + + const bundlePath = path.join(os.tmpdir(), `git-helpers-prereqs-range-${Date.now()}.bundle`); + try { + spawnSync("git", ["bundle", "create", bundlePath, `${baseSha}..HEAD`], { cwd: tmpRepo }); + + const prereqs = await getBundlePrerequisites(makeExecApi(), bundlePath); + + // The bundle must declare baseSha as its single prerequisite regardless + // of how many commits the bundle contains. + expect(prereqs).toHaveLength(1); + expect(prereqs[0]).toBe(baseSha); + } finally { + if (fs.existsSync(bundlePath)) fs.unlinkSync(bundlePath); + } + }); + + it("returns empty array for a self-contained bundle with no prerequisites", async () => { + const { getBundlePrerequisites } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + const os = require("os"); + + // Bundle the full history from the root — no prerequisites needed + const bundlePath = path.join(os.tmpdir(), `git-helpers-prereqs-full-${Date.now()}.bundle`); + try { + spawnSync("git", ["bundle", "create", bundlePath, "--all"], { cwd: tmpRepo }); + + const prereqs = await getBundlePrerequisites(makeExecApi(), bundlePath); + + expect(prereqs).toEqual([]); + } finally { + if (fs.existsSync(bundlePath)) fs.unlinkSync(bundlePath); + } + }); + }); + + describe("linearizeRangeAsCommit (integration with real git)", () => { + let tmpRepo; + + beforeEach(() => { + const { spawnSync } = require("child_process"); + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + + tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), "git-helpers-linearize-")); + const g = args => spawnSync("git", args, { cwd: tmpRepo, encoding: "utf8" }); + g(["init", "-b", "main"]); + g(["config", "user.name", "Test"]); + g(["config", "user.email", "test@test.com"]); + fs.writeFileSync(path.join(tmpRepo, "a.txt"), "a\n"); + g(["add", "a.txt"]); + g(["commit", "-m", "initial"]); + }); + + afterEach(() => { + const fs = require("fs"); + if (tmpRepo) { + fs.rmSync(tmpRepo, { recursive: true, force: true }); + tmpRepo = null; + } + }); + + function makeRealExecApi(cwd) { + const { spawnSync } = require("child_process"); + return { + async exec(command, args = []) { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.status; + }, + async getExecOutput(command, args = [], opts = {}) { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (result.status !== 0 && !opts.ignoreReturnCode) { + throw new Error(result.stderr || result.stdout); + } + return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr }; + }, + }; + } + + it("collapses multiple commits into a single commit on top of baseSha", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + fs.writeFileSync(path.join(tmpRepo, "b.txt"), "b\n"); + spawnSync("git", ["add", "b.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "commit b"], { cwd: tmpRepo }); + + fs.writeFileSync(path.join(tmpRepo, "c.txt"), "c\n"); + spawnSync("git", ["add", "c.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "commit c"], { cwd: tmpRepo }); + + const newSha = await linearizeRangeAsCommit(baseSha, "Squash: b and c", makeRealExecApi(tmpRepo)); + + const actualHead = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + expect(newSha).toBe(actualHead); + + // Exactly one commit above baseSha + const commitCount = Number(spawnSync("git", ["rev-list", "--count", `${baseSha}..HEAD`], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim()); + expect(commitCount).toBe(1); + + // Parent of new HEAD is the original baseSha + const parentSha = spawnSync("git", ["rev-parse", "HEAD^"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + expect(parentSha).toBe(baseSha); + + // Both files are present in the working tree + expect(fs.existsSync(path.join(tmpRepo, "b.txt"))).toBe(true); + expect(fs.existsSync(path.join(tmpRepo, "c.txt"))).toBe(true); + }); + + it("preserves the working tree contents of the agent's last commit after linearization", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + + const baseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + // Simulate agent creating a file, then modifying it in a second commit + fs.writeFileSync(path.join(tmpRepo, "work.txt"), "v1\n"); + spawnSync("git", ["add", "work.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "create work.txt v1"], { cwd: tmpRepo }); + + fs.writeFileSync(path.join(tmpRepo, "work.txt"), "v2\n"); + spawnSync("git", ["add", "work.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "update work.txt to v2"], { cwd: tmpRepo }); + + await linearizeRangeAsCommit(baseSha, "Squash agent work", makeRealExecApi(tmpRepo)); + + // The squashed commit must contain the final file content, not an intermediate state + expect(fs.readFileSync(path.join(tmpRepo, "work.txt"), "utf8")).toBe("v2\n"); + }); + + it("restores original HEAD when the baseRef does not exist", async () => { + const { linearizeRangeAsCommit } = await import("./git_helpers.cjs"); + const { spawnSync } = require("child_process"); + const path = require("path"); + const fs = require("fs"); + + // Add a commit so there is work to preserve + fs.writeFileSync(path.join(tmpRepo, "b.txt"), "b\n"); + spawnSync("git", ["add", "b.txt"], { cwd: tmpRepo }); + spawnSync("git", ["commit", "-m", "commit b"], { cwd: tmpRepo }); + + const headBeforeLinearize = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + + await expect(linearizeRangeAsCommit("nonexistent-ref-that-does-not-exist", "msg", makeRealExecApi(tmpRepo))).rejects.toThrow(/Failed to linearize/); + + // HEAD must be restored to the SHA it held before the failed call + const headAfter = spawnSync("git", ["rev-parse", "HEAD"], { cwd: tmpRepo, encoding: "utf8" }).stdout.trim(); + expect(headAfter).toBe(headBeforeLinearize); + }); + }); });