@@ -71,6 +71,11 @@ export interface MergeNodeDeps {
7171 * Resolve a branch ref to its current SHA.
7272 */
7373 revParse : ( cwd : string , ref : string ) => Promise < string > ;
74+ /**
75+ * Stage all changes and commit in the given directory.
76+ * Returns the commit hash, or undefined if there was nothing to commit.
77+ */
78+ commitAll : ( cwd : string , message : string ) => Promise < string | undefined > ;
7479 gitPrService : IGitPrService ;
7580 cleanupFeatureWorktreeUseCase : Pick < CleanupFeatureWorktreeUseCase , 'execute' > ;
7681}
@@ -159,77 +164,104 @@ export function createMergeNode(deps: MergeNodeDeps) {
159164
160165 // --- Agent Call 1: Commit + Push + PR (skip on approval resume) ---
161166 if ( ! isResumeAfterInterrupt ) {
162- if ( ! remoteAvailable ) {
163- log . info ( 'No git remote configured — skipping push and PR, will merge locally' ) ;
164- }
165-
166167 const effectiveState = remoteAvailable ? state : { ...state , push : false , openPr : false } ;
168+ const needsAgentCall = effectiveState . push || effectiveState . openPr ;
167169
168- log . info ( 'Agent call 1: commit + push + PR' ) ;
169- const commitPushPrPrompt = buildCommitPushPrPrompt (
170- effectiveState ,
171- branch ,
172- baseBranch ,
173- repoUrl
174- ) ;
175- const commitResult = await retryExecute ( executor , commitPushPrPrompt , options , {
176- logger : log ,
177- } ) ;
178- totalInputTokens += commitResult . usage ?. inputTokens ?? 0 ;
179- totalOutputTokens += commitResult . usage ?. outputTokens ?? 0 ;
180-
181- commitHash = parseCommitHash ( commitResult . result ) ?? state . commitHash ;
182- messages . push ( `[merge] Agent completed commit/push/PR operations` ) ;
183-
184- if ( effectiveState . openPr ) {
185- const prResult = parsePrUrl ( commitResult . result ) ;
186- if ( prResult ) {
187- prUrl = prResult . url ;
188- prNumber = prResult . number ;
189-
190- // Cross-validate agent-parsed PR URL against authoritative source.
191- // The agent may hallucinate the repo URL or PR number, so we look up
192- // the real PR for this branch via the GitHub API.
193- try {
194- const prStatuses = await deps . gitPrService . listPrStatuses ( cwd ) ;
195- const matchingPr = prStatuses . find ( ( pr ) => pr . headRefName === branch ) ;
196- if ( matchingPr ) {
197- prUrl = matchingPr . url ;
198- prNumber = matchingPr . number ;
199- }
200- } catch {
201- // gh CLI unavailable or API failure — fall back to agent-parsed URL
170+ if ( ! needsAgentCall ) {
171+ // Local-only: commit programmatically in worktree, no agent needed.
172+ // This avoids calling the agent executor just to run git commit,
173+ // which is slow/unreliable on some platforms (e.g. cursor on Windows hangs).
174+ log . info ( 'Local-only mode — committing in worktree programmatically (no agent)' ) ;
175+ const worktreeCwd = state . worktreePath ?? cwd ;
176+ try {
177+ const msg = `feat: ${ branch } ` ;
178+ const hash = await deps . commitAll ( worktreeCwd , msg ) ;
179+ if ( hash ) {
180+ commitHash = hash ;
181+ log . info ( `Committed changes in worktree: ${ commitHash } ` ) ;
182+ messages . push ( `[merge] Programmatic commit: ${ commitHash } ` ) ;
183+ } else {
184+ log . info ( 'No changes to commit in worktree' ) ;
185+ messages . push ( `[merge] No changes to commit` ) ;
202186 }
203-
204- messages . push ( `[merge] PR created: ${ prUrl } ` ) ;
187+ } catch ( commitErr ) {
188+ const errMsg = commitErr instanceof Error ? commitErr . message : String ( commitErr ) ;
189+ log . info ( `Programmatic commit failed: ${ errMsg } — falling back to agent` ) ;
190+ messages . push ( `[merge] Programmatic commit failed, falling back to agent` ) ;
191+ // Fall through — needsAgentCall stays false so agent won't run either.
192+ // The localMergeSquash will handle uncommitted changes via --squash.
205193 }
206194 }
207195
208- // --- CI watch/fix loop (when push or openPr is enabled and CI watch is not disabled) ---
209- const ciWatchEnabled = getSettings ( ) . workflow ?. ciWatchEnabled !== false ;
210- if ( ciWatchEnabled && ( effectiveState . push || effectiveState . openPr ) ) {
211- const ciResult = await runCiWatchFixLoop (
212- {
213- executor,
214- gitPrService : deps . gitPrService ,
215- featureRepository : deps . featureRepository ,
216- } ,
217- {
218- cwd,
219- branch,
220- options,
221- feature,
222- prUrl,
223- prNumber,
224- existingAttempts : ciFixAttempts ,
225- messages,
226- log,
227- }
196+ if ( needsAgentCall ) {
197+ if ( ! remoteAvailable ) {
198+ log . info ( 'No git remote configured — skipping push and PR, will merge locally' ) ;
199+ }
200+
201+ log . info ( 'Agent call 1: commit + push + PR' ) ;
202+ const commitPushPrPrompt = buildCommitPushPrPrompt (
203+ effectiveState ,
204+ branch ,
205+ baseBranch ,
206+ repoUrl
228207 ) ;
229- ciStatus = ciResult . ciStatus ;
230- ciFixAttempts = ciResult . ciFixAttempts ;
231- ciFixHistory = ciResult . ciFixHistory ;
232- ciFixStatus = ciResult . ciFixStatus ;
208+ const commitResult = await retryExecute ( executor , commitPushPrPrompt , options , {
209+ logger : log ,
210+ } ) ;
211+ totalInputTokens += commitResult . usage ?. inputTokens ?? 0 ;
212+ totalOutputTokens += commitResult . usage ?. outputTokens ?? 0 ;
213+
214+ commitHash = parseCommitHash ( commitResult . result ) ?? state . commitHash ;
215+ messages . push ( `[merge] Agent completed commit/push/PR operations` ) ;
216+
217+ if ( effectiveState . openPr ) {
218+ const prResult = parsePrUrl ( commitResult . result ) ;
219+ if ( prResult ) {
220+ prUrl = prResult . url ;
221+ prNumber = prResult . number ;
222+
223+ // Cross-validate agent-parsed PR URL against authoritative source.
224+ try {
225+ const prStatuses = await deps . gitPrService . listPrStatuses ( cwd ) ;
226+ const matchingPr = prStatuses . find ( ( pr ) => pr . headRefName === branch ) ;
227+ if ( matchingPr ) {
228+ prUrl = matchingPr . url ;
229+ prNumber = matchingPr . number ;
230+ }
231+ } catch {
232+ // gh CLI unavailable or API failure — fall back to agent-parsed URL
233+ }
234+
235+ messages . push ( `[merge] PR created: ${ prUrl } ` ) ;
236+ }
237+ }
238+
239+ // --- CI watch/fix loop (when push or openPr is enabled and CI watch is not disabled) ---
240+ const ciWatchEnabled = getSettings ( ) . workflow ?. ciWatchEnabled !== false ;
241+ if ( ciWatchEnabled && ( effectiveState . push || effectiveState . openPr ) ) {
242+ const ciResult = await runCiWatchFixLoop (
243+ {
244+ executor,
245+ gitPrService : deps . gitPrService ,
246+ featureRepository : deps . featureRepository ,
247+ } ,
248+ {
249+ cwd,
250+ branch,
251+ options,
252+ feature,
253+ prUrl,
254+ prNumber,
255+ existingAttempts : ciFixAttempts ,
256+ messages,
257+ log,
258+ }
259+ ) ;
260+ ciStatus = ciResult . ciStatus ;
261+ ciFixAttempts = ciResult . ciFixAttempts ;
262+ ciFixHistory = ciResult . ciFixHistory ;
263+ ciFixStatus = ciResult . ciFixStatus ;
264+ }
233265 }
234266
235267 // --- Persist lifecycle + PR data before approval gate ---
0 commit comments