MCPcopy Create free account
hub / github.com/QodeXcli/QodeX / finish

Method finish

src/agent/git-sandbox.ts:339–417  ·  view source on GitHub ↗

* 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)

Source from the content-addressed store, hash-verified

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 }

Callers 1

runSandboxedMethod · 0.95

Calls 5

currentBranchMethod · 0.95
restoreStashByMessageMethod · 0.95
recoveryRecordPathFunction · 0.85
warnMethod · 0.80
gitFunction · 0.50

Tested by

no test coverage detected