* Leave the sandbox. * ok=true → squash all sandbox work into ONE commit on the origin branch. * ok=false → abandon the sandbox branch; restore the user's original state. * Either way we return to the origin branch and pop the WIP stash.
(ok: boolean, commitMessage: string, signal?: AbortSignal)
| 337 | * Either way we return to the origin branch and pop the WIP stash. |
| 338 | */ |
| 339 | async finish(ok: boolean, commitMessage: string, signal?: AbortSignal): Promise<FinishResult> { |
| 340 | if (!this.state?.active) return { merged: false, reason: 'empty' }; |
| 341 | const { branch, originBranch, baseCommit, stashed, stashMessage } = this.state; |
| 342 | // Distinguish a genuine "nothing to merge" (empty) from a real merge failure |
| 343 | // (conflict/error). Default to 'empty' for the !ok abandon path; the ok path |
| 344 | // overrides this when the squash actually fails. |
| 345 | let merged = false; |
| 346 | let failReason: 'empty' | 'conflict' | 'error' = 'empty'; |
| 347 | let failError: string | undefined; |
| 348 | try { |
| 349 | if (ok) { |
| 350 | // Stage everything still uncommitted, then commit so squash captures it. |
| 351 | await git(['add', '-A'], { cwd: this.cwd, signal }); |
| 352 | await git(['commit', '--no-verify', '--allow-empty', '-m', 'qodex sandbox final'], { cwd: this.cwd, signal }); |
| 353 | // Back to origin, squash-merge the diff from base→branch as a single commit. |
| 354 | await git(['checkout', originBranch], { cwd: this.cwd, signal }); |
| 355 | const sq = await git(['merge', '--squash', branch], { cwd: this.cwd, signal }); |
| 356 | if ((sq.exitCode === 0)) { |
| 357 | const c = await git(['commit', '--no-verify', '-m', commitMessage], { cwd: this.cwd, signal }); |
| 358 | merged = (c.exitCode === 0); |
| 359 | if (!merged) { |
| 360 | // Squash staged nothing → empty commit refused. That's a genuine |
| 361 | // "no committed changes" case, not a failure. |
| 362 | const out = `${c.stdout ?? ''}\n${c.stderr ?? ''}`; |
| 363 | failReason = /nothing to commit|no changes added/i.test(out) ? 'empty' : 'error'; |
| 364 | if (failReason === 'error') failError = (c.stderr || c.stdout || '').trim() || undefined; |
| 365 | } |
| 366 | } else { |
| 367 | // Merge itself failed (e.g. conflict) — surface it as a real failure. |
| 368 | merged = false; |
| 369 | const out = `${sq.stdout ?? ''}\n${sq.stderr ?? ''}`; |
| 370 | failReason = /conflict|CONFLICT/.test(out) ? 'conflict' : 'error'; |
| 371 | failError = (sq.stderr || sq.stdout || '').trim() || undefined; |
| 372 | } |
| 373 | if (!merged) { |
| 374 | // Squash had nothing or failed; ensure we at least left the branch. |
| 375 | await git(['merge', '--abort'], { cwd: this.cwd, signal }).catch(() => {}); |
| 376 | } |
| 377 | } else { |
| 378 | // Abandon: just return to origin; the sandbox branch is left for forensics. |
| 379 | await git(['checkout', '--force', originBranch], { cwd: this.cwd, signal }); |
| 380 | // Only hard-reset origin once we've confirmed we're actually on it — never |
| 381 | // blow away whatever branch we happen to be on if the checkout failed. |
| 382 | const cur = await this.currentBranch(signal); |
| 383 | if (cur === originBranch) { |
| 384 | await git(['reset', '--hard', baseCommit], { cwd: this.cwd, signal }); |
| 385 | } else { |
| 386 | logger.warn('GitSandbox.finish: not on origin after checkout — skipping reset --hard', { expected: originBranch, actual: cur }); |
| 387 | } |
| 388 | } |
| 389 | // Delete the throwaway branch (best-effort). |
| 390 | await git(['branch', '-D', branch], { cwd: this.cwd, signal }).catch(() => {}); |
| 391 | // Restore the user's pre-sandbox WIP via the SPECIFIC stash (by message), |
| 392 | // never a positional pop that could cross-pop another session's stash. A |
| 393 | // conflict is surfaced (logged), not silently swallowed. |
| 394 | if (stashed && stashMessage) { |
| 395 | await this.restoreStashByMessage(stashMessage, signal); |
| 396 | } |
no test coverage detected