@btatum5/codex-bridge 0.1.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1267 @@
1
+ // FILE: git-handler.js
2
+ // Purpose: Intercepts git/* JSON-RPC methods and executes git commands locally on the Mac.
3
+ // Layer: Bridge handler
4
+ // Exports: handleGitRequest
5
+ // Depends on: child_process, fs, os, path, crypto
6
+
7
+ const { execFile } = require("child_process");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+ const { randomBytes } = require("crypto");
12
+ const { promisify } = require("util");
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const GIT_TIMEOUT_MS = 30_000;
16
+ const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
17
+
18
+ /**
19
+ * Intercepts git/* JSON-RPC methods and executes git commands locally.
20
+ * @param {string} rawMessage - Raw WebSocket message
21
+ * @param {(response: string) => void} sendResponse - Callback to send response back
22
+ * @returns {boolean} true if message was handled, false if it should pass through
23
+ */
24
+ function handleGitRequest(rawMessage, sendResponse) {
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(rawMessage);
28
+ } catch {
29
+ return false;
30
+ }
31
+
32
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
33
+ if (!method.startsWith("git/")) {
34
+ return false;
35
+ }
36
+
37
+ const id = parsed.id;
38
+ const params = parsed.params || {};
39
+
40
+ handleGitMethod(method, params)
41
+ .then((result) => {
42
+ sendResponse(JSON.stringify({ id, result }));
43
+ })
44
+ .catch((err) => {
45
+ const errorCode = err.errorCode || "git_error";
46
+ const message = err.userMessage || err.message || "Unknown git error";
47
+ sendResponse(
48
+ JSON.stringify({
49
+ id,
50
+ error: {
51
+ code: -32000,
52
+ message,
53
+ data: { errorCode },
54
+ },
55
+ })
56
+ );
57
+ });
58
+
59
+ return true;
60
+ }
61
+
62
+ async function handleGitMethod(method, params) {
63
+ const cwd = await resolveGitCwd(params);
64
+
65
+ switch (method) {
66
+ case "git/status":
67
+ return gitStatus(cwd);
68
+ case "git/diff":
69
+ return gitDiff(cwd);
70
+ case "git/commit":
71
+ return gitCommit(cwd, params);
72
+ case "git/push":
73
+ return gitPush(cwd);
74
+ case "git/pull":
75
+ return gitPull(cwd);
76
+ case "git/branches":
77
+ return gitBranches(cwd);
78
+ case "git/checkout":
79
+ return gitCheckout(cwd, params);
80
+ case "git/log":
81
+ return gitLog(cwd);
82
+ case "git/createBranch":
83
+ return gitCreateBranch(cwd, params);
84
+ case "git/createWorktree":
85
+ return gitCreateWorktree(cwd, params);
86
+ case "git/removeWorktree":
87
+ return gitRemoveWorktree(cwd, params);
88
+ case "git/stash":
89
+ return gitStash(cwd);
90
+ case "git/stashPop":
91
+ return gitStashPop(cwd);
92
+ case "git/resetToRemote":
93
+ return gitResetToRemote(cwd, params);
94
+ case "git/remoteUrl":
95
+ return gitRemoteUrl(cwd);
96
+ case "git/branchesWithStatus":
97
+ return gitBranchesWithStatus(cwd);
98
+ default:
99
+ throw gitError("unknown_method", `Unknown git method: ${method}`);
100
+ }
101
+ }
102
+
103
+ // ─── Git Status ───────────────────────────────────────────────
104
+
105
+ async function gitStatus(cwd) {
106
+ const [porcelain, branchInfo, repoRoot] = await Promise.all([
107
+ git(cwd, "status", "--porcelain=v1", "-b"),
108
+ revListCounts(cwd).catch(() => ({ ahead: 0, behind: 0 })),
109
+ resolveRepoRoot(cwd).catch(() => null),
110
+ ]);
111
+
112
+ const lines = porcelain.trim().split("\n").filter(Boolean);
113
+ const branchLine = lines[0] || "";
114
+ const fileLines = lines.slice(1);
115
+
116
+ const branch = parseBranchFromStatus(branchLine);
117
+ const tracking = parseTrackingFromStatus(branchLine);
118
+ const files = fileLines.map((line) => ({
119
+ path: line.substring(3).trim(),
120
+ status: line.substring(0, 2).trim(),
121
+ }));
122
+
123
+ const dirty = files.length > 0;
124
+ const { ahead, behind } = branchInfo;
125
+ const detached = branchLine.includes("HEAD detached") || branchLine.includes("no branch");
126
+ const noUpstream = tracking === null && !detached;
127
+ const publishedToRemote = !detached && !!branch && await remoteBranchExists(cwd, branch).catch(() => false);
128
+ const localOnlyCommitCount = await countLocalOnlyCommits(cwd, { detached }).catch(() => 0);
129
+ const state = computeState(dirty, ahead, behind, detached, noUpstream);
130
+ const canPush = (ahead > 0 || noUpstream) && !detached;
131
+ const diff = await repoDiffTotals(cwd, {
132
+ tracking,
133
+ fileLines,
134
+ }).catch(() => ({ additions: 0, deletions: 0, binaryFiles: 0 }));
135
+
136
+ return {
137
+ repoRoot,
138
+ branch,
139
+ tracking,
140
+ dirty,
141
+ ahead,
142
+ behind,
143
+ localOnlyCommitCount,
144
+ state,
145
+ canPush,
146
+ publishedToRemote,
147
+ files,
148
+ diff,
149
+ };
150
+ }
151
+
152
+ // ─── Git Diff ─────────────────────────────────────────────────
153
+
154
+ async function gitDiff(cwd) {
155
+ const porcelain = await git(cwd, "status", "--porcelain=v1", "-b");
156
+ const lines = porcelain.trim().split("\n").filter(Boolean);
157
+ const branchLine = lines[0] || "";
158
+ const fileLines = lines.slice(1);
159
+ const tracking = parseTrackingFromStatus(branchLine);
160
+ const baseRef = await resolveRepoDiffBase(cwd, tracking);
161
+ const trackedPatch = await gitDiffAgainstBase(cwd, baseRef);
162
+ const untrackedPaths = fileLines
163
+ .filter((line) => line.startsWith("?? "))
164
+ .map((line) => line.substring(3).trim())
165
+ .filter(Boolean);
166
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
167
+ const patch = [trackedPatch.trim(), untrackedPatch.trim()].filter(Boolean).join("\n\n").trim();
168
+ return { patch };
169
+ }
170
+
171
+ // ─── Git Commit ───────────────────────────────────────────────
172
+
173
+ async function gitCommit(cwd, params) {
174
+ const message =
175
+ typeof params.message === "string" && params.message.trim()
176
+ ? params.message.trim()
177
+ : "Changes from Codex";
178
+
179
+ // Check for changes first
180
+ const statusCheck = await git(cwd, "status", "--porcelain");
181
+ if (!statusCheck.trim()) {
182
+ throw gitError("nothing_to_commit", "Nothing to commit.");
183
+ }
184
+
185
+ await git(cwd, "add", "-A");
186
+ const output = await git(cwd, "commit", "-m", message);
187
+
188
+ const hashMatch = output.match(/\[(\S+)\s+([a-f0-9]+)\]/);
189
+ const hash = hashMatch ? hashMatch[2] : "";
190
+ const branch = hashMatch ? hashMatch[1] : "";
191
+ const summaryMatch = output.match(/\d+ files? changed/);
192
+ const summary = summaryMatch ? summaryMatch[0] : output.split("\n").pop()?.trim() || "";
193
+
194
+ return { hash, branch, summary };
195
+ }
196
+
197
+ // ─── Git Push ─────────────────────────────────────────────────
198
+
199
+ async function gitPush(cwd) {
200
+ try {
201
+ const branchOutput = await git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
202
+ const branch = branchOutput.trim();
203
+
204
+ // Try normal push first; if no upstream, set it
205
+ try {
206
+ await git(cwd, "push");
207
+ } catch (pushErr) {
208
+ if (
209
+ pushErr.message?.includes("no upstream") ||
210
+ pushErr.message?.includes("has no upstream branch")
211
+ ) {
212
+ await git(cwd, "push", "--set-upstream", "origin", branch);
213
+ } else {
214
+ throw pushErr;
215
+ }
216
+ }
217
+
218
+ const remote = "origin";
219
+ const status = await gitStatus(cwd);
220
+ return { branch, remote, status };
221
+ } catch (err) {
222
+ if (err.errorCode) throw err;
223
+ if (err.message?.includes("rejected")) {
224
+ throw gitError("push_rejected", "Push rejected. Pull changes first.");
225
+ }
226
+ throw gitError("push_failed", err.message || "Push failed.");
227
+ }
228
+ }
229
+
230
+ // ─── Git Pull ─────────────────────────────────────────────────
231
+
232
+ async function gitPull(cwd) {
233
+ try {
234
+ await git(cwd, "pull", "--rebase");
235
+ const status = await gitStatus(cwd);
236
+ return { success: true, status };
237
+ } catch (err) {
238
+ // Abort rebase on conflict
239
+ try {
240
+ await git(cwd, "rebase", "--abort");
241
+ } catch {
242
+ // ignore abort errors
243
+ }
244
+ if (err.errorCode) throw err;
245
+ throw gitError("pull_conflict", "Pull failed due to conflicts. Rebase aborted.");
246
+ }
247
+ }
248
+
249
+ // ─── Git Branches ─────────────────────────────────────────────
250
+
251
+ async function gitBranches(cwd) {
252
+ const [output, repoRoot, localCheckoutRoot] = await Promise.all([
253
+ git(cwd, "branch", "--no-color"),
254
+ resolveRepoRoot(cwd).catch(() => null),
255
+ resolveLocalCheckoutRoot(cwd).catch(() => null),
256
+ ]);
257
+ const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
258
+ const worktreePathByBranch = await gitWorktreePathByBranch(cwd, { projectRelativePath }).catch(() => ({}));
259
+ const localCheckoutPath = scopedLocalCheckoutPath(localCheckoutRoot || repoRoot, projectRelativePath);
260
+ const lines = output
261
+ .trim()
262
+ .split("\n")
263
+ .filter(Boolean);
264
+
265
+ let current = "";
266
+ const branchSet = new Set();
267
+ const branchesCheckedOutElsewhere = new Set();
268
+
269
+ for (const line of lines) {
270
+ const entry = normalizeBranchListEntry(line);
271
+ if (!entry) {
272
+ continue;
273
+ }
274
+
275
+ const { isCurrent, isCheckedOutElsewhere, name } = entry;
276
+
277
+ if (name.includes("HEAD detached") || name === "(no branch)") {
278
+ if (isCurrent) current = "HEAD";
279
+ continue;
280
+ }
281
+
282
+ branchSet.add(name);
283
+ if (isCheckedOutElsewhere) {
284
+ branchesCheckedOutElsewhere.add(name);
285
+ }
286
+
287
+ if (isCurrent) current = name;
288
+ }
289
+
290
+ const branches = [...branchSet].sort();
291
+ const defaultBranch = await detectDefaultBranch(cwd, branches);
292
+
293
+ return {
294
+ branches,
295
+ branchesCheckedOutElsewhere: [...branchesCheckedOutElsewhere].sort(),
296
+ worktreePathByBranch,
297
+ localCheckoutPath,
298
+ current,
299
+ default: defaultBranch,
300
+ };
301
+ }
302
+
303
+ // ─── Git Checkout ─────────────────────────────────────────────
304
+
305
+ async function gitCheckout(cwd, params) {
306
+ const branch = typeof params.branch === "string" ? params.branch.trim() : "";
307
+ if (!branch) {
308
+ throw gitError("missing_branch", "Branch name is required.");
309
+ }
310
+
311
+ try {
312
+ await git(cwd, "checkout", branch);
313
+ } catch (err) {
314
+ if (err.message?.includes("would be overwritten")) {
315
+ throw gitError(
316
+ "checkout_conflict_dirty_tree",
317
+ "Cannot switch branches: you have uncommitted changes."
318
+ );
319
+ }
320
+ if (err.message?.includes("already used by worktree")) {
321
+ throw gitError(
322
+ "checkout_branch_in_other_worktree",
323
+ "Cannot switch branches: this branch is already open in another worktree."
324
+ );
325
+ }
326
+ throw gitError("checkout_failed", err.message || "Checkout failed.");
327
+ }
328
+
329
+ const status = await gitStatus(cwd);
330
+ return { current: status.branch || branch, tracking: status.tracking, status };
331
+ }
332
+
333
+ // ─── Git Log ──────────────────────────────────────────────────
334
+
335
+ async function gitLog(cwd) {
336
+ const output = await git(
337
+ cwd,
338
+ "log",
339
+ "-20",
340
+ "--format=%H%x00%s%x00%an%x00%aI"
341
+ );
342
+
343
+ const commits = output
344
+ .trim()
345
+ .split("\n")
346
+ .filter(Boolean)
347
+ .map((line) => {
348
+ const [hash, message, author, date] = line.split("\0");
349
+ return {
350
+ hash: hash?.substring(0, 7) || "",
351
+ message: message || "",
352
+ author: author || "",
353
+ date: date || "",
354
+ };
355
+ });
356
+
357
+ return { commits };
358
+ }
359
+
360
+ // ─── Git Create Branch ────────────────────────────────────────
361
+
362
+ async function gitCreateBranch(cwd, params) {
363
+ const name = normalizeCreatedBranchName(params.name);
364
+ if (!name) {
365
+ throw gitError("missing_branch_name", "Branch name is required.");
366
+ }
367
+ await assertValidCreatedBranchName(cwd, name);
368
+
369
+ // Keep create-branch local-first so we never fork history under a remote-only name.
370
+ if (!(await localBranchExists(cwd, name)) && await remoteBranchExists(cwd, name)) {
371
+ throw gitError(
372
+ "branch_exists",
373
+ `Branch '${name}' already exists on origin. Check it out locally instead of creating a new branch.`
374
+ );
375
+ }
376
+
377
+ try {
378
+ await git(cwd, "checkout", "-b", name);
379
+ } catch (err) {
380
+ if (err.message?.includes("already exists")) {
381
+ throw gitError("branch_exists", `Branch '${name}' already exists.`);
382
+ }
383
+ throw gitError("create_branch_failed", err.message || "Failed to create branch.");
384
+ }
385
+
386
+ const status = await gitStatus(cwd);
387
+ return { branch: name, status };
388
+ }
389
+
390
+ async function gitCreateWorktree(cwd, params) {
391
+ const branch = normalizeCreatedBranchName(params.name);
392
+ if (!branch) {
393
+ throw gitError("missing_branch_name", "Branch name is required.");
394
+ }
395
+ await assertValidCreatedBranchName(cwd, branch);
396
+
397
+ const branchResult = await gitBranches(cwd);
398
+ const repoRoot = await resolveRepoRoot(cwd);
399
+ const status = await gitStatus(cwd);
400
+ const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
401
+ const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.defaultBranch);
402
+ const changeTransfer = resolveWorktreeChangeTransfer(params.changeTransfer);
403
+ if (!baseBranch) {
404
+ throw gitError("missing_base_branch", "Base branch is required.");
405
+ }
406
+ if (!(await localBranchExists(cwd, baseBranch))) {
407
+ throw gitError(
408
+ "missing_base_branch",
409
+ `Base branch '${baseBranch}' is not available locally. Create or check out that branch first.`
410
+ );
411
+ }
412
+
413
+ const currentBranch = typeof status.branch === "string" ? status.branch.trim() : "";
414
+ const canCarryLocalChanges = status.dirty && !!currentBranch && currentBranch === baseBranch;
415
+ if (status.dirty && !canCarryLocalChanges) {
416
+ const currentBranchLabel = currentBranch || "the current branch";
417
+ const transferVerb = changeTransfer === "copy" ? "copy" : "move";
418
+ throw gitError(
419
+ "dirty_worktree_base_mismatch",
420
+ `Uncommitted changes can ${transferVerb} into a new worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
421
+ );
422
+ }
423
+
424
+ const existingWorktreePath = branchResult.worktreePathByBranch[branch];
425
+ if (existingWorktreePath) {
426
+ if (sameFilePath(existingWorktreePath, cwd)) {
427
+ throw gitError(
428
+ "branch_already_open_here",
429
+ `Branch '${branch}' is already open in this project.`
430
+ );
431
+ }
432
+
433
+ return {
434
+ branch,
435
+ worktreePath: existingWorktreePath,
436
+ alreadyExisted: true,
437
+ };
438
+ }
439
+
440
+ const branchExists = await localBranchExists(cwd, branch);
441
+ if (branchExists) {
442
+ throw gitError(
443
+ "branch_exists",
444
+ `Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
445
+ );
446
+ }
447
+
448
+ const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
449
+ let handoffStashRef = null;
450
+ let copiedLocalChangesPatch = "";
451
+ let didCreateWorktree = false;
452
+
453
+ try {
454
+ if (canCarryLocalChanges) {
455
+ if (changeTransfer === "copy") {
456
+ copiedLocalChangesPatch = await captureLocalChangesPatch(repoRoot);
457
+ } else {
458
+ handoffStashRef = await stashChangesForWorktreeHandoff(repoRoot);
459
+ }
460
+ }
461
+
462
+ await git(repoRoot, "worktree", "add", "-b", branch, worktreeRootPath, baseBranch);
463
+ didCreateWorktree = true;
464
+
465
+ if (handoffStashRef) {
466
+ await applyWorktreeHandoffStash(worktreeRootPath, handoffStashRef);
467
+ }
468
+ if (copiedLocalChangesPatch) {
469
+ await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
470
+ }
471
+ } catch (err) {
472
+ if (didCreateWorktree) {
473
+ await cleanupManagedWorktree(repoRoot, worktreeRootPath, branch);
474
+ } else {
475
+ fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
476
+ }
477
+
478
+ if (handoffStashRef) {
479
+ await restoreWorktreeHandoffStash(repoRoot, handoffStashRef);
480
+ }
481
+
482
+ if (err.message?.includes("invalid reference")) {
483
+ throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
484
+ }
485
+ if (err.message?.includes("already exists")) {
486
+ throw gitError("branch_exists", `Branch '${branch}' already exists.`);
487
+ }
488
+ if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
489
+ throw gitError(
490
+ "branch_in_other_worktree",
491
+ `Branch '${branch}' is already open in another worktree.`
492
+ );
493
+ }
494
+ throw gitError("create_worktree_failed", err.message || "Failed to create worktree.");
495
+ }
496
+
497
+ const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
498
+ return {
499
+ branch,
500
+ worktreePath,
501
+ alreadyExisted: false,
502
+ };
503
+ }
504
+
505
+ async function gitRemoveWorktree(cwd, params) {
506
+ const worktreeRootPath = await resolveRepoRoot(cwd).catch(() => null);
507
+ const localCheckoutRoot = await resolveLocalCheckoutRoot(cwd).catch(() => null);
508
+ const branch = typeof params.branch === "string" ? params.branch.trim() : "";
509
+
510
+ if (!worktreeRootPath || !localCheckoutRoot) {
511
+ throw gitError("missing_working_directory", "Could not resolve the worktree roots for cleanup.");
512
+ }
513
+ if (sameFilePath(worktreeRootPath, localCheckoutRoot)) {
514
+ throw gitError("cannot_remove_local_checkout", "Cannot remove the main local checkout.");
515
+ }
516
+ if (!isManagedWorktreePath(worktreeRootPath)) {
517
+ throw gitError("unmanaged_worktree", "Only managed worktrees can be removed automatically.");
518
+ }
519
+
520
+ await cleanupManagedWorktree(localCheckoutRoot, worktreeRootPath, branch || null);
521
+ if (branch && await localBranchExists(localCheckoutRoot, branch)) {
522
+ throw gitError(
523
+ "worktree_cleanup_failed",
524
+ `The temporary worktree was removed, but branch '${branch}' could not be deleted automatically.`
525
+ );
526
+ }
527
+ return { success: true };
528
+ }
529
+
530
+ // ─── Git Stash ────────────────────────────────────────────────
531
+
532
+ async function gitStash(cwd) {
533
+ const output = await git(cwd, "stash");
534
+ const saved = !output.includes("No local changes");
535
+ return { success: saved, message: output.trim() };
536
+ }
537
+
538
+ // ─── Git Stash Pop ────────────────────────────────────────────
539
+
540
+ async function gitStashPop(cwd) {
541
+ try {
542
+ const output = await git(cwd, "stash", "pop");
543
+ return { success: true, message: output.trim() };
544
+ } catch (err) {
545
+ throw gitError("stash_pop_conflict", err.message || "Stash pop failed due to conflicts.");
546
+ }
547
+ }
548
+
549
+ // ─── Git Reset to Remote ──────────────────────────────────────
550
+
551
+ async function gitResetToRemote(cwd, params) {
552
+ if (params.confirm !== "discard_runtime_changes") {
553
+ throw gitError(
554
+ "confirmation_required",
555
+ 'This action requires params.confirm === "discard_runtime_changes".'
556
+ );
557
+ }
558
+
559
+ let hasUpstream = true;
560
+ try {
561
+ await git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}");
562
+ } catch {
563
+ hasUpstream = false;
564
+ }
565
+
566
+ if (hasUpstream) {
567
+ await git(cwd, "fetch");
568
+ await git(cwd, "reset", "--hard", "@{u}");
569
+ } else {
570
+ await git(cwd, "checkout", "--", ".");
571
+ }
572
+ await git(cwd, "clean", "-fd");
573
+
574
+ const status = await gitStatus(cwd);
575
+ return { success: true, status };
576
+ }
577
+
578
+ // ─── Git Remote URL ───────────────────────────────────────────
579
+
580
+ async function gitRemoteUrl(cwd) {
581
+ const raw = (await git(cwd, "config", "--get", "remote.origin.url")).trim();
582
+ const ownerRepo = parseOwnerRepo(raw);
583
+ return { url: raw, ownerRepo };
584
+ }
585
+
586
+ function parseOwnerRepo(remoteUrl) {
587
+ const match = remoteUrl.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
588
+ return match ? match[1] : null;
589
+ }
590
+
591
+ // ─── Git Branches With Status ─────────────────────────────────
592
+
593
+ async function gitBranchesWithStatus(cwd) {
594
+ const [branchResult, statusResult] = await Promise.all([
595
+ gitBranches(cwd),
596
+ gitStatus(cwd),
597
+ ]);
598
+ return { ...branchResult, status: statusResult };
599
+ }
600
+
601
+ async function gitWorktreePathByBranch(cwd, options = {}) {
602
+ const output = await git(cwd, "worktree", "list", "--porcelain");
603
+ return parseWorktreePathByBranch(output, options);
604
+ }
605
+
606
+ async function stashChangesForWorktreeHandoff(cwd) {
607
+ const stashLabel = `codex-worktree-handoff-${randomBytes(6).toString("hex")}`;
608
+ const output = await git(cwd, "stash", "push", "--include-untracked", "--message", stashLabel);
609
+ if (output.includes("No local changes")) {
610
+ return null;
611
+ }
612
+
613
+ const stashRef = await findStashRefByLabel(cwd, stashLabel);
614
+ if (!stashRef) {
615
+ throw gitError("create_worktree_failed", "Could not prepare local changes for the worktree handoff.");
616
+ }
617
+
618
+ return stashRef;
619
+ }
620
+
621
+ async function captureLocalChangesPatch(cwd) {
622
+ const trackedPatch = await git(cwd, "diff", "--binary", "--find-renames", "HEAD");
623
+ const porcelain = await git(cwd, "status", "--porcelain=v1");
624
+ const untrackedPaths = porcelain
625
+ .trim()
626
+ .split("\n")
627
+ .filter((line) => line.startsWith("?? "))
628
+ .map((line) => line.substring(3).trim())
629
+ .filter(Boolean);
630
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
631
+ return [trackedPatch, untrackedPatch]
632
+ .filter((patch) => typeof patch === "string" && patch.trim())
633
+ .map(ensureTrailingNewline)
634
+ .join("\n");
635
+ }
636
+
637
+ async function findStashRefByLabel(cwd, stashLabel) {
638
+ const output = await git(cwd, "stash", "list", "--format=%gd%x00%s");
639
+ const records = output
640
+ .trim()
641
+ .split("\n")
642
+ .map((line) => line.trim())
643
+ .filter(Boolean);
644
+
645
+ for (const record of records) {
646
+ const [ref, summary] = record.split("\0");
647
+ if (ref && summary?.includes(stashLabel)) {
648
+ return ref.trim();
649
+ }
650
+ }
651
+
652
+ return null;
653
+ }
654
+
655
+ async function applyWorktreeHandoffStash(cwd, stashRef) {
656
+ try {
657
+ await git(cwd, "stash", "pop", stashRef);
658
+ } catch (err) {
659
+ throw gitError(
660
+ "create_worktree_failed",
661
+ err.message || "Could not apply local changes in the new worktree."
662
+ );
663
+ }
664
+ }
665
+
666
+ async function applyCopiedLocalChangesToWorktree(cwd, patch) {
667
+ if (!patch.trim()) {
668
+ return;
669
+ }
670
+
671
+ const patchFilePath = path.join(os.tmpdir(), `codex-worktree-copy-${randomBytes(6).toString("hex")}.patch`);
672
+ fs.writeFileSync(patchFilePath, ensureTrailingNewline(patch), "utf8");
673
+
674
+ try {
675
+ await git(cwd, "apply", "--binary", "--whitespace=nowarn", patchFilePath);
676
+ } catch (err) {
677
+ throw gitError(
678
+ "create_worktree_failed",
679
+ err.message || "Could not copy local changes into the new worktree."
680
+ );
681
+ } finally {
682
+ fs.rmSync(patchFilePath, { force: true });
683
+ }
684
+ }
685
+
686
+ async function restoreWorktreeHandoffStash(cwd, stashRef) {
687
+ try {
688
+ await git(cwd, "stash", "pop", stashRef);
689
+ } catch {
690
+ // Best effort: if restore fails we prefer surfacing the original worktree error without masking it.
691
+ }
692
+ }
693
+
694
+ async function cleanupManagedWorktree(repoRoot, worktreeRootPath, branchName = null) {
695
+ try {
696
+ await git(repoRoot, "worktree", "remove", "--force", worktreeRootPath);
697
+ } catch {
698
+ // Fall back to directory cleanup below.
699
+ }
700
+
701
+ if (branchName) {
702
+ try {
703
+ await git(repoRoot, "branch", "-D", branchName);
704
+ } catch {
705
+ // Best effort: leave the branch around if Git refuses deletion for any reason.
706
+ }
707
+ }
708
+
709
+ fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
710
+ }
711
+
712
+ function parseWorktreePathByBranch(output, options = {}) {
713
+ const worktreePathByBranch = {};
714
+ const records = typeof output === "string" ? output.split("\n\n") : [];
715
+ const projectRelativePath = typeof options.projectRelativePath === "string"
716
+ ? options.projectRelativePath
717
+ : "";
718
+
719
+ for (const record of records) {
720
+ const lines = record
721
+ .split("\n")
722
+ .map((line) => line.trim())
723
+ .filter(Boolean);
724
+
725
+ if (!lines.length) {
726
+ continue;
727
+ }
728
+
729
+ const worktreeLine = lines.find((line) => line.startsWith("worktree "));
730
+ const branchLine = lines.find((line) => line.startsWith("branch "));
731
+ const worktreePath = worktreeLine?.slice("worktree ".length).trim();
732
+ const branchName = normalizeWorktreeBranchRef(branchLine?.slice("branch ".length).trim());
733
+
734
+ if (!worktreePath || !branchName) {
735
+ continue;
736
+ }
737
+
738
+ worktreePathByBranch[branchName] = scopedWorktreePath(worktreePath, projectRelativePath);
739
+ }
740
+
741
+ return worktreePathByBranch;
742
+ }
743
+
744
+ // Normalizes `git branch` output so the UI never sees worktree markers like `+ main`.
745
+ function normalizeBranchListEntry(rawLine) {
746
+ const trimmed = typeof rawLine === "string" ? rawLine.trim() : "";
747
+ if (!trimmed) {
748
+ return null;
749
+ }
750
+
751
+ const isCurrent = trimmed.startsWith("* ");
752
+ const isCheckedOutElsewhere = trimmed.startsWith("+ ");
753
+ const name = trimmed.replace(/^[*+]\s+/, "").trim();
754
+
755
+ if (!name) {
756
+ return null;
757
+ }
758
+
759
+ return { isCurrent, isCheckedOutElsewhere, name };
760
+ }
761
+
762
+ function normalizeWorktreeBranchRef(rawRef) {
763
+ const trimmed = typeof rawRef === "string" ? rawRef.trim() : "";
764
+ if (!trimmed.startsWith("refs/heads/")) {
765
+ return null;
766
+ }
767
+
768
+ const branchName = trimmed.slice("refs/heads/".length).trim();
769
+ return branchName || null;
770
+ }
771
+
772
+ function normalizeCreatedBranchName(rawName) {
773
+ const trimmed = typeof rawName === "string" ? rawName.trim() : "";
774
+ if (!trimmed) {
775
+ return "";
776
+ }
777
+
778
+ // Keep slash-separated branch groups, but normalize user-entered whitespace into Git-friendly dashes.
779
+ const normalized = trimmed
780
+ .split("/")
781
+ .map((segment) => segment.trim().replace(/\s+/g, "-"))
782
+ .join("/");
783
+
784
+ if (normalized.startsWith("codex/")) {
785
+ return normalized;
786
+ }
787
+ return `codex/${normalized}`;
788
+ }
789
+
790
+ function resolveBaseBranchName(rawBaseBranch, fallbackBranch) {
791
+ const trimmedBaseBranch = typeof rawBaseBranch === "string" ? rawBaseBranch.trim() : "";
792
+ if (trimmedBaseBranch) {
793
+ return trimmedBaseBranch;
794
+ }
795
+
796
+ return typeof fallbackBranch === "string" && fallbackBranch.trim() ? fallbackBranch.trim() : "";
797
+ }
798
+
799
+ // Mirrors Codex-managed worktree paths under CODEX_HOME/worktrees/<token>/<repo>.
800
+ function allocateManagedWorktreePath(repoRoot) {
801
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
802
+ const worktreesRoot = path.join(codexHome, "worktrees");
803
+ fs.mkdirSync(worktreesRoot, { recursive: true });
804
+
805
+ const repoName = path.basename(repoRoot) || "repo";
806
+ for (let attempt = 0; attempt < 16; attempt += 1) {
807
+ const token = randomBytes(2).toString("hex");
808
+ const tokenDirectory = path.join(worktreesRoot, token);
809
+ const worktreePath = path.join(tokenDirectory, repoName);
810
+ if (fs.existsSync(tokenDirectory) || fs.existsSync(worktreePath)) {
811
+ continue;
812
+ }
813
+ fs.mkdirSync(tokenDirectory, { recursive: true });
814
+ return worktreePath;
815
+ }
816
+
817
+ throw gitError("create_worktree_failed", "Could not allocate a managed worktree path.");
818
+ }
819
+
820
+ async function localBranchExists(cwd, branchName) {
821
+ try {
822
+ await git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`);
823
+ return true;
824
+ } catch {
825
+ return false;
826
+ }
827
+ }
828
+
829
+ async function assertValidCreatedBranchName(cwd, branchName) {
830
+ try {
831
+ await git(cwd, "check-ref-format", "--branch", branchName);
832
+ } catch {
833
+ throw gitError("invalid_branch_name", `Branch '${branchName}' is not a valid Git branch name.`);
834
+ }
835
+ }
836
+
837
+ // Keeps branch creation local-only even when a same-named ref exists on origin.
838
+ async function remoteBranchExists(cwd, branchName) {
839
+ try {
840
+ await git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`);
841
+ return true;
842
+ } catch {
843
+ return false;
844
+ }
845
+ }
846
+
847
+ function sameFilePath(leftPath, rightPath) {
848
+ const normalizedLeft = normalizeExistingPath(leftPath);
849
+ const normalizedRight = normalizeExistingPath(rightPath);
850
+ return normalizedLeft !== null && normalizedLeft === normalizedRight;
851
+ }
852
+
853
+ function normalizeExistingPath(candidatePath) {
854
+ if (typeof candidatePath !== "string") {
855
+ return null;
856
+ }
857
+
858
+ const trimmedPath = candidatePath.trim();
859
+ if (!trimmedPath) {
860
+ return null;
861
+ }
862
+
863
+ try {
864
+ return fs.realpathSync.native(trimmedPath);
865
+ } catch {
866
+ return path.resolve(trimmedPath);
867
+ }
868
+ }
869
+
870
+ function managedWorktreesRoot() {
871
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
872
+ return normalizeExistingPath(path.join(codexHome, "worktrees"));
873
+ }
874
+
875
+ function isManagedWorktreePath(candidatePath) {
876
+ const normalizedCandidate = normalizeExistingPath(candidatePath);
877
+ const normalizedRoot = managedWorktreesRoot();
878
+ if (!normalizedCandidate || !normalizedRoot) {
879
+ return false;
880
+ }
881
+
882
+ const relativePath = path.relative(normalizedRoot, normalizedCandidate);
883
+ return !!relativePath && relativePath !== "." && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
884
+ }
885
+
886
+ function resolveProjectRelativePath(cwd, repoRoot) {
887
+ const normalizedCwd = normalizeExistingPath(cwd);
888
+ const normalizedRepoRoot = normalizeExistingPath(repoRoot);
889
+ if (!normalizedCwd || !normalizedRepoRoot) {
890
+ return "";
891
+ }
892
+
893
+ const relativePath = path.relative(normalizedRepoRoot, normalizedCwd);
894
+ if (!relativePath || relativePath === ".") {
895
+ return "";
896
+ }
897
+
898
+ return relativePath;
899
+ }
900
+
901
+ // Preserves package-scoped threads by reopening the matching subpath inside sibling worktrees.
902
+ function scopedWorktreePath(worktreeRootPath, projectRelativePath) {
903
+ const normalizedWorktreeRootPath = normalizeExistingPath(worktreeRootPath);
904
+ if (!normalizedWorktreeRootPath) {
905
+ return worktreeRootPath;
906
+ }
907
+ if (!projectRelativePath) {
908
+ return normalizedWorktreeRootPath;
909
+ }
910
+
911
+ const candidatePath = path.join(normalizedWorktreeRootPath, projectRelativePath);
912
+ return isExistingDirectory(candidatePath) ? normalizeExistingPath(candidatePath) ?? candidatePath : normalizedWorktreeRootPath;
913
+ }
914
+
915
+ // Resolves a Local checkout path only when the matching subpath actually exists there.
916
+ function scopedLocalCheckoutPath(checkoutRootPath, projectRelativePath) {
917
+ const normalizedCheckoutRootPath = normalizeExistingPath(checkoutRootPath);
918
+ if (!normalizedCheckoutRootPath) {
919
+ return null;
920
+ }
921
+ if (!projectRelativePath) {
922
+ return normalizedCheckoutRootPath;
923
+ }
924
+
925
+ const candidatePath = path.join(normalizedCheckoutRootPath, projectRelativePath);
926
+ return isExistingDirectory(candidatePath) ? normalizeExistingPath(candidatePath) ?? candidatePath : null;
927
+ }
928
+
929
+ // Computes the local repo delta that still exists on this machine and is not on the remote.
930
+ async function repoDiffTotals(cwd, context) {
931
+ const baseRef = await resolveRepoDiffBase(cwd, context.tracking);
932
+ const trackedTotals = await diffTotalsAgainstBase(cwd, baseRef);
933
+ const untrackedPaths = context.fileLines
934
+ .filter((line) => line.startsWith("?? "))
935
+ .map((line) => line.substring(3).trim())
936
+ .filter(Boolean);
937
+ const untrackedTotals = await diffTotalsForUntrackedFiles(cwd, untrackedPaths);
938
+
939
+ return {
940
+ additions: trackedTotals.additions + untrackedTotals.additions,
941
+ deletions: trackedTotals.deletions + untrackedTotals.deletions,
942
+ binaryFiles: trackedTotals.binaryFiles + untrackedTotals.binaryFiles,
943
+ };
944
+ }
945
+
946
+ // Uses upstream when available; otherwise falls back to commits not yet present on any remote.
947
+ async function resolveRepoDiffBase(cwd, tracking) {
948
+ if (tracking) {
949
+ try {
950
+ return (await git(cwd, "merge-base", "HEAD", "@{u}")).trim();
951
+ } catch {
952
+ // Fall through to the local-only commit scan if upstream metadata is stale.
953
+ }
954
+ }
955
+
956
+ const firstLocalOnlyCommit = (
957
+ await git(cwd, "rev-list", "--reverse", "--topo-order", "HEAD", "--not", "--remotes")
958
+ )
959
+ .trim()
960
+ .split("\n")
961
+ .find(Boolean);
962
+
963
+ if (!firstLocalOnlyCommit) {
964
+ return "HEAD";
965
+ }
966
+
967
+ try {
968
+ return (await git(cwd, "rev-parse", `${firstLocalOnlyCommit}^`)).trim();
969
+ } catch {
970
+ return EMPTY_TREE_HASH;
971
+ }
972
+ }
973
+
974
+ async function diffTotalsAgainstBase(cwd, baseRef) {
975
+ const output = await git(cwd, "diff", "--numstat", baseRef);
976
+ return parseNumstatTotals(output);
977
+ }
978
+
979
+ async function gitDiffAgainstBase(cwd, baseRef) {
980
+ return git(cwd, "diff", "--binary", "--find-renames", baseRef);
981
+ }
982
+
983
+ async function diffTotalsForUntrackedFiles(cwd, filePaths) {
984
+ if (!filePaths.length) {
985
+ return { additions: 0, deletions: 0, binaryFiles: 0 };
986
+ }
987
+
988
+ const totals = await Promise.all(
989
+ filePaths.map(async (filePath) => {
990
+ const output = await gitDiffNoIndexNumstat(cwd, filePath);
991
+ return parseNumstatTotals(output);
992
+ })
993
+ );
994
+
995
+ return totals.reduce(
996
+ (aggregate, current) => ({
997
+ additions: aggregate.additions + current.additions,
998
+ deletions: aggregate.deletions + current.deletions,
999
+ binaryFiles: aggregate.binaryFiles + current.binaryFiles,
1000
+ }),
1001
+ { additions: 0, deletions: 0, binaryFiles: 0 }
1002
+ );
1003
+ }
1004
+
1005
+ // Counts commits reachable from HEAD that are not present on any remote ref.
1006
+ async function countLocalOnlyCommits(cwd, context) {
1007
+ if (context.detached) {
1008
+ return 0;
1009
+ }
1010
+
1011
+ const remoteRefs = await git(cwd, "for-each-ref", "--format=%(refname)", "refs/remotes");
1012
+ const hasAnyRemoteRefs = remoteRefs
1013
+ .trim()
1014
+ .split("\n")
1015
+ .map((line) => line.trim())
1016
+ .filter(Boolean)
1017
+ .length > 0;
1018
+
1019
+ if (!hasAnyRemoteRefs) {
1020
+ return 0;
1021
+ }
1022
+
1023
+ const output = await git(cwd, "rev-list", "--count", "HEAD", "--not", "--remotes");
1024
+ return Number.parseInt(output.trim(), 10) || 0;
1025
+ }
1026
+
1027
+ function parseNumstatTotals(output) {
1028
+ return output
1029
+ .trim()
1030
+ .split("\n")
1031
+ .filter(Boolean)
1032
+ .reduce(
1033
+ (aggregate, line) => {
1034
+ const [rawAdditions, rawDeletions] = line.split("\t");
1035
+ const additions = Number.parseInt(rawAdditions, 10);
1036
+ const deletions = Number.parseInt(rawDeletions, 10);
1037
+ const isBinary = !Number.isFinite(additions) || !Number.isFinite(deletions);
1038
+
1039
+ return {
1040
+ additions: aggregate.additions + (Number.isFinite(additions) ? additions : 0),
1041
+ deletions: aggregate.deletions + (Number.isFinite(deletions) ? deletions : 0),
1042
+ binaryFiles: aggregate.binaryFiles + (isBinary ? 1 : 0),
1043
+ };
1044
+ },
1045
+ { additions: 0, deletions: 0, binaryFiles: 0 }
1046
+ );
1047
+ }
1048
+
1049
+ function resolveWorktreeChangeTransfer(rawValue) {
1050
+ const normalizedValue = typeof rawValue === "string" ? rawValue.trim().toLowerCase() : "";
1051
+ return normalizedValue === "copy" ? "copy" : "move";
1052
+ }
1053
+
1054
+ function ensureTrailingNewline(value) {
1055
+ return value.endsWith("\n") ? value : `${value}\n`;
1056
+ }
1057
+
1058
+ async function gitDiffNoIndexNumstat(cwd, filePath) {
1059
+ try {
1060
+ const { stdout } = await execFileAsync(
1061
+ "git",
1062
+ ["diff", "--no-index", "--numstat", "--", "/dev/null", filePath],
1063
+ { cwd, timeout: GIT_TIMEOUT_MS }
1064
+ );
1065
+ return stdout;
1066
+ } catch (err) {
1067
+ if (typeof err?.code === "number" && err.code === 1) {
1068
+ return err.stdout || "";
1069
+ }
1070
+ const msg = (err.stderr || err.message || "").trim();
1071
+ throw new Error(msg || "git diff --no-index failed");
1072
+ }
1073
+ }
1074
+
1075
+ async function diffPatchForUntrackedFiles(cwd, filePaths) {
1076
+ if (!filePaths.length) {
1077
+ return "";
1078
+ }
1079
+
1080
+ const patches = await Promise.all(filePaths.map((filePath) => gitDiffNoIndexPatch(cwd, filePath)));
1081
+ return patches.filter(Boolean).join("\n\n");
1082
+ }
1083
+
1084
+ async function gitDiffNoIndexPatch(cwd, filePath) {
1085
+ try {
1086
+ const { stdout } = await execFileAsync(
1087
+ "git",
1088
+ ["diff", "--no-index", "--binary", "--", "/dev/null", filePath],
1089
+ { cwd, timeout: GIT_TIMEOUT_MS }
1090
+ );
1091
+ return stdout;
1092
+ } catch (err) {
1093
+ if (typeof err?.code === "number" && err.code === 1) {
1094
+ return err.stdout || "";
1095
+ }
1096
+ const msg = (err.stderr || err.message || "").trim();
1097
+ throw new Error(msg || "git diff --no-index failed");
1098
+ }
1099
+ }
1100
+
1101
+ // ─── Helpers ──────────────────────────────────────────────────
1102
+
1103
+ function git(cwd, ...args) {
1104
+ return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
1105
+ .then(({ stdout }) => stdout)
1106
+ .catch((err) => {
1107
+ const msg = (err.stderr || err.message || "").trim();
1108
+ const wrapped = new Error(msg || "git command failed");
1109
+ throw wrapped;
1110
+ });
1111
+ }
1112
+
1113
+ async function revListCounts(cwd) {
1114
+ const output = await git(cwd, "rev-list", "--left-right", "--count", "HEAD...@{u}");
1115
+ const parts = output.trim().split(/\s+/);
1116
+ return {
1117
+ ahead: parseInt(parts[0], 10) || 0,
1118
+ behind: parseInt(parts[1], 10) || 0,
1119
+ };
1120
+ }
1121
+
1122
+ function parseBranchFromStatus(line) {
1123
+ // "## main...origin/main" or "## main" or "## HEAD (no branch)"
1124
+ const match = line.match(/^## (.+?)(?:\.{3}|$)/);
1125
+ if (!match) return null;
1126
+ const branch = match[1].trim();
1127
+ if (branch === "HEAD (no branch)" || branch.includes("HEAD detached")) return null;
1128
+ return branch;
1129
+ }
1130
+
1131
+ function parseTrackingFromStatus(line) {
1132
+ const match = line.match(/\.{3}(.+?)(?:\s|$)/);
1133
+ return match ? match[1].trim() : null;
1134
+ }
1135
+
1136
+ function computeState(dirty, ahead, behind, detached, noUpstream) {
1137
+ if (detached) return "detached_head";
1138
+ if (noUpstream) return "no_upstream";
1139
+ if (dirty && behind > 0) return "dirty_and_behind";
1140
+ if (dirty) return "dirty";
1141
+ if (ahead > 0 && behind > 0) return "diverged";
1142
+ if (behind > 0) return "behind_only";
1143
+ if (ahead > 0) return "ahead_only";
1144
+ return "up_to_date";
1145
+ }
1146
+
1147
+ async function detectDefaultBranch(cwd, branches) {
1148
+ // Try symbolic-ref first
1149
+ try {
1150
+ const ref = await git(cwd, "symbolic-ref", "refs/remotes/origin/HEAD");
1151
+ const defaultBranch = ref.trim().replace("refs/remotes/origin/", "");
1152
+ // Repo default is metadata about origin, not a promise that the local selector should show it.
1153
+ if (defaultBranch) {
1154
+ return defaultBranch;
1155
+ }
1156
+ } catch {
1157
+ // ignore
1158
+ }
1159
+
1160
+ // Some repos never record origin/HEAD locally, so prefer the common remote defaults before local fallback.
1161
+ if (await remoteBranchExists(cwd, "main")) return "main";
1162
+ if (await remoteBranchExists(cwd, "master")) return "master";
1163
+
1164
+ // Fallback: prefer main, then master
1165
+ if (branches.includes("main")) return "main";
1166
+ if (branches.includes("master")) return "master";
1167
+ return branches[0] || null;
1168
+ }
1169
+
1170
+ function gitError(errorCode, userMessage) {
1171
+ const err = new Error(userMessage);
1172
+ err.errorCode = errorCode;
1173
+ err.userMessage = userMessage;
1174
+ return err;
1175
+ }
1176
+
1177
+ // Resolves git commands to a concrete local directory.
1178
+ async function resolveGitCwd(params) {
1179
+ const requestedCwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory]);
1180
+
1181
+ if (!requestedCwd) {
1182
+ throw gitError(
1183
+ "missing_working_directory",
1184
+ "Git actions require a bound local working directory."
1185
+ );
1186
+ }
1187
+
1188
+ if (!isExistingDirectory(requestedCwd)) {
1189
+ throw gitError(
1190
+ "missing_working_directory",
1191
+ "The requested local working directory does not exist on this Mac."
1192
+ );
1193
+ }
1194
+
1195
+ return requestedCwd;
1196
+ }
1197
+
1198
+ function firstNonEmptyString(candidates) {
1199
+ for (const candidate of candidates) {
1200
+ if (typeof candidate !== "string") {
1201
+ continue;
1202
+ }
1203
+
1204
+ const trimmed = candidate.trim();
1205
+ if (trimmed) {
1206
+ return trimmed;
1207
+ }
1208
+ }
1209
+
1210
+ return null;
1211
+ }
1212
+
1213
+ function isExistingDirectory(candidatePath) {
1214
+ try {
1215
+ return fs.statSync(candidatePath).isDirectory();
1216
+ } catch {
1217
+ return false;
1218
+ }
1219
+ }
1220
+
1221
+ async function resolveRepoRoot(cwd) {
1222
+ const output = await git(cwd, "rev-parse", "--show-toplevel");
1223
+ const repoRoot = output.trim();
1224
+ return repoRoot || null;
1225
+ }
1226
+
1227
+ async function resolveLocalCheckoutRoot(cwd) {
1228
+ const output = await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
1229
+ const commonDir = output.trim();
1230
+ if (!commonDir) {
1231
+ return null;
1232
+ }
1233
+
1234
+ const normalizedCommonDir = normalizeExistingPath(commonDir);
1235
+ if (!normalizedCommonDir) {
1236
+ return null;
1237
+ }
1238
+
1239
+ if (path.basename(normalizedCommonDir) !== ".git") {
1240
+ return await resolveRepoRoot(cwd);
1241
+ }
1242
+
1243
+ const checkoutRoot = normalizeExistingPath(path.dirname(normalizedCommonDir));
1244
+ return checkoutRoot || null;
1245
+ }
1246
+
1247
+ module.exports = {
1248
+ handleGitRequest,
1249
+ gitStatus,
1250
+ __test: {
1251
+ gitBranches,
1252
+ gitCreateBranch,
1253
+ gitCreateWorktree,
1254
+ gitCheckout,
1255
+ gitRemoveWorktree,
1256
+ isManagedWorktreePath,
1257
+ normalizeBranchListEntry,
1258
+ normalizeCreatedBranchName,
1259
+ parseWorktreePathByBranch,
1260
+ ensureTrailingNewline,
1261
+ resolveWorktreeChangeTransfer,
1262
+ resolveLocalCheckoutRoot,
1263
+ scopedLocalCheckoutPath,
1264
+ scopedWorktreePath,
1265
+ resolveBaseBranchName,
1266
+ },
1267
+ };