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` diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 373245a58fd..b91abbb1937 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,54 @@ 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) { + // 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 { + 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 +1717,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 +2783,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..5a12a14b97e 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,126 @@ 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 ──────────────────────────────────────────────── + // + // 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. + // + // 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. + // + // 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-")); + 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 A. + 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 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 (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. 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 }); + + // 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")); + + // 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. + + // 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"); + + // 7. Call rewriteBundleBranchAsSingleCommit with the bundle path so it reads prereq A. + await rewriteBundleBranchAsSingleCommit("main", createExecApi(safeOutputsRepo), bundlePath); + + // 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 baseDriftInWorkingTree = fs.existsSync(path.join(safeOutputsRepo, "base-drift.txt")); + + expect(agentFilePresent).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 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(/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(); + 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..4a23ca158b2 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -735,6 +735,196 @@ 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"; + + 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"; @@ -911,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); + }); + }); });