@@ -262,4 +262,177 @@ describe("create_pull_request bundle integration", () => {
262262 expect ( fs . readFileSync ( path . join ( targetRepo , "feature.txt" ) , "utf8" ) ) . toBe ( "feature branch commit\n" ) ;
263263 expect ( fs . readFileSync ( path . join ( targetRepo , "main.txt" ) , "utf8" ) ) . toBe ( "main branch commit\n" ) ;
264264 } ) ;
265+
266+ it ( "captures a push rejection error after applying a reconcile-spark diverged-history bundle" , async ( ) => {
267+ // ─── Why this test exists ────────────────────────────────────────────────
268+ //
269+ // The "reconcile-spark" chaos scenario exposed a gap in the
270+ // create_pull_request bundle path:
271+ //
272+ // 1. An agent works on a feature branch and makes commits.
273+ // 2. The main branch receives new commits while the agent is working
274+ // (history diverges).
275+ // 3. The agent reconciles by merging main into their branch, producing
276+ // a non-linear (merge-commit) history — this is the "reconcile-spark"
277+ // topology.
278+ // 4. A git bundle is created from that non-linear history.
279+ // 5. The bundle is applied to the safe-outputs runner via applyBundleToBranch.
280+ // 6. The subsequent push to origin fails because the remote branch has
281+ // also diverged (or a policy hook rejects the push).
282+ //
283+ // Previously, pushSignedCommits attempted a linear cherry-pick replay of
284+ // the commit range onto the current GraphQL parent. That path choked on
285+ // merge commits and produced a CONFLICT error, dropping the flow into the
286+ // fallback-issue path with no useful context.
287+ //
288+ // The fix adds a sanitized `pushFailureMessage` to the fallback issue body
289+ // so that manual recovery is deterministic. This integration test verifies:
290+ //
291+ // • applyBundleToBranch correctly imports a reconcile-spark merge topology
292+ // (merge commits are preserved, not flattened).
293+ // • A real git push to a diverged bare remote fails with an error — the
294+ // kind of raw error string that create_pull_request captures and sanitizes
295+ // before embedding in the fallback issue body.
296+ // • The raw error produced by git contains content that sanitization must
297+ // handle (the test injects an @-mention into the hook rejection message
298+ // to document the attack surface; sanitizeContent strips it in prod).
299+ //
300+ // ─────────────────────────────────────────────────────────────────────────
301+
302+ const branchName = "scratchpad/chaos/reconcile-spark" ;
303+
304+ // 1. Set up a bare "origin" repo and a working clone — this mimics the
305+ // relationship between GitHub and the safe-outputs runner checkout.
306+ const bareRemote = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "create-pr-reconcile-spark-bare-" ) ) ;
307+ const agentRepo = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "create-pr-reconcile-spark-agent-" ) ) ;
308+ const safeOutputsRepo = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "create-pr-reconcile-spark-so-" ) ) ;
309+ tempDirs . push ( bareRemote , agentRepo , safeOutputsRepo ) ;
310+
311+ // Initialize the bare remote and push a first commit onto main.
312+ // Use -b main so we never need to run git symbolic-ref inside the bare repo
313+ // (git 2.36+ restricts bare-repo commands unless safe.bareRepository=all).
314+ execGit ( [ "init" , "--bare" , "-b" , "main" ] , { cwd : bareRemote } ) ;
315+ execGit ( [ "clone" , bareRemote , "." ] , { cwd : agentRepo } ) ;
316+ execGit ( [ "config" , "user.name" , "Agent" ] , { cwd : agentRepo } ) ;
317+ execGit ( [ "config" , "user.email" , "agent@example.com" ] , { cwd : agentRepo } ) ;
318+
319+ fs . writeFileSync ( path . join ( agentRepo , "README.md" ) , "# Chaos scenario\n" ) ;
320+ execGit ( [ "add" , "README.md" ] , { cwd : agentRepo } ) ;
321+ execGit ( [ "commit" , "-m" , "Initial commit on main" ] , { cwd : agentRepo } ) ;
322+ execGit ( [ "branch" , "-M" , "main" ] , { cwd : agentRepo } ) ;
323+ execGit ( [ "push" , "-u" , "origin" , "main" ] , { cwd : agentRepo } ) ;
324+ core . info ( "[reconcile-spark] bare remote initialized with main" ) ;
325+
326+ // 2. Agent creates the feature branch and makes a first content commit.
327+ execGit ( [ "checkout" , "-b" , branchName ] , { cwd : agentRepo } ) ;
328+ fs . writeFileSync ( path . join ( agentRepo , "notes.md" ) , "# Agent notes\n" ) ;
329+ execGit ( [ "add" , "notes.md" ] , { cwd : agentRepo } ) ;
330+ execGit ( [ "commit" , "-m" , "feat: add initial notes" ] , { cwd : agentRepo } ) ;
331+ core . info ( "[reconcile-spark] agent made first commit on feature branch" ) ;
332+
333+ // 3. While the agent is working, a collaborator pushes a commit directly
334+ // to main on the remote. The agent's local main diverges from origin/main.
335+ // We simulate this by making a commit on main in the agent repo and then
336+ // force-pushing it so the bare remote has a commit the agent branch doesn't.
337+ execGit ( [ "checkout" , "main" ] , { cwd : agentRepo } ) ;
338+ fs . writeFileSync ( path . join ( agentRepo , "collab.md" ) , "# Collaborator change\n" ) ;
339+ execGit ( [ "add" , "collab.md" ] , { cwd : agentRepo } ) ;
340+ execGit ( [ "commit" , "-m" , "collab: landing from main" ] , { cwd : agentRepo } ) ;
341+ execGit ( [ "push" , "origin" , "main" ] , { cwd : agentRepo } ) ;
342+ core . info ( "[reconcile-spark] collaborator commit pushed to origin/main — histories now diverged" ) ;
343+
344+ // 4. Agent reconciles: merges the updated main back into the feature branch.
345+ // This creates the "reconcile-spark" non-linear merge commit.
346+ execGit ( [ "checkout" , branchName ] , { cwd : agentRepo } ) ;
347+ execGit ( [ "merge" , "--no-ff" , "main" , "-m" , "reconcile: merge main into feature" ] , { cwd : agentRepo } ) ;
348+ core . info ( "[reconcile-spark] merge commit created — non-linear history established" ) ;
349+
350+ // Add one more commit after the reconcile merge to ensure the bundle tip is
351+ // beyond the merge (the pathological shape that the original linear-replay
352+ // path could not handle: a non-empty range starting with a merge parent).
353+ fs . writeFileSync ( path . join ( agentRepo , "notes.md" ) , "# Agent notes\n\nPost-reconcile edit\n" ) ;
354+ execGit ( [ "commit" , "-am" , "feat: post-reconcile update" ] , { cwd : agentRepo } ) ;
355+ const expectedBundleTip = execGit ( [ "rev-parse" , "HEAD" ] , { cwd : agentRepo } ) . stdout . trim ( ) ;
356+ const mergeCommitCount = Number ( execGit ( [ "rev-list" , "--count" , "--merges" , `main..${ branchName } ` ] , { cwd : agentRepo } ) . stdout . trim ( ) ) ;
357+ core . info ( `[reconcile-spark] feature branch tip: ${ expectedBundleTip . slice ( 0 , 8 ) } , merge commits in range: ${ mergeCommitCount } ` ) ;
358+ // Confirm the branch contains at least one merge commit — the test topology
359+ // is only valid when the reconcile merge is present.
360+ expect ( mergeCommitCount ) . toBeGreaterThanOrEqual ( 1 ) ;
361+
362+ // 5. Create a git bundle from the reconcile-spark branch. The bundle
363+ // includes the full history so that the safe-outputs runner can apply it
364+ // without access to origin.
365+ const bundlePath = path . join ( agentRepo , "reconcile-spark.bundle" ) ;
366+ execGit ( [ "bundle" , "create" , bundlePath , `refs/heads/${ branchName } ` ] , { cwd : agentRepo } ) ;
367+ core . info ( `[reconcile-spark] bundle created: ${ bundlePath } ` ) ;
368+
369+ // 6. Set up the safe-outputs runner checkout — a fresh clone of origin/main.
370+ // This is the state the runner is in before it applies the agent's bundle.
371+ execGit ( [ "clone" , bareRemote , "." ] , { cwd : safeOutputsRepo } ) ;
372+ execGit ( [ "config" , "user.name" , "Runner" ] , { cwd : safeOutputsRepo } ) ;
373+ execGit ( [ "config" , "user.email" , "runner@example.com" ] , { cwd : safeOutputsRepo } ) ;
374+ execGit ( [ "checkout" , "-b" , branchName ] , { cwd : safeOutputsRepo } ) ;
375+ core . info ( "[reconcile-spark] safe-outputs runner checkout ready" ) ;
376+
377+ // 7. Apply the bundle via applyBundleToBranch — the function under test.
378+ const { applyBundleToBranch } = require ( "./create_pull_request.cjs" ) ;
379+ await applyBundleToBranch ( bundlePath , branchName , `refs/heads/${ branchName } ` , createExecApi ( safeOutputsRepo ) ) ;
380+
381+ // Verify that the merge-commit topology survived the bundle round-trip.
382+ const appliedTip = execGit ( [ "rev-parse" , "HEAD" ] , { cwd : safeOutputsRepo } ) . stdout . trim ( ) ;
383+ const appliedMergeCount = Number ( execGit ( [ "rev-list" , "--count" , "--merges" , "HEAD" ] , { cwd : safeOutputsRepo } ) . stdout . trim ( ) ) ;
384+ core . info ( `[reconcile-spark] bundle applied; tip: ${ appliedTip . slice ( 0 , 8 ) } , merges: ${ appliedMergeCount } ` ) ;
385+ expect ( appliedTip ) . toBe ( expectedBundleTip ) ;
386+ expect ( appliedMergeCount ) . toBeGreaterThanOrEqual ( 1 ) ;
387+ expect ( fs . readFileSync ( path . join ( safeOutputsRepo , "notes.md" ) , "utf8" ) ) . toContain ( "Post-reconcile edit" ) ;
388+ expect ( fs . readFileSync ( path . join ( safeOutputsRepo , "collab.md" ) , "utf8" ) ) . toBe ( "# Collaborator change\n" ) ;
389+
390+ // 8. Simulate a push rejection.
391+ //
392+ // In the reconcile-spark scenario the push fails because:
393+ // (a) The remote branch may not accept non-fast-forward pushes, or
394+ // (b) A policy hook (e.g. "require signed commits") rejects the push.
395+ //
396+ // We reproduce (b) by installing a pre-receive hook that emits a message
397+ // containing an @-mention — deliberately chosen to document the attack
398+ // surface that sanitizeContent must neutralise before the error is
399+ // embedded in the fallback issue body.
400+ const hooksDir = path . join ( bareRemote , "hooks" ) ;
401+ fs . mkdirSync ( hooksDir , { recursive : true } ) ;
402+ const hookPath = path . join ( hooksDir , "pre-receive" ) ;
403+ // The @org /team mention in the hook message is intentional: it demonstrates
404+ // the class of content (@ mentions, URLs, closing keywords) that can appear
405+ // in raw git push errors and must be stripped by sanitizeContent before the
406+ // message is interpolated into the fallback issue markdown body.
407+ fs . writeFileSync (
408+ hookPath ,
409+ [
410+ "#!/bin/sh" ,
411+ "echo 'remote: error: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (merge commit detected)' >&2" ,
412+ "echo 'remote: - CONFLICT (content): Merge conflict in scratchpad/chaos/reconcile-spark.md' >&2" ,
413+ "echo 'remote: - See @org/team for recovery steps.' >&2" ,
414+ "exit 1" ,
415+ ] . join ( "\n" ) + "\n"
416+ ) ;
417+ fs . chmodSync ( hookPath , "0755" ) ;
418+
419+ // Attempt the real git push — this MUST fail so we can capture the error.
420+ const pushResult = execGit ( [ "push" , "origin" , branchName ] , { cwd : safeOutputsRepo , allowFailure : true } ) ;
421+ core . info ( `[reconcile-spark] push exit code: ${ pushResult . status } ` ) ;
422+ core . info ( `[reconcile-spark] push stderr: ${ pushResult . stderr . trim ( ) } ` ) ;
423+ expect ( pushResult . status ) . not . toBe ( 0 ) ;
424+
425+ // 9. Verify the raw push error contains the content that create_pull_request
426+ // must sanitize and embed in the fallback issue body.
427+ // This is the value that will be passed through:
428+ // sanitizeContent(neutralizeClosingKeywordsForIssueBody(pushError.message), ...)
429+ // before being written into the fallback issue markdown.
430+ const rawPushError = pushResult . stderr . trim ( ) ;
431+ expect ( rawPushError ) . toContain ( "merge commit detected" ) ;
432+ expect ( rawPushError ) . toContain ( "CONFLICT" ) ;
433+ // The @-mention in the hook output confirms that unsanitized error text can
434+ // contain @ tokens — sanitizeContent replaces them with safe equivalents.
435+ expect ( rawPushError ) . toContain ( "@org/team" ) ;
436+ core . info ( "[reconcile-spark] push error captured — ready for sanitization and fallback issue embedding" ) ;
437+ } ) ;
265438} ) ;
0 commit comments