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
8 changes: 4 additions & 4 deletions .github/workflows/daily-arxiv-researcher.lock.yml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .github/workflows/smoke-checkout-pr-dispatch.lock.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,10 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile
}

core.warning(`Rewriting bundled commits to a single linear commit for signed push compatibility (base: ${baseRef})`);
const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi, { excludedFiles: options.excludedFiles });
const newHead = await linearizeRangeAsCommit(baseRef, commitHeadline, execApi, {
excludedFiles: options.excludedFiles,
rebaseOnto: fallbackBaseRef,
});
core.info(`Bundle rewrite completed (new HEAD: ${newHead})`);
}

Expand Down
980 changes: 972 additions & 8 deletions actions/setup/js/create_pull_request_bundle_integration.test.cjs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
*
* Both regression tests assert: file_set(patch) == file_set(pushed_commit).
*
* The non-rewrite regression now passes with the filtered-bundle fix in place.
* The merge-commit rewrite regression remains `it.fails(...)` until the rewrite
* path is updated to preserve parity across base-branch drift as well.
* Both regressions now pass: filtered bundle synthesis keeps excluded files out
* of the pushed commit, and the merge-commit rewrite path linearizes against
* the bundle prerequisite so base-branch drift is not absorbed into the result.
*/

import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
Expand Down Expand Up @@ -297,11 +297,10 @@ describe("create_pull_request – validation/push file-set parity", () => {
* taken by `rewriteBundleBranchAsSingleCommit` inside `create_pull_request`
* when signed push refuses merge-commit topology).
*
* When base drift exists, the rewrite path still synthesizes a commit whose
* file set does not match the validated patch. Keep this as `it.fails(...)`
* until the rewrite/base-drift fix lands.
* When base drift exists, the rewritten commit must still match the validated
* patch file set rather than reverting or absorbing unrelated base changes.
*/
it.fails("merge-commit rewrite path: rewritten commit file set matches validated patch", async () => {
it("merge-commit rewrite path: rewritten commit file set matches validated patch", async () => {
const { generateGitPatch } = require("./generate_git_patch.cjs");
const { generateGitBundle } = require("./generate_git_bundle.cjs");
const { applyBundleToBranch, rewriteBundleBranchAsSingleCommit } = require("./create_pull_request.cjs");
Expand Down Expand Up @@ -390,8 +389,6 @@ describe("create_pull_request – validation/push file-set parity", () => {
});

// REGRESSION ASSERTION: the rewritten commit must contain the same files as the patch.
// Today this still fails under base drift, which is why the test is marked
// `it.fails(...)` until the remaining rewrite-path fix lands.
const fromPush = fileListFromPushedCommit(safeOutputsRepo, "origin/main");
expect(fromPush, "rewritten commit should match patch file set after rewrite under base drift").toEqual(fromPatch);
});
Expand Down
46 changes: 44 additions & 2 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -712,14 +712,17 @@ async function backfillCommitObjects(execApi, commitShas, options = {}) {
* invocation (e.g. `["--allow-empty", "--no-verify"]`).
* @param {string[]} [opts.excludedFiles] - Paths that should be removed from the staged rewrite
* before creating the linearized commit.
* @param {string} [opts.rebaseOnto] - Optional ref to replay the synthesized commit onto after
* it has been linearized relative to `baseRef`. Use this when `baseRef` captures the agent's
* actual change base but the resulting single commit must sit on a newer branch tip.
* @param {number} [opts.maxCommits] - Override the implausibility threshold (default
* `SHALLOW_RANGE_MAX_COMMITS`). Set to `Infinity` to disable the shallow guard.
* @returns {Promise<string>} The new HEAD SHA after the rewrite.
* @throws {Error} If the soft reset, staged-changes validation, or recommit fails, or if a
* shallow checkout produces an implausible commit range.
*/
async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}) {
const { gitOpts, commitFlags = [], excludedFiles = [], maxCommits = SHALLOW_RANGE_MAX_COMMITS } = opts;
const { gitOpts, commitFlags = [], excludedFiles = [], rebaseOnto, maxCommits = SHALLOW_RANGE_MAX_COMMITS } = opts;
// Spread gitOpts into exec calls only when it is explicitly provided — passing
// `undefined` as a third argument changes the arity seen by mocks in tests.
const execArgs = gitOpts !== undefined ? [gitOpts] : [];
Expand Down Expand Up @@ -762,23 +765,62 @@ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}
throw new Error("Could not resolve current HEAD before linearizing range");
}

// Track whether a `git rebase` call was started so the catch block can distinguish
// "rebase in progress" (needs --abort) from "pre-rebase failure" (needs reset only).
let rebaseStarted = false;
try {
await execApi.exec("git", ["reset", "--soft", baseRef], ...execArgs);
if (Array.isArray(excludedFiles) && excludedFiles.length > 0) {
const { stdout: excludedStagedOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only", "--", ...excludedFiles], ...execArgs);
if (excludedStagedOut.trim()) {
await execApi.exec("git", ["checkout", "HEAD", "--", ...excludedFiles], ...execArgs);
// Use `git reset HEAD -- <files>` rather than `git checkout HEAD -- <files>`.
// For newly-added excluded files (not present in HEAD), `checkout` fails with
// "pathspec did not match any file(s) known to git". `reset HEAD --` handles
// both cases: removes new files from the index and restores modified files to
// the HEAD version, without touching the working tree.
await execApi.exec("git", ["reset", "HEAD", "--", ...excludedFiles], ...execArgs);
// For excluded files that were modifications (not new additions), the working tree
// still has the agent's version while the index was just restored to HEAD. This
// creates an unstaged change that would cause `git rebase --onto` to fail.
// Detect any such unstaged changes among the excluded files and restore them from
// the index so the working tree stays in sync before the commit and rebase steps.
const { stdout: modifiedExcludedOut } = await execApi.getExecOutput("git", ["diff", "--name-only", "--", ...excludedFiles], ...execArgs);
const modifiedExcluded = modifiedExcludedOut.trim().split("\n").filter(Boolean);
if (modifiedExcluded.length > 0) {
await execApi.exec("git", ["checkout", "--", ...modifiedExcluded], ...execArgs);
}
}
}
const { stdout: stagedFilesOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only"], ...execArgs);
if (!stagedFilesOut.trim()) {
throw new Error(`No staged changes found after soft reset to ${baseRef}. ` + `The commit range may contain only no-op or empty commits. ` + `Ensure your commits contain actual file changes before pushing.`);
}
await execApi.exec("git", ["commit", ...commitFlags, "-m", commitMessage], ...execArgs);
if (typeof rebaseOnto === "string" && rebaseOnto.trim() && rebaseOnto.trim() !== baseRef.trim()) {
rebaseStarted = true;
await execApi.exec("git", ["rebase", "--onto", rebaseOnto.trim(), baseRef, "HEAD"], ...execArgs);
Comment thread
github-actions[bot] marked this conversation as resolved.
rebaseStarted = false;
// Guard: if the rebase silently dropped the commit (became empty relative to rebaseOnto),
// the agent's changes are lost. Detect and fail loudly rather than pushing an empty diff.
const { stdout: diffOut } = await execApi.getExecOutput("git", ["diff", "--name-only", rebaseOnto.trim(), "HEAD"], ...execArgs);
if (!diffOut.trim()) {
throw new Error(`Rebase onto ${rebaseOnto} produced no changes; the synthesized commit was dropped as empty`);
}
}
Comment thread
github-actions[bot] marked this conversation as resolved.
Comment thread
github-actions[bot] marked this conversation as resolved.
const { stdout: newHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"], ...execArgs);
return newHeadOut.trim();
} catch (rewriteError) {
Comment thread
github-actions[bot] marked this conversation as resolved.
try {
if (rebaseStarted) {
// A rebase was in progress when the error occurred; abort it to restore the repo to its
// pre-rebase state before the hard reset below finishes the rollback.
try {
await execApi.exec("git", ["rebase", "--abort"], ...execArgs);
} catch (abortError) {
// --abort failed while a rebase was genuinely in progress — repo may be in a dirty state.
core.error(`linearizeRangeAsCommit: rebase --abort also failed: ${getErrorMessage(abortError)}`);
}
}
await execApi.exec("git", ["reset", "--hard", originalHead], ...execArgs);
core.warning(`linearizeRangeAsCommit: rewrite failed; restored original HEAD ${originalHead}`);
} catch (restoreError) {
Expand Down
84 changes: 84 additions & 0 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,7 @@
}
// Either path (shallow→early false, or non-shallow→no merge commits found→false)
// yields false for a linear single-commit range.
expect(result).toBe(false);

Check failure on line 1395 in actions/setup/js/git_helpers.test.cjs

View workflow job for this annotation

GitHub Actions / impacted-js-tests

git_helpers.test.cjs > git_helpers.cjs > hasMergeCommitsInRange > should respect the shallow status of the current repo when range exceeds threshold

AssertionError: expected true to be false // Object.is equality - Expected + Received - false + true ❯ git_helpers.test.cjs:1395:22
void isShallow; // used above to document expected behavior
});
});
Expand Down Expand Up @@ -1620,6 +1620,90 @@
expect(fs.existsSync(path.join(repoDir, "r.txt"))).toBe(true);
});

