1- import { GitHub } from "@array/core" ;
1+ import { GitHub , type PRStatus } from "@array/core" ;
22import { cyan , dim , formatError , formatSuccess , yellow } from "../utils/output" ;
33import { createJJ , unwrap } from "../utils/run" ;
44
@@ -8,6 +8,12 @@ interface MergeFlags {
88 merge ?: boolean ;
99}
1010
11+ interface PRToMerge {
12+ pr : PRStatus ;
13+ bookmarkName : string ;
14+ changeId : string | null ;
15+ }
16+
1117export async function merge ( flags : MergeFlags = { } ) : Promise < void > {
1218 const jj = createJJ ( ) ;
1319 const github = new GitHub ( process . cwd ( ) ) ;
@@ -40,65 +46,125 @@ export async function merge(flags: MergeFlags = {}): Promise<void> {
4046 ) ;
4147 process . exit ( 1 ) ;
4248 }
43- const pr = unwrap ( await github . getPRForBranch ( bookmarkName ) ) ;
4449
45- if ( ! pr ) {
46- console . error ( formatError ( `No PR found for branch ${ cyan ( bookmarkName ) } ` ) ) ;
47- console . log ( dim ( " Submit first with 'arr submit'" ) ) ;
48- process . exit ( 1 ) ;
50+ // Build the stack of PRs to merge (from current up to trunk)
51+ const prsToMerge : PRToMerge [ ] = [ ] ;
52+ let currentBookmark : string | null = bookmarkName ;
53+ let currentChangeId : string | null = changeId ;
54+
55+ while ( currentBookmark ) {
56+ const prResult = await github . getPRForBranch ( currentBookmark ) ;
57+ const pr : PRStatus | null = unwrap ( prResult ) ;
58+
59+ if ( ! pr ) {
60+ console . error (
61+ formatError ( `No PR found for branch ${ cyan ( currentBookmark ) } ` ) ,
62+ ) ;
63+ console . log ( dim ( " Submit first with 'arr submit'" ) ) ;
64+ process . exit ( 1 ) ;
65+ }
66+
67+ if ( pr . state === "merged" ) {
68+ // Already merged, skip and continue up the stack
69+ break ;
70+ }
71+
72+ if ( pr . state === "closed" ) {
73+ console . error ( formatError ( `PR #${ pr . number } is closed (not merged)` ) ) ;
74+ process . exit ( 1 ) ;
75+ }
76+
77+ prsToMerge . unshift ( {
78+ pr,
79+ bookmarkName : currentBookmark ,
80+ changeId : currentChangeId ,
81+ } ) ;
82+
83+ // Follow the base to find parent PRs
84+ if ( pr . baseRefName === trunk ) {
85+ break ; // Reached trunk, we have the full stack
86+ }
87+
88+ currentBookmark = pr . baseRefName ;
89+ currentChangeId = null ; // We don't track changeIds for parent PRs
4990 }
5091
51- if ( pr . state === "merged" ) {
52- console . log ( yellow ( `PR # ${ pr . number } is already merged` ) ) ;
92+ if ( prsToMerge . length === 0 ) {
93+ console . log ( yellow ( "No open PRs to merge" ) ) ;
5394 console . log ( dim ( " Running sync to update local state..." ) ) ;
5495 unwrap ( await jj . sync ( ) ) ;
5596 console . log ( formatSuccess ( "Synced" ) ) ;
5697 return ;
5798 }
5899
59- if ( pr . state === "closed" ) {
60- console . error ( formatError ( `PR #${ pr . number } is closed (not merged)` ) ) ;
61- process . exit ( 1 ) ;
62- }
63-
64- // Check if this is a stacked PR (base is not trunk)
65- if ( pr . baseRefName !== trunk ) {
66- console . error (
67- formatError (
68- `Cannot merge: PR #${ pr . number } is stacked on ${ cyan ( pr . baseRefName ) } ` ,
69- ) ,
70- ) ;
71- console . log (
72- dim ( ` Merge the base PR first, or use 'arr merge --stack' to merge all` ) ,
73- ) ;
74- process . exit ( 1 ) ;
75- }
76-
77100 let method : "merge" | "squash" | "rebase" = "squash" ;
78101 if ( flags . merge ) method = "merge" ;
79102 if ( flags . rebase ) method = "rebase" ;
80103
81- console . log ( `Merging PR #${ cyan ( String ( pr . number ) ) } : ${ pr . title } ` ) ;
82-
83- // Merge and delete the remote branch to prevent stale references
84- unwrap (
85- await github . mergePR ( pr . number , {
86- method,
87- deleteHead : true ,
88- headRef : bookmarkName ,
89- } ) ,
104+ // Merge PRs from bottom to top
105+ console . log (
106+ `Merging ${ prsToMerge . length } PR${ prsToMerge . length > 1 ? "s" : "" } from stack...` ,
90107 ) ;
91- console . log ( formatSuccess ( `Merged PR # ${ pr . number } ` ) ) ;
108+ console . log ( ) ;
92109
93- // Clean up local state: delete bookmark and abandon change
94- if ( bookmarkName ) {
95- await jj . deleteBookmark ( bookmarkName ) ;
96- }
97- if ( changeId ) {
98- await jj . abandon ( changeId ) ;
110+ for ( const { pr, bookmarkName : bookmark , changeId : chgId } of prsToMerge ) {
111+ // SAFETY: Validate bookmark is not a protected branch
112+ const protectedBranches = [ trunk , "main" , "master" , "develop" ] ;
113+ if ( protectedBranches . includes ( bookmark ) ) {
114+ console . error (
115+ formatError (
116+ `Internal error: Cannot merge with protected branch as head: ${ bookmark } ` ,
117+ ) ,
118+ ) ;
119+ process . exit ( 1 ) ;
120+ }
121+
122+ console . log ( `Merging PR #${ cyan ( String ( pr . number ) ) } : ${ pr . title } ` ) ;
123+ console . log ( dim ( ` Branch: ${ bookmark } → ${ pr . baseRefName } ` ) ) ;
124+
125+ // Before merging, update the PR base to trunk if it's not already
126+ // (since we're merging bottom-up, each PR should target trunk after its base is merged)
127+ if ( pr . baseRefName !== trunk ) {
128+ await github . updatePR ( pr . number , { base : trunk } ) ;
129+
130+ // Wait for GitHub to recalculate merge status after base change
131+ process . stdout . write ( dim ( " Waiting for GitHub..." ) ) ;
132+ const mergeableResult = unwrap (
133+ await github . waitForMergeable ( pr . number , {
134+ timeoutMs : 60000 ,
135+ pollIntervalMs : 2000 ,
136+ } ) ,
137+ ) ;
138+ process . stdout . write ( `\r${ " " . repeat ( 30 ) } \r` ) ;
139+
140+ if ( ! mergeableResult . mergeable ) {
141+ console . error (
142+ formatError (
143+ `PR #${ pr . number } is not mergeable: ${ mergeableResult . reason } ` ,
144+ ) ,
145+ ) ;
146+ process . exit ( 1 ) ;
147+ }
148+ }
149+
150+ unwrap (
151+ await github . mergePR ( pr . number , {
152+ method,
153+ deleteHead : true ,
154+ headRef : bookmark ,
155+ } ) ,
156+ ) ;
157+ console . log ( formatSuccess ( `Merged PR #${ pr . number } ` ) ) ;
158+
159+ // Clean up local state
160+ await jj . deleteBookmark ( bookmark ) ;
161+ if ( chgId ) {
162+ await jj . abandon ( chgId ) ;
163+ }
99164 }
100165
101- console . log ( dim ( " Syncing to update local state..." ) ) ;
166+ console . log ( ) ;
167+ console . log ( dim ( "Syncing to update local state..." ) ) ;
102168 unwrap ( await jj . sync ( ) ) ;
103- console . log ( formatSuccess ( "Done! Change has been merged and synced." ) ) ;
169+ console . log ( formatSuccess ( "Done! All PRs merged and synced." ) ) ;
104170}
0 commit comments