@@ -553,8 +553,12 @@ export const GithubRunCommand = cmd({
553553 const branch = await checkoutNewBranch ( branchPrefix )
554554 const head = ( await $ `git rev-parse HEAD` ) . stdout . toString ( ) . trim ( )
555555 const response = await chat ( userPrompt , promptFiles )
556- const { dirty, uncommittedChanges } = await branchIsDirty ( head )
557- if ( dirty ) {
556+ const { dirty, uncommittedChanges, switched } = await branchIsDirty ( head , branch )
557+ if ( switched ) {
558+ // Agent switched branches (likely created its own branch/PR)
559+ console . log ( "Agent managed its own branch, skipping infrastructure push/PR" )
560+ console . log ( "Response:" , response )
561+ } else if ( dirty ) {
558562 const summary = await summarize ( response )
559563 // workflow_dispatch has an actor for co-author attribution, schedule does not
560564 await pushToNewBranch ( summary , branch , uncommittedChanges , isScheduleEvent )
@@ -565,7 +569,11 @@ export const GithubRunCommand = cmd({
565569 summary ,
566570 `${ response } \n\nTriggered by ${ triggerType } ${ footer ( { image : true } ) } ` ,
567571 )
568- console . log ( `Created PR #${ pr } ` )
572+ if ( pr ) {
573+ console . log ( `Created PR #${ pr } ` )
574+ } else {
575+ console . log ( "Skipped PR creation (no new commits)" )
576+ }
569577 } else {
570578 console . log ( "Response:" , response )
571579 }
@@ -580,8 +588,11 @@ export const GithubRunCommand = cmd({
580588 const head = ( await $ `git rev-parse HEAD` ) . stdout . toString ( ) . trim ( )
581589 const dataPrompt = buildPromptDataForPR ( prData )
582590 const response = await chat ( `${ userPrompt } \n\n${ dataPrompt } ` , promptFiles )
583- const { dirty, uncommittedChanges } = await branchIsDirty ( head )
584- if ( dirty ) {
591+ const { dirty, uncommittedChanges, switched } = await branchIsDirty ( head , prData . headRefName )
592+ if ( switched ) {
593+ console . log ( "Agent managed its own branch, skipping infrastructure push" )
594+ }
595+ if ( dirty && ! switched ) {
585596 const summary = await summarize ( response )
586597 await pushToLocalBranch ( summary , uncommittedChanges )
587598 }
@@ -591,12 +602,15 @@ export const GithubRunCommand = cmd({
591602 }
592603 // Fork PR
593604 else {
594- await checkoutForkBranch ( prData )
605+ const forkBranch = await checkoutForkBranch ( prData )
595606 const head = ( await $ `git rev-parse HEAD` ) . stdout . toString ( ) . trim ( )
596607 const dataPrompt = buildPromptDataForPR ( prData )
597608 const response = await chat ( `${ userPrompt } \n\n${ dataPrompt } ` , promptFiles )
598- const { dirty, uncommittedChanges } = await branchIsDirty ( head )
599- if ( dirty ) {
609+ const { dirty, uncommittedChanges, switched } = await branchIsDirty ( head , forkBranch )
610+ if ( switched ) {
611+ console . log ( "Agent managed its own branch, skipping infrastructure push" )
612+ }
613+ if ( dirty && ! switched ) {
600614 const summary = await summarize ( response )
601615 await pushToForkBranch ( summary , prData , uncommittedChanges )
602616 }
@@ -612,8 +626,13 @@ export const GithubRunCommand = cmd({
612626 const issueData = await fetchIssue ( )
613627 const dataPrompt = buildPromptDataForIssue ( issueData )
614628 const response = await chat ( `${ userPrompt } \n\n${ dataPrompt } ` , promptFiles )
615- const { dirty, uncommittedChanges } = await branchIsDirty ( head )
616- if ( dirty ) {
629+ const { dirty, uncommittedChanges, switched } = await branchIsDirty ( head , branch )
630+ if ( switched ) {
631+ // Agent switched branches (likely created its own branch/PR).
632+ // Don't push the stale infrastructure branch — just comment.
633+ await createComment ( `${ response } ${ footer ( { image : true } ) } ` )
634+ await removeReaction ( commentType )
635+ } else if ( dirty ) {
617636 const summary = await summarize ( response )
618637 await pushToNewBranch ( summary , branch , uncommittedChanges , false )
619638 const pr = await createPR (
@@ -622,7 +641,11 @@ export const GithubRunCommand = cmd({
622641 summary ,
623642 `${ response } \n\nCloses #${ issueId } ${ footer ( { image : true } ) } ` ,
624643 )
625- await createComment ( `Created PR #${ pr } ${ footer ( { image : true } ) } ` )
644+ if ( pr ) {
645+ await createComment ( `Created PR #${ pr } ${ footer ( { image : true } ) } ` )
646+ } else {
647+ await createComment ( `${ response } ${ footer ( { image : true } ) } ` )
648+ }
626649 await removeReaction ( commentType )
627650 } else {
628651 await createComment ( `${ response } ${ footer ( { image : true } ) } ` )
@@ -1068,6 +1091,7 @@ export const GithubRunCommand = cmd({
10681091 await $ `git remote add fork https://github.com/${ pr . headRepository . nameWithOwner } .git`
10691092 await $ `git fetch fork --depth=${ depth } ${ remoteBranch } `
10701093 await $ `git checkout -b ${ localBranch } fork/${ remoteBranch } `
1094+ return localBranch
10711095 }
10721096
10731097 function generateBranchName ( type : "issue" | "pr" | "schedule" | "dispatch" ) {
@@ -1125,21 +1149,42 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
11251149 await $ `git push fork HEAD:${ remoteBranch } `
11261150 }
11271151
1128- async function branchIsDirty ( originalHead : string ) {
1152+ async function branchIsDirty ( originalHead : string , expectedBranch : string ) {
11291153 console . log ( "Checking if branch is dirty..." )
1154+ // Detect if the agent switched branches during chat (e.g. created
1155+ // its own branch, committed, and possibly pushed/created a PR).
1156+ const current = ( await $ `git rev-parse --abbrev-ref HEAD` ) . stdout . toString ( ) . trim ( )
1157+ if ( current !== expectedBranch ) {
1158+ console . log ( `Branch changed during chat: expected ${ expectedBranch } , now on ${ current } ` )
1159+ return { dirty : true , uncommittedChanges : false , switched : true }
1160+ }
1161+
11301162 const ret = await $ `git status --porcelain`
11311163 const status = ret . stdout . toString ( ) . trim ( )
11321164 if ( status . length > 0 ) {
1133- return {
1134- dirty : true ,
1135- uncommittedChanges : true ,
1136- }
1165+ return { dirty : true , uncommittedChanges : true , switched : false }
11371166 }
1138- const head = await $ `git rev-parse HEAD`
1167+ const head = ( await $ `git rev-parse HEAD` ) . stdout . toString ( ) . trim ( )
11391168 return {
1140- dirty : head . stdout . toString ( ) . trim ( ) !== originalHead ,
1169+ dirty : head !== originalHead ,
11411170 uncommittedChanges : false ,
1171+ switched : false ,
1172+ }
1173+ }
1174+
1175+ // Verify commits exist between base ref and a branch using rev-list.
1176+ // Falls back to fetching from origin when local refs are missing
1177+ // (common in shallow clones from actions/checkout).
1178+ async function hasNewCommits ( base : string , head : string ) {
1179+ const result = await $ `git rev-list --count ${ base } ..${ head } ` . nothrow ( )
1180+ if ( result . exitCode !== 0 ) {
1181+ console . log ( `rev-list failed, fetching origin/${ base } ...` )
1182+ await $ `git fetch origin ${ base } --depth=1` . nothrow ( )
1183+ const retry = await $ `git rev-list --count origin/${ base } ..${ head } ` . nothrow ( )
1184+ if ( retry . exitCode !== 0 ) return true // assume dirty if we can't tell
1185+ return parseInt ( retry . stdout . toString ( ) . trim ( ) ) > 0
11421186 }
1187+ return parseInt ( result . stdout . toString ( ) . trim ( ) ) > 0
11431188 }
11441189
11451190 async function assertPermissions ( ) {
@@ -1261,7 +1306,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
12611306 } )
12621307 }
12631308
1264- async function createPR ( base : string , branch : string , title : string , body : string ) {
1309+ async function createPR ( base : string , branch : string , title : string , body : string ) : Promise < number | null > {
12651310 console . log ( "Creating pull request..." )
12661311
12671312 // Check if an open PR already exists for this head→base combination
@@ -1286,17 +1331,36 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
12861331 console . log ( `Failed to check for existing PR: ${ e } ` )
12871332 }
12881333
1289- const pr = await withRetry ( ( ) =>
1290- octoRest . rest . pulls . create ( {
1291- owner,
1292- repo,
1293- head : branch ,
1294- base,
1295- title,
1296- body,
1297- } ) ,
1298- )
1299- return pr . data . number
1334+ // Verify there are commits between base and head before creating the PR.
1335+ // In shallow clones, the branch can appear dirty but share the same
1336+ // commit as the base, causing a 422 from GitHub.
1337+ if ( ! ( await hasNewCommits ( base , branch ) ) ) {
1338+ console . log ( `No commits between ${ base } and ${ branch } , skipping PR creation` )
1339+ return null
1340+ }
1341+
1342+ try {
1343+ const pr = await withRetry ( ( ) =>
1344+ octoRest . rest . pulls . create ( {
1345+ owner,
1346+ repo,
1347+ head : branch ,
1348+ base,
1349+ title,
1350+ body,
1351+ } ) ,
1352+ )
1353+ return pr . data . number
1354+ } catch ( e : unknown ) {
1355+ // Handle "No commits between X and Y" validation error from GitHub.
1356+ // This can happen when the branch was pushed but has no new commits
1357+ // relative to the base (e.g. shallow clone edge cases).
1358+ if ( e instanceof Error && e . message . includes ( "No commits between" ) ) {
1359+ console . log ( `GitHub rejected PR: ${ e . message } ` )
1360+ return null
1361+ }
1362+ throw e
1363+ }
13001364 }
13011365
13021366 async function withRetry < T > ( fn : ( ) => Promise < T > , retries = 1 , delayMs = 5000 ) : Promise < T > {
0 commit comments