it("can replay the synthesized commit onto a newer origin/main tip without reverting base drift", async () => {
const { linearizeRangeAsCommit } = requireLocal("./git_helpers.cjs");

const originalBaseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();
const originalBranch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();

try {
execSync("git checkout -b feature-drift", { cwd: repoDir, stdio: "pipe" });
addCommit(repoDir, "agent.txt", "agent\n", "add agent change");

const collaboratorDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-collab-"));
try {
execSync(`git clone ${remoteDir} ${collaboratorDir}`, { stdio: "pipe" });
execSync('git config user.email "test@example.com"', { cwd: collaboratorDir, stdio: "pipe" });
execSync('git config user.name "Test User"', { cwd: collaboratorDir, stdio: "pipe" });
addCommit(collaboratorDir, "drift.txt", "drift\n", "base drift");
execSync("git push origin main", { cwd: collaboratorDir, stdio: "pipe" });
} finally {
fs.rmSync(collaboratorDir, { recursive: true, force: true });
}

execSync("git fetch origin main:refs/remotes/origin/main", { cwd: repoDir, stdio: "pipe" });

await linearizeRangeAsCommit(originalBaseSha, "Squash agent change", makeRealExecApi(repoDir), {
gitOpts: { cwd: repoDir },
rebaseOnto: "origin/main",
});

const diffNames = spawnSync("git", ["diff", "--name-only", "origin/main..HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim().split("\n").filter(Boolean);
expect(diffNames).toEqual(["agent.txt"]);
expect(fs.readFileSync(path.join(repoDir, "drift.txt"), "utf8")).toBe("drift\n");

const parentSha = spawnSync("git", ["rev-parse", "HEAD^"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();
const currentOriginMain = spawnSync("git", ["rev-parse", "origin/main"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();
expect(parentSha).toBe(currentOriginMain);
} finally {
execSync(`git checkout ${originalBranch}`, { cwd: repoDir, stdio: "pipe" });
}
});

it("aborts cleanly and throws when rebase --onto encounters a conflict", async () => {
const { linearizeRangeAsCommit } = requireLocal("./git_helpers.cjs");

// Setup: main has conflict.txt at a known base.
addCommit(repoDir, "conflict.txt", "original\n", "add conflict.txt");
execSync("git push origin main", { cwd: repoDir, stdio: "pipe" });
const originalBaseSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();

// Agent branch: modifies the same file.
execSync("git checkout -b feature-conflict", { cwd: repoDir, stdio: "pipe" });
addCommit(repoDir, "conflict.txt", "agent-change\n", "agent modifies conflict.txt");
const agentHeadSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();

// Collaborator pushes a conflicting change to main (same file, incompatible content).
const collaboratorDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-conflict-collab-"));
try {
execSync(`git clone ${remoteDir} ${collaboratorDir}`, { stdio: "pipe" });
execSync('git config user.email "test@example.com"', { cwd: collaboratorDir, stdio: "pipe" });
execSync('git config user.name "Test User"', { cwd: collaboratorDir, stdio: "pipe" });
addCommit(collaboratorDir, "conflict.txt", "base-change\n", "base drift modifies conflict.txt");
execSync("git push origin main", { cwd: collaboratorDir, stdio: "pipe" });
} finally {
fs.rmSync(collaboratorDir, { recursive: true, force: true });
}

execSync("git fetch origin main:refs/remotes/origin/main", { cwd: repoDir, stdio: "pipe" });

// Rebase will conflict: the squashed diff touches the same file as origin/main.
await expect(
linearizeRangeAsCommit(originalBaseSha, "Squash agent change", makeRealExecApi(repoDir), {
gitOpts: { cwd: repoDir },
rebaseOnto: "origin/main",
})
).rejects.toThrow(/Failed to linearize/);

// Repo must be in a clean state — no rebase in progress after the abort.
const rebaseHeadExists = fs.existsSync(path.join(repoDir, ".git", "REBASE_HEAD"));
expect(rebaseHeadExists).toBe(false);

// HEAD must be restored to the pre-linearize state (agent's commit, not the squash).
const headAfter = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8" }).stdout.trim();
expect(headAfter).toBe(agentHeadSha);
});

it("throws before any git state mutation for a shallow+implausible range", async () => {
const shallowDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-helpers-shallow-lin-"));
try {
Expand Down
Loading