@makerbi/remodex 1.3.8

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,2371 @@
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, spawn } = 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 GIT_DRAFT_TIMEOUT_MS = 120_000;
17
+ const GIT_DRAFT_PATCH_MAX_BYTES = 80_000;
18
+ const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
19
+ const DEFAULT_GIT_WRITER_MODEL = "gpt-5.4-mini";
20
+
21
+ let runStructuredCodexJsonImpl = runStructuredCodexJson;
22
+
23
+ function resolveGitWriterModel(rawModel) {
24
+ const trimmed = typeof rawModel === "string" ? rawModel.trim() : "";
25
+ return trimmed || DEFAULT_GIT_WRITER_MODEL;
26
+ }
27
+
28
+ /**
29
+ * Intercepts git/* JSON-RPC methods and executes git commands locally.
30
+ * @param {string} rawMessage - Raw WebSocket message
31
+ * @param {(response: string) => void} sendResponse - Callback to send response back
32
+ * @returns {boolean} true if message was handled, false if it should pass through
33
+ */
34
+ function handleGitRequest(rawMessage, sendResponse, options = {}) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(rawMessage);
38
+ } catch {
39
+ return false;
40
+ }
41
+
42
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
43
+ if (!method.startsWith("git/") && !["thread/generateTitle", "thread/name/set"].includes(method)) {
44
+ return false;
45
+ }
46
+
47
+ const id = parsed.id;
48
+ const params = parsed.params || {};
49
+
50
+ handleGitMethod(method, params, options)
51
+ .then((result) => {
52
+ sendResponse(JSON.stringify({ id, result }));
53
+ if (method === "thread/name/set") {
54
+ options.onThreadNameSet?.(result);
55
+ }
56
+ })
57
+ .catch((err) => {
58
+ const errorCode = err.errorCode || "git_error";
59
+ const message = err.userMessage || err.message || "Unknown git error";
60
+ sendResponse(
61
+ JSON.stringify({
62
+ id,
63
+ error: {
64
+ code: -32000,
65
+ message,
66
+ data: { errorCode },
67
+ },
68
+ })
69
+ );
70
+ });
71
+
72
+ return true;
73
+ }
74
+
75
+ async function handleGitMethod(method, params, options = {}) {
76
+ if (method === "thread/generateTitle") {
77
+ return threadGenerateTitle(params, options);
78
+ }
79
+ if (method === "thread/name/set") {
80
+ return threadNameSet(params);
81
+ }
82
+
83
+ const cwd = await resolveGitCwd(params);
84
+
85
+ switch (method) {
86
+ case "git/status":
87
+ return gitStatus(cwd);
88
+ case "git/init":
89
+ return gitInit(cwd);
90
+ case "git/diff":
91
+ return gitDiff(cwd);
92
+ case "git/commit":
93
+ return gitCommit(cwd, params);
94
+ case "git/generateCommitMessage":
95
+ return gitGenerateCommitMessage(cwd, params, options);
96
+ case "git/push":
97
+ return gitPush(cwd);
98
+ case "git/pull":
99
+ return gitPull(cwd);
100
+ case "git/branches":
101
+ return gitBranches(cwd);
102
+ case "git/checkout":
103
+ return gitCheckout(cwd, params);
104
+ case "git/log":
105
+ return gitLog(cwd);
106
+ case "git/createBranch":
107
+ return gitCreateBranch(cwd, params);
108
+ case "git/createWorktree":
109
+ return gitCreateWorktree(cwd, params);
110
+ case "git/createManagedWorktree":
111
+ return gitCreateManagedWorktree(cwd, params);
112
+ case "git/transferManagedHandoff":
113
+ return gitTransferManagedHandoff(cwd, params);
114
+ case "git/removeWorktree":
115
+ return gitRemoveWorktree(cwd, params);
116
+ case "git/stash":
117
+ return gitStash(cwd);
118
+ case "git/stashPop":
119
+ return gitStashPop(cwd);
120
+ case "git/resetToRemote":
121
+ return gitResetToRemote(cwd, params);
122
+ case "git/remoteUrl":
123
+ return gitRemoteUrl(cwd);
124
+ case "git/generatePullRequestDraft":
125
+ return gitGeneratePullRequestDraft(cwd, params, options);
126
+ case "git/branchesWithStatus":
127
+ return gitBranchesWithStatus(cwd);
128
+ default:
129
+ throw gitError("unknown_method", `Unknown git method: ${method}`);
130
+ }
131
+ }
132
+
133
+ // Owns mobile thread renames locally so they do not fall through to unsupported Codex RPC.
134
+ function threadNameSet(params) {
135
+ const threadId = normalizeNonEmptyLine(params.threadId || params.thread_id || params.conversationId || params.conversation_id);
136
+ const name = normalizeNonEmptyLine(params.name || params.threadName || params.thread_name || params.title);
137
+ if (!threadId) {
138
+ throw gitError("missing_thread_id", "A thread ID is required to rename a thread.");
139
+ }
140
+ if (!name) {
141
+ throw gitError("missing_thread_name", "A thread name is required.");
142
+ }
143
+
144
+ return { threadId, thread_id: threadId, name, title: name };
145
+ }
146
+
147
+ // ─── Git Status ───────────────────────────────────────────────
148
+
149
+ async function gitStatus(cwd) {
150
+ if (!(await isInsideGitWorkTree(cwd))) {
151
+ return nonRepositoryStatus(cwd);
152
+ }
153
+
154
+ const [porcelain, branchInfo, repoRoot] = await Promise.all([
155
+ git(cwd, "status", "--porcelain=v1", "-b"),
156
+ revListCounts(cwd).catch(() => ({ ahead: 0, behind: 0 })),
157
+ resolveRepoRoot(cwd).catch(() => null),
158
+ ]);
159
+
160
+ const lines = porcelain.trim().split("\n").filter(Boolean);
161
+ const branchLine = lines[0] || "";
162
+ const fileLines = lines.slice(1);
163
+
164
+ const branch = parseBranchFromStatus(branchLine);
165
+ const tracking = parseTrackingFromStatus(branchLine);
166
+ const files = fileLines.map((line) => ({
167
+ path: line.substring(3).trim(),
168
+ status: line.substring(0, 2).trim(),
169
+ }));
170
+
171
+ const dirty = files.length > 0;
172
+ const { ahead, behind } = branchInfo;
173
+ const detached = branchLine.includes("HEAD detached") || branchLine.includes("no branch");
174
+ const noUpstream = tracking === null && !detached;
175
+ const hasHeadCommit = await refExists(cwd, "HEAD").catch(() => false);
176
+ const hasPushRemote = await pushRemoteAvailable(cwd, tracking).catch(() => false);
177
+ const publishedToRemote = !detached && !!branch && await remoteBranchExists(cwd, branch).catch(() => false);
178
+ const localOnlyCommitCount = await countLocalOnlyCommits(cwd, { detached }).catch(() => 0);
179
+ const state = computeState(dirty, ahead, behind, detached, noUpstream);
180
+ const canPush = hasPushRemote && hasHeadCommit && (ahead > 0 || noUpstream) && !detached;
181
+ const diff = await repoDiffTotals(cwd, {
182
+ tracking,
183
+ fileLines,
184
+ }).catch(() => ({ additions: 0, deletions: 0, binaryFiles: 0 }));
185
+
186
+ return {
187
+ isRepo: true,
188
+ repoRoot,
189
+ branch,
190
+ tracking,
191
+ dirty,
192
+ hasHeadCommit,
193
+ hasPushRemote,
194
+ ahead,
195
+ behind,
196
+ localOnlyCommitCount,
197
+ state,
198
+ canPush,
199
+ publishedToRemote,
200
+ files,
201
+ diff,
202
+ };
203
+ }
204
+
205
+ async function gitInit(cwd) {
206
+ if (await isInsideGitWorkTree(cwd)) {
207
+ throw gitError("already_git_repository", "This folder is already inside a Git repository.");
208
+ }
209
+
210
+ if (fs.existsSync(path.join(cwd, ".git"))) {
211
+ throw gitError("git_metadata_exists", "A .git entry already exists in this folder.");
212
+ }
213
+
214
+ try {
215
+ await git(cwd, "init", "-b", "main");
216
+ } catch (err) {
217
+ if (gitInitBranchFlagUnsupported(err)) {
218
+ await git(cwd, "init");
219
+ await git(cwd, "symbolic-ref", "HEAD", "refs/heads/main");
220
+ } else {
221
+ throw gitError("git_init_failed", err.message || "Git initialization failed.");
222
+ }
223
+ }
224
+
225
+ return { status: await gitStatus(cwd) };
226
+ }
227
+
228
+ // ─── Git Diff ─────────────────────────────────────────────────
229
+
230
+ async function gitDiff(cwd) {
231
+ const porcelain = await git(cwd, "status", "--porcelain=v1", "-b");
232
+ const lines = porcelain.trim().split("\n").filter(Boolean);
233
+ const branchLine = lines[0] || "";
234
+ const fileLines = lines.slice(1);
235
+ const tracking = parseTrackingFromStatus(branchLine);
236
+ const baseRef = await resolveRepoDiffBase(cwd, tracking);
237
+ const trackedPatch = await gitDiffAgainstBase(cwd, baseRef);
238
+ const untrackedPaths = fileLines
239
+ .filter((line) => line.startsWith("?? "))
240
+ .map((line) => line.substring(3).trim())
241
+ .filter(Boolean);
242
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
243
+ const patch = [trackedPatch.trim(), untrackedPatch.trim()].filter(Boolean).join("\n\n").trim();
244
+ return { patch };
245
+ }
246
+
247
+ // ─── Git Commit ───────────────────────────────────────────────
248
+
249
+ async function gitCommit(cwd, params) {
250
+ const message =
251
+ typeof params.message === "string" && params.message.trim()
252
+ ? params.message.trim()
253
+ : "Changes from Codex";
254
+
255
+ // Check for changes first
256
+ const statusCheck = await git(cwd, "status", "--porcelain");
257
+ if (!statusCheck.trim()) {
258
+ throw gitError("nothing_to_commit", "Nothing to commit.");
259
+ }
260
+
261
+ await git(cwd, "add", "-A");
262
+ const output = await git(cwd, "commit", "-m", message);
263
+
264
+ const hashMatch = output.match(/\[(\S+)\s+([a-f0-9]+)\]/);
265
+ const hash = hashMatch ? hashMatch[2] : "";
266
+ const branch = hashMatch ? hashMatch[1] : "";
267
+ const summaryMatch = output.match(/\d+ files? changed/);
268
+ const summary = summaryMatch ? summaryMatch[0] : output.split("\n").pop()?.trim() || "";
269
+
270
+ return { hash, branch, summary };
271
+ }
272
+
273
+ // ─── Git Draft Generation ────────────────────────────────────
274
+
275
+ async function gitGenerateCommitMessage(cwd, params, options = {}) {
276
+ const model = resolveGitWriterModel(params.model);
277
+
278
+ try {
279
+ const context = await buildCommitDraftContext(cwd);
280
+ const prompt = buildCommitDraftPrompt(context);
281
+ const schema = {
282
+ type: "object",
283
+ properties: {
284
+ subject: { type: "string" },
285
+ body: { type: "string" },
286
+ fullMessage: { type: "string" },
287
+ },
288
+ required: ["subject", "body", "fullMessage"],
289
+ additionalProperties: false,
290
+ };
291
+ const draft = await runStructuredCodexJsonImpl({
292
+ cwd,
293
+ model,
294
+ prompt,
295
+ schema,
296
+ codexAppPath: options.codexAppPath,
297
+ });
298
+
299
+ return normalizeCommitDraft(draft);
300
+ } catch (error) {
301
+ if (error?.errorCode) {
302
+ throw error;
303
+ }
304
+ throw wrapDraftGenerationError(error, "commit");
305
+ }
306
+ }
307
+
308
+ async function gitGeneratePullRequestDraft(cwd, params, options = {}) {
309
+ const model = resolveGitWriterModel(params.model);
310
+
311
+ try {
312
+ const context = await buildPullRequestDraftContext(cwd, params);
313
+ const prompt = buildPullRequestDraftPrompt(context);
314
+ const schema = {
315
+ type: "object",
316
+ properties: {
317
+ title: { type: "string" },
318
+ body: { type: "string" },
319
+ },
320
+ required: ["title", "body"],
321
+ additionalProperties: false,
322
+ };
323
+ const draft = await runStructuredCodexJsonImpl({
324
+ cwd,
325
+ model,
326
+ prompt,
327
+ schema,
328
+ codexAppPath: options.codexAppPath,
329
+ });
330
+
331
+ return normalizePullRequestDraft(draft);
332
+ } catch (error) {
333
+ if (error?.errorCode) {
334
+ throw error;
335
+ }
336
+ throw wrapDraftGenerationError(error, "pull_request");
337
+ }
338
+ }
339
+
340
+ async function threadGenerateTitle(params, options = {}) {
341
+ const model = resolveGitWriterModel(params.model);
342
+ const message = normalizeNonEmptyMultilineString(params.message || params.prompt);
343
+ if (!message) {
344
+ throw gitError("missing_thread_title_message", "A first message is required to generate a thread title.");
345
+ }
346
+
347
+ try {
348
+ const cwd = resolveThreadTitleCwd(params.cwd || params.workingDirectory);
349
+ const prompt = buildThreadTitlePrompt({
350
+ message,
351
+ attachmentCount: normalizeNonNegativeInteger(params.attachmentCount),
352
+ });
353
+ const schema = {
354
+ type: "object",
355
+ properties: {
356
+ title: { type: "string" },
357
+ },
358
+ required: ["title"],
359
+ additionalProperties: false,
360
+ };
361
+ const draft = await runStructuredCodexJsonImpl({
362
+ cwd,
363
+ model,
364
+ prompt,
365
+ schema,
366
+ codexAppPath: options.codexAppPath,
367
+ skipGitRepoCheck: true,
368
+ sandboxMode: "read-only",
369
+ });
370
+
371
+ return normalizeThreadTitleDraft(draft, message);
372
+ } catch (error) {
373
+ if (error?.errorCode) {
374
+ throw error;
375
+ }
376
+ throw wrapDraftGenerationError(error, "thread_title");
377
+ }
378
+ }
379
+
380
+ // ─── Git Push ─────────────────────────────────────────────────
381
+
382
+ async function gitPush(cwd) {
383
+ try {
384
+ const statusOutput = await git(cwd, "status", "--porcelain=v1", "-b");
385
+ const branchLine = statusOutput.trim().split("\n").filter(Boolean)[0] || "";
386
+ const tracking = parseTrackingFromStatus(branchLine);
387
+ if (!(await pushRemoteAvailable(cwd, tracking))) {
388
+ throw gitError("no_remote", "Add a Git remote before pushing.");
389
+ }
390
+ const remote = trackingRemoteName(tracking) || "origin";
391
+
392
+ const branchOutput = await git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
393
+ const branch = branchOutput.trim();
394
+
395
+ // Try normal push first; if no upstream, set it
396
+ try {
397
+ await git(cwd, "push");
398
+ } catch (pushErr) {
399
+ if (
400
+ pushErr.message?.includes("no upstream") ||
401
+ pushErr.message?.includes("has no upstream branch")
402
+ ) {
403
+ await git(cwd, "push", "--set-upstream", "origin", branch);
404
+ } else {
405
+ throw pushErr;
406
+ }
407
+ }
408
+
409
+ const status = await gitStatus(cwd);
410
+ return { branch, remote, status };
411
+ } catch (err) {
412
+ if (err.errorCode) throw err;
413
+ if (err.message?.includes("rejected")) {
414
+ throw gitError("push_rejected", "Push rejected. Pull changes first.");
415
+ }
416
+ throw gitError("push_failed", err.message || "Push failed.");
417
+ }
418
+ }
419
+
420
+ // ─── Git Pull ─────────────────────────────────────────────────
421
+
422
+ async function gitPull(cwd) {
423
+ try {
424
+ await git(cwd, "pull", "--rebase");
425
+ const status = await gitStatus(cwd);
426
+ return { success: true, status };
427
+ } catch (err) {
428
+ // Abort rebase on conflict
429
+ try {
430
+ await git(cwd, "rebase", "--abort");
431
+ } catch {
432
+ // ignore abort errors
433
+ }
434
+ if (err.errorCode) throw err;
435
+ throw gitError("pull_conflict", "Pull failed due to conflicts. Rebase aborted.");
436
+ }
437
+ }
438
+
439
+ // ─── Git Branches ─────────────────────────────────────────────
440
+
441
+ async function gitBranches(cwd) {
442
+ const [output, repoRoot, localCheckoutRoot] = await Promise.all([
443
+ git(cwd, "branch", "--no-color"),
444
+ resolveRepoRoot(cwd).catch(() => null),
445
+ resolveLocalCheckoutRoot(cwd).catch(() => null),
446
+ ]);
447
+ const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
448
+ const worktreePathByBranch = await gitWorktreePathByBranch(cwd, { projectRelativePath }).catch(() => ({}));
449
+ const localCheckoutPath = scopedLocalCheckoutPath(localCheckoutRoot || repoRoot, projectRelativePath);
450
+ const lines = output
451
+ .trim()
452
+ .split("\n")
453
+ .filter(Boolean);
454
+
455
+ let current = "";
456
+ const branchSet = new Set();
457
+ const branchesCheckedOutElsewhere = new Set();
458
+
459
+ for (const line of lines) {
460
+ const entry = normalizeBranchListEntry(line);
461
+ if (!entry) {
462
+ continue;
463
+ }
464
+
465
+ const { isCurrent, isCheckedOutElsewhere, name } = entry;
466
+
467
+ if (name.includes("HEAD detached") || name === "(no branch)") {
468
+ if (isCurrent) current = "HEAD";
469
+ continue;
470
+ }
471
+
472
+ branchSet.add(name);
473
+ if (isCheckedOutElsewhere) {
474
+ branchesCheckedOutElsewhere.add(name);
475
+ }
476
+
477
+ if (isCurrent) current = name;
478
+ }
479
+
480
+ if (!current) {
481
+ const unbornBranch = await currentBranchFromStatus(cwd).catch(() => null);
482
+ if (unbornBranch) {
483
+ current = unbornBranch;
484
+ if (!branchSet.has(unbornBranch)) {
485
+ branchSet.add(unbornBranch);
486
+ }
487
+ }
488
+ }
489
+ const resolvedBranches = [...branchSet].sort();
490
+ const defaultBranch = await detectDefaultBranch(cwd, resolvedBranches);
491
+
492
+ return {
493
+ branches: resolvedBranches,
494
+ branchesCheckedOutElsewhere: [...branchesCheckedOutElsewhere].sort(),
495
+ worktreePathByBranch,
496
+ localCheckoutPath,
497
+ current,
498
+ default: defaultBranch,
499
+ defaultBranch,
500
+ };
501
+ }
502
+
503
+ // ─── Git Checkout ─────────────────────────────────────────────
504
+
505
+ async function gitCheckout(cwd, params) {
506
+ const branch = typeof params.branch === "string" ? params.branch.trim() : "";
507
+ if (!branch) {
508
+ throw gitError("missing_branch", "Branch name is required.");
509
+ }
510
+
511
+ try {
512
+ await git(cwd, "switch", branch);
513
+ } catch (err) {
514
+ if (err.message?.includes("untracked working tree files would be overwritten")) {
515
+ throw gitError(
516
+ "checkout_conflict_untracked_collision",
517
+ "Cannot switch branches: untracked files would be overwritten."
518
+ );
519
+ }
520
+ if (err.message?.includes("local changes to the following files would be overwritten")) {
521
+ throw gitError(
522
+ "checkout_conflict_dirty_tree",
523
+ "Cannot switch branches: tracked local changes would be overwritten."
524
+ );
525
+ }
526
+ if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
527
+ throw gitError(
528
+ "checkout_branch_in_other_worktree",
529
+ "Cannot switch branches: this branch is already open in another worktree."
530
+ );
531
+ }
532
+ if (err.message?.includes("invalid reference") || err.message?.includes("unknown revision")) {
533
+ throw gitError("branch_not_found", `Branch '${branch}' does not exist locally.`);
534
+ }
535
+ throw gitError("checkout_failed", err.message || "Checkout failed.");
536
+ }
537
+
538
+ const status = await gitStatus(cwd);
539
+ return { current: status.branch || branch, tracking: status.tracking, status };
540
+ }
541
+
542
+ // ─── Git Log ──────────────────────────────────────────────────
543
+
544
+ async function gitLog(cwd) {
545
+ const output = await git(
546
+ cwd,
547
+ "log",
548
+ "-20",
549
+ "--format=%H%x00%s%x00%an%x00%aI"
550
+ );
551
+
552
+ const commits = output
553
+ .trim()
554
+ .split("\n")
555
+ .filter(Boolean)
556
+ .map((line) => {
557
+ const [hash, message, author, date] = line.split("\0");
558
+ return {
559
+ hash: hash?.substring(0, 7) || "",
560
+ message: message || "",
561
+ author: author || "",
562
+ date: date || "",
563
+ };
564
+ });
565
+
566
+ return { commits };
567
+ }
568
+
569
+ // ─── Git Create Branch ────────────────────────────────────────
570
+
571
+ async function gitCreateBranch(cwd, params) {
572
+ const name = normalizeCreatedBranchName(params.name);
573
+ if (!name) {
574
+ throw gitError("missing_branch_name", "Branch name is required.");
575
+ }
576
+ await assertValidCreatedBranchName(cwd, name);
577
+
578
+ // Keep create-branch local-first so we never fork history under a remote-only name.
579
+ if (!(await localBranchExists(cwd, name)) && await remoteBranchExists(cwd, name)) {
580
+ throw gitError(
581
+ "branch_exists",
582
+ `Branch '${name}' already exists on origin. Check it out locally instead of creating a new branch.`
583
+ );
584
+ }
585
+
586
+ try {
587
+ await git(cwd, "switch", "-c", name);
588
+ } catch (err) {
589
+ if (err.message?.includes("already exists")) {
590
+ throw gitError("branch_exists", `Branch '${name}' already exists.`);
591
+ }
592
+ throw gitError("create_branch_failed", err.message || "Failed to create branch.");
593
+ }
594
+
595
+ const status = await gitStatus(cwd);
596
+ return { branch: name, status };
597
+ }
598
+
599
+ async function gitCreateWorktree(cwd, params) {
600
+ const branch = normalizeCreatedBranchName(params.name);
601
+ if (!branch) {
602
+ throw gitError("missing_branch_name", "Branch name is required.");
603
+ }
604
+ await assertValidCreatedBranchName(cwd, branch);
605
+
606
+ const branchResult = await gitBranches(cwd);
607
+ const repoRoot = await resolveRepoRoot(cwd);
608
+ const status = await gitStatus(cwd);
609
+ const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
610
+ const changeScope = await scopedProjectChanges(repoRoot, projectRelativePath);
611
+ const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.defaultBranch);
612
+ const changeTransfer = resolveWorktreeChangeTransfer(params.changeTransfer);
613
+ if (!baseBranch) {
614
+ throw gitError("missing_base_branch", "Base branch is required.");
615
+ }
616
+ if (!(await localBranchExists(cwd, baseBranch))) {
617
+ throw gitError(
618
+ "missing_base_branch",
619
+ `Base branch '${baseBranch}' is not available locally. Create or check out that branch first.`
620
+ );
621
+ }
622
+
623
+ const currentBranch = typeof status.branch === "string" ? status.branch.trim() : "";
624
+ const canCarryLocalChanges = changeScope.dirty && !!currentBranch && currentBranch === baseBranch;
625
+ if (changeScope.dirty && changeTransfer !== "none" && !canCarryLocalChanges) {
626
+ const currentBranchLabel = currentBranch || "the current branch";
627
+ const transferVerb = changeTransfer === "copy" ? "copy" : "move";
628
+ throw gitError(
629
+ "dirty_worktree_base_mismatch",
630
+ `Uncommitted changes can ${transferVerb} into a new worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
631
+ );
632
+ }
633
+
634
+ const existingWorktreePath = branchResult.worktreePathByBranch[branch];
635
+ if (existingWorktreePath) {
636
+ if (sameFilePath(existingWorktreePath, cwd)) {
637
+ throw gitError(
638
+ "branch_already_open_here",
639
+ `Branch '${branch}' is already open in this project.`
640
+ );
641
+ }
642
+
643
+ return {
644
+ branch,
645
+ worktreePath: existingWorktreePath,
646
+ alreadyExisted: true,
647
+ };
648
+ }
649
+
650
+ const branchExists = await localBranchExists(cwd, branch);
651
+ if (branchExists) {
652
+ throw gitError(
653
+ "branch_exists",
654
+ `Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
655
+ );
656
+ }
657
+
658
+ const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
659
+ let handoffStashRef = null;
660
+ let copiedLocalChangesPatch = "";
661
+ let didCreateWorktree = false;
662
+
663
+ try {
664
+ if (canCarryLocalChanges) {
665
+ if (changeTransfer === "copy") {
666
+ copiedLocalChangesPatch = await captureLocalChangesPatch(repoRoot, changeScope.pathspecArgs);
667
+ } else if (changeTransfer === "move") {
668
+ handoffStashRef = await stashChangesForWorktreeHandoff(repoRoot, changeScope.pathspecArgs);
669
+ }
670
+ }
671
+
672
+ await git(repoRoot, "worktree", "add", "-b", branch, worktreeRootPath, baseBranch);
673
+ didCreateWorktree = true;
674
+
675
+ if (handoffStashRef) {
676
+ await applyWorktreeHandoffStash(worktreeRootPath, handoffStashRef);
677
+ }
678
+ if (copiedLocalChangesPatch) {
679
+ await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
680
+ }
681
+ } catch (err) {
682
+ if (didCreateWorktree) {
683
+ await cleanupManagedWorktree(repoRoot, worktreeRootPath, branch);
684
+ } else {
685
+ fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
686
+ }
687
+
688
+ if (handoffStashRef) {
689
+ await restoreWorktreeHandoffStash(repoRoot, handoffStashRef);
690
+ }
691
+
692
+ if (err.message?.includes("invalid reference")) {
693
+ throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
694
+ }
695
+ if (err.message?.includes("already exists")) {
696
+ throw gitError("branch_exists", `Branch '${branch}' already exists.`);
697
+ }
698
+ if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
699
+ throw gitError(
700
+ "branch_in_other_worktree",
701
+ `Branch '${branch}' is already open in another worktree.`
702
+ );
703
+ }
704
+ throw gitError("create_worktree_failed", err.message || "Failed to create worktree.");
705
+ }
706
+
707
+ const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
708
+ return {
709
+ branch,
710
+ worktreePath,
711
+ alreadyExisted: false,
712
+ };
713
+ }
714
+
715
+ async function gitCreateManagedWorktree(cwd, params) {
716
+ const branchResult = await gitBranches(cwd);
717
+ const repoRoot = await resolveRepoRoot(cwd);
718
+ const status = await gitStatus(cwd);
719
+ const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
720
+ const changeScope = await scopedProjectChanges(repoRoot, projectRelativePath);
721
+ const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.defaultBranch);
722
+ const changeTransfer = resolveWorktreeChangeTransfer(params.changeTransfer);
723
+ if (!baseBranch) {
724
+ throw gitError("missing_base_branch", "Base branch is required.");
725
+ }
726
+ if (!(await localBranchExists(cwd, baseBranch))) {
727
+ throw gitError(
728
+ "missing_base_branch",
729
+ `Base branch '${baseBranch}' is not available locally. Create or check out that branch first.`
730
+ );
731
+ }
732
+
733
+ const currentBranch = typeof status.branch === "string" ? status.branch.trim() : "";
734
+ const canCarryLocalChanges = changeScope.dirty && !!currentBranch && currentBranch === baseBranch;
735
+ if (changeScope.dirty && changeTransfer !== "none" && !canCarryLocalChanges) {
736
+ const currentBranchLabel = currentBranch || "the current branch";
737
+ const transferVerb = changeTransfer === "copy" ? "copy" : "move";
738
+ throw gitError(
739
+ "dirty_worktree_base_mismatch",
740
+ `Uncommitted changes can ${transferVerb} into a managed worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
741
+ );
742
+ }
743
+
744
+ const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
745
+ let handoffStashRef = null;
746
+ let copiedLocalChangesPatch = "";
747
+ let didCreateWorktree = false;
748
+
749
+ try {
750
+ if (canCarryLocalChanges) {
751
+ if (changeTransfer === "copy") {
752
+ copiedLocalChangesPatch = await captureLocalChangesPatch(repoRoot, changeScope.pathspecArgs);
753
+ } else if (changeTransfer === "move") {
754
+ handoffStashRef = await stashChangesForWorktreeHandoff(repoRoot, changeScope.pathspecArgs);
755
+ }
756
+ }
757
+
758
+ await git(repoRoot, "worktree", "add", "--detach", worktreeRootPath, baseBranch);
759
+ didCreateWorktree = true;
760
+
761
+ if (handoffStashRef) {
762
+ await applyWorktreeHandoffStash(worktreeRootPath, handoffStashRef);
763
+ }
764
+ if (copiedLocalChangesPatch) {
765
+ await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
766
+ }
767
+ } catch (err) {
768
+ if (didCreateWorktree) {
769
+ await cleanupManagedWorktree(repoRoot, worktreeRootPath);
770
+ } else {
771
+ fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
772
+ }
773
+
774
+ if (handoffStashRef) {
775
+ await restoreWorktreeHandoffStash(repoRoot, handoffStashRef);
776
+ }
777
+
778
+ if (err.message?.includes("invalid reference")) {
779
+ throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
780
+ }
781
+ throw gitError("create_worktree_failed", err.message || "Failed to create managed worktree.");
782
+ }
783
+
784
+ const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
785
+ return {
786
+ worktreePath,
787
+ alreadyExisted: false,
788
+ baseBranch,
789
+ headMode: "detached",
790
+ transferredChanges: Boolean(handoffStashRef || copiedLocalChangesPatch),
791
+ };
792
+ }
793
+
794
+ async function gitTransferManagedHandoff(cwd, params) {
795
+ const targetPath = firstNonEmptyString([params.targetPath, params.targetProjectPath]);
796
+ if (!targetPath) {
797
+ throw gitError("missing_handoff_target", "A handoff target path is required.");
798
+ }
799
+ if (!isExistingDirectory(cwd)) {
800
+ throw gitError(
801
+ "missing_handoff_source",
802
+ "The current handoff source is not available on this Mac."
803
+ );
804
+ }
805
+ if (!isExistingDirectory(targetPath)) {
806
+ throw gitError(
807
+ "missing_handoff_target",
808
+ "The destination for this handoff is not available on this Mac."
809
+ );
810
+ }
811
+
812
+ const [sourceRepoRoot, sourceLocalCheckoutRoot, targetRepoRoot, targetLocalCheckoutRoot] = await Promise.all([
813
+ resolveRepoRoot(cwd),
814
+ resolveLocalCheckoutRoot(cwd),
815
+ resolveRepoRoot(targetPath),
816
+ resolveLocalCheckoutRoot(targetPath),
817
+ ]);
818
+
819
+ const sourceCheckoutRoot = sourceLocalCheckoutRoot || sourceRepoRoot;
820
+ const targetCheckoutRoot = targetLocalCheckoutRoot || targetRepoRoot;
821
+ if (!sameFilePath(sourceCheckoutRoot, targetCheckoutRoot)) {
822
+ throw gitError(
823
+ "handoff_target_mismatch",
824
+ "The selected handoff destination belongs to a different checkout."
825
+ );
826
+ }
827
+
828
+ if (sameFilePath(cwd, targetPath)) {
829
+ return {
830
+ success: true,
831
+ targetPath: normalizeExistingPath(targetPath) ?? targetPath,
832
+ transferredChanges: false,
833
+ };
834
+ }
835
+
836
+ const sourceProjectRelativePath = resolveProjectRelativePath(cwd, sourceRepoRoot);
837
+ const targetProjectRelativePath = resolveProjectRelativePath(targetPath, targetRepoRoot);
838
+ const [sourceChangeScope, targetChangeScope] = await Promise.all([
839
+ scopedProjectChanges(sourceRepoRoot, sourceProjectRelativePath),
840
+ scopedProjectChanges(targetRepoRoot, targetProjectRelativePath),
841
+ ]);
842
+
843
+ if (!sourceChangeScope.dirty) {
844
+ return {
845
+ success: true,
846
+ targetPath: normalizeExistingPath(targetPath) ?? targetPath,
847
+ transferredChanges: false,
848
+ };
849
+ }
850
+
851
+ if (targetChangeScope.dirty) {
852
+ throw gitError(
853
+ "handoff_target_dirty",
854
+ "The handoff destination already has uncommitted changes. Clean it up before moving this thread there."
855
+ );
856
+ }
857
+
858
+ const stashRef = await stashChangesForWorktreeHandoff(sourceRepoRoot, sourceChangeScope.pathspecArgs);
859
+ if (!stashRef) {
860
+ return {
861
+ success: true,
862
+ targetPath: normalizeExistingPath(targetPath) ?? targetPath,
863
+ transferredChanges: false,
864
+ };
865
+ }
866
+
867
+ try {
868
+ await applyWorktreeHandoffStash(targetRepoRoot, stashRef, { dropAfterApply: true });
869
+ } catch (err) {
870
+ await rollbackFailedHandoffTransfer(targetRepoRoot, targetChangeScope.pathspecArgs);
871
+ await restoreWorktreeHandoffStash(sourceRepoRoot, stashRef);
872
+ throw gitError(
873
+ "handoff_transfer_failed",
874
+ err.userMessage || err.message || "Could not move local changes into the handoff destination."
875
+ );
876
+ }
877
+
878
+ return {
879
+ success: true,
880
+ targetPath: normalizeExistingPath(targetPath) ?? targetPath,
881
+ transferredChanges: true,
882
+ };
883
+ }
884
+
885
+ async function gitRemoveWorktree(cwd, params) {
886
+ const worktreeRootPath = await resolveRepoRoot(cwd).catch(() => null);
887
+ const localCheckoutRoot = await resolveLocalCheckoutRoot(cwd).catch(() => null);
888
+ const branch = typeof params.branch === "string" ? params.branch.trim() : "";
889
+
890
+ if (!worktreeRootPath || !localCheckoutRoot) {
891
+ throw gitError("missing_working_directory", "Could not resolve the worktree roots for cleanup.");
892
+ }
893
+ if (sameFilePath(worktreeRootPath, localCheckoutRoot)) {
894
+ throw gitError("cannot_remove_local_checkout", "Cannot remove the main local checkout.");
895
+ }
896
+ if (!isManagedWorktreePath(worktreeRootPath)) {
897
+ throw gitError("unmanaged_worktree", "Only managed worktrees can be removed automatically.");
898
+ }
899
+
900
+ await cleanupManagedWorktree(localCheckoutRoot, worktreeRootPath, branch || null);
901
+ if (branch && await localBranchExists(localCheckoutRoot, branch)) {
902
+ throw gitError(
903
+ "worktree_cleanup_failed",
904
+ `The temporary worktree was removed, but branch '${branch}' could not be deleted automatically.`
905
+ );
906
+ }
907
+ return { success: true };
908
+ }
909
+
910
+ // ─── Git Stash ────────────────────────────────────────────────
911
+
912
+ async function gitStash(cwd) {
913
+ const output = await git(cwd, "stash", "push", "--include-untracked");
914
+ const saved = !output.includes("No local changes");
915
+ return { success: saved, message: output.trim() };
916
+ }
917
+
918
+ // ─── Git Stash Pop ────────────────────────────────────────────
919
+
920
+ async function gitStashPop(cwd) {
921
+ try {
922
+ const output = await git(cwd, "stash", "pop");
923
+ return { success: true, message: output.trim() };
924
+ } catch (err) {
925
+ throw gitError("stash_pop_conflict", err.message || "Stash pop failed due to conflicts.");
926
+ }
927
+ }
928
+
929
+ // ─── Git Reset to Remote ──────────────────────────────────────
930
+
931
+ async function gitResetToRemote(cwd, params) {
932
+ if (params.confirm !== "discard_runtime_changes") {
933
+ throw gitError(
934
+ "confirmation_required",
935
+ 'This action requires params.confirm === "discard_runtime_changes".'
936
+ );
937
+ }
938
+
939
+ let hasUpstream = true;
940
+ try {
941
+ await git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}");
942
+ } catch {
943
+ hasUpstream = false;
944
+ }
945
+
946
+ if (hasUpstream) {
947
+ await git(cwd, "fetch");
948
+ await git(cwd, "reset", "--hard", "@{u}");
949
+ } else {
950
+ await git(cwd, "checkout", "--", ".");
951
+ }
952
+ await git(cwd, "clean", "-fd");
953
+
954
+ const status = await gitStatus(cwd);
955
+ return { success: true, status };
956
+ }
957
+
958
+ // ─── Git Remote URL ───────────────────────────────────────────
959
+
960
+ async function gitRemoteUrl(cwd) {
961
+ const raw = (await git(cwd, "config", "--get", "remote.origin.url")).trim();
962
+ const ownerRepo = parseOwnerRepo(raw);
963
+ return { url: raw, ownerRepo };
964
+ }
965
+
966
+ async function buildCommitDraftContext(cwd) {
967
+ const [statusResult, repoRoot] = await Promise.all([
968
+ gitStatus(cwd),
969
+ resolveRepoRoot(cwd).catch(() => cwd),
970
+ ]);
971
+
972
+ if (!statusResult.dirty) {
973
+ throw gitError("nothing_to_commit", "Nothing to commit.");
974
+ }
975
+
976
+ const trackedBase = await refExists(cwd, "HEAD") ? "HEAD" : EMPTY_TREE_HASH;
977
+ const trackedPatch = await git(cwd, "diff", "--binary", "--find-renames", trackedBase);
978
+ const untrackedPaths = statusResult.files
979
+ .filter((file) => file.status === "??")
980
+ .map((file) => file.path)
981
+ .filter(Boolean);
982
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
983
+ const patch = truncateDraftPatch(
984
+ [trackedPatch.trim(), untrackedPatch.trim()].filter(Boolean).join("\n\n").trim()
985
+ );
986
+
987
+ if (!patch) {
988
+ throw gitError("nothing_to_commit", "Nothing to commit.");
989
+ }
990
+
991
+ return {
992
+ repoRoot,
993
+ branch: statusResult.branch || "HEAD",
994
+ files: statusResult.files,
995
+ diff: statusResult.diff || { additions: 0, deletions: 0, binaryFiles: 0 },
996
+ patch,
997
+ };
998
+ }
999
+
1000
+ async function buildPullRequestDraftContext(cwd, params) {
1001
+ const branchResult = await gitBranches(cwd);
1002
+ const currentBranch = (branchResult.current || "").trim();
1003
+ const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.default || branchResult.defaultBranch);
1004
+
1005
+ if (!currentBranch) {
1006
+ throw gitError("no_branch", "No current branch found.");
1007
+ }
1008
+
1009
+ if (!baseBranch) {
1010
+ throw gitError("no_default_branch", "Could not determine the repository default branch.");
1011
+ }
1012
+
1013
+ const baseRef = await resolveExistingBranchRef(cwd, baseBranch);
1014
+ const mergeBase = (await git(cwd, "merge-base", "HEAD", baseRef)).trim();
1015
+ const patch = truncateDraftPatch(
1016
+ (await git(cwd, "diff", "--binary", "--find-renames", `${mergeBase}..HEAD`)).trim()
1017
+ );
1018
+ const numstatOutput = await git(cwd, "diff", "--numstat", `${mergeBase}..HEAD`);
1019
+ const diff = parseNumstatTotals(numstatOutput);
1020
+ const commitList = (
1021
+ await git(cwd, "log", "--format=%h %s", `${mergeBase}..HEAD`)
1022
+ )
1023
+ .trim()
1024
+ .split("\n")
1025
+ .map((line) => line.trim())
1026
+ .filter(Boolean)
1027
+ .slice(0, 40);
1028
+
1029
+ if (!patch && commitList.length === 0) {
1030
+ throw gitError("nothing_to_compare", "No branch changes are available for a pull request.");
1031
+ }
1032
+
1033
+ return {
1034
+ repoRoot: await resolveRepoRoot(cwd).catch(() => cwd),
1035
+ currentBranch,
1036
+ baseBranch,
1037
+ mergeBase,
1038
+ diff,
1039
+ commitList,
1040
+ patch,
1041
+ };
1042
+ }
1043
+
1044
+ async function resolveExistingBranchRef(cwd, branchName) {
1045
+ const localRef = `refs/heads/${branchName}`;
1046
+ const remoteRef = `refs/remotes/origin/${branchName}`;
1047
+
1048
+ if (await refExists(cwd, localRef)) {
1049
+ return localRef;
1050
+ }
1051
+ if (await refExists(cwd, remoteRef)) {
1052
+ return remoteRef;
1053
+ }
1054
+
1055
+ return branchName;
1056
+ }
1057
+
1058
+ async function refExists(cwd, refName) {
1059
+ try {
1060
+ await git(cwd, "show-ref", "--verify", "--quiet", refName);
1061
+ return true;
1062
+ } catch {
1063
+ return false;
1064
+ }
1065
+ }
1066
+
1067
+ function buildCommitDraftPrompt(context) {
1068
+ const changedFiles = context.files
1069
+ .map((file) => `- ${file.status || "M"} ${file.path}`)
1070
+ .join("\n");
1071
+
1072
+ return [
1073
+ "Write a detailed Git commit message from the repository context below.",
1074
+ "Return JSON only that matches the provided schema.",
1075
+ "Rules:",
1076
+ "- `subject` must be imperative, 72 characters or fewer, and must not end with a period.",
1077
+ "- `body` must be non-empty and use 2 to 5 concise Markdown bullets.",
1078
+ "- `fullMessage` must equal the final commit text: subject, blank line, then body.",
1079
+ "- Do not mention AI, Codex, prompt instructions, or that the message was generated.",
1080
+ "",
1081
+ `Repository: ${context.repoRoot}`,
1082
+ `Branch: ${context.branch}`,
1083
+ `Diff totals: +${context.diff.additions} -${context.diff.deletions} binary=${context.diff.binaryFiles}`,
1084
+ "Changed files:",
1085
+ changedFiles || "- (none)",
1086
+ "",
1087
+ "Patch:",
1088
+ "```diff",
1089
+ context.patch,
1090
+ "```",
1091
+ ].join("\n");
1092
+ }
1093
+
1094
+ function truncateDraftPatch(patch) {
1095
+ if (Buffer.byteLength(patch, "utf8") <= GIT_DRAFT_PATCH_MAX_BYTES) {
1096
+ return patch;
1097
+ }
1098
+
1099
+ let byteCount = 0;
1100
+ const keptLines = [];
1101
+ for (const line of patch.split("\n")) {
1102
+ const lineBytes = Buffer.byteLength(`${line}\n`, "utf8");
1103
+ if (byteCount + lineBytes > GIT_DRAFT_PATCH_MAX_BYTES) {
1104
+ break;
1105
+ }
1106
+ keptLines.push(line);
1107
+ byteCount += lineBytes;
1108
+ }
1109
+
1110
+ return [
1111
+ ...keptLines,
1112
+ "",
1113
+ `[Diff truncated for draft generation after ${GIT_DRAFT_PATCH_MAX_BYTES} bytes.]`,
1114
+ ].join("\n");
1115
+ }
1116
+
1117
+ function buildPullRequestDraftPrompt(context) {
1118
+ const commitLines = context.commitList.length > 0 ? context.commitList.map((line) => `- ${line}`).join("\n") : "- None";
1119
+
1120
+ return [
1121
+ "Write a pull request title and body from the repository context below.",
1122
+ "Return JSON only that matches the provided schema.",
1123
+ "Rules:",
1124
+ "- `title` should be concise and readable on GitHub.",
1125
+ "- `body` must be Markdown with exactly these top-level sections: `## Summary`, `## Testing`, `## Notes`.",
1126
+ "- In `## Testing`, explicitly say when testing was not run or could not be verified. Do not invent test results.",
1127
+ "- Keep the body specific to the actual diff and commits.",
1128
+ "- Do not mention AI, Codex, prompt instructions, or that the text was generated.",
1129
+ "",
1130
+ `Repository: ${context.repoRoot}`,
1131
+ `Base branch: ${context.baseBranch}`,
1132
+ `Current branch: ${context.currentBranch}`,
1133
+ `Merge base: ${context.mergeBase}`,
1134
+ `Diff totals: +${context.diff.additions} -${context.diff.deletions} binary=${context.diff.binaryFiles}`,
1135
+ "Commits since base:",
1136
+ commitLines,
1137
+ "",
1138
+ "Patch:",
1139
+ "```diff",
1140
+ context.patch,
1141
+ "```",
1142
+ ].join("\n");
1143
+ }
1144
+
1145
+ function buildThreadTitlePrompt(context) {
1146
+ const attachmentLine = context.attachmentCount > 0
1147
+ ? `Attachments: ${context.attachmentCount} image${context.attachmentCount === 1 ? "" : "s"}`
1148
+ : "Attachments: none";
1149
+
1150
+ return [
1151
+ "Write a short chat thread title from the user's first message.",
1152
+ "Return JSON only that matches the provided schema.",
1153
+ "Rules:",
1154
+ "- `title` must be 2 to 4 words, maximum 4 words.",
1155
+ "- Use a concise noun or verb phrase that captures the task.",
1156
+ "- Do not use markdown, quotes, emoji, or final punctuation.",
1157
+ "- Do not mention AI, Codex, prompt instructions, or that the title was generated.",
1158
+ "",
1159
+ attachmentLine,
1160
+ "",
1161
+ "First message:",
1162
+ messageForPrompt(context.message),
1163
+ ].join("\n");
1164
+ }
1165
+
1166
+ function messageForPrompt(message) {
1167
+ const trimmed = normalizeNonEmptyMultilineString(message);
1168
+ if (trimmed.length <= 4_000) {
1169
+ return trimmed;
1170
+ }
1171
+ return `${trimmed.slice(0, 4_000).trimEnd()}\n[Message truncated for title generation.]`;
1172
+ }
1173
+
1174
+ function normalizeCommitDraft(draft) {
1175
+ const subject = normalizeCommitSubject(draft?.subject);
1176
+ const body = normalizeNonEmptyMultilineString(draft?.body);
1177
+
1178
+ if (!subject || !body) {
1179
+ throw new Error("Commit draft was missing a valid subject or body.");
1180
+ }
1181
+
1182
+ const fullMessage = `${subject}\n\n${body}`;
1183
+ return { subject, body, fullMessage };
1184
+ }
1185
+
1186
+ function normalizePullRequestDraft(draft) {
1187
+ const title = normalizeNonEmptyLine(draft?.title);
1188
+ const body = normalizeNonEmptyMultilineString(draft?.body);
1189
+
1190
+ if (!title || !body) {
1191
+ throw new Error("Pull request draft was missing a valid title or body.");
1192
+ }
1193
+
1194
+ const requiredHeadings = ["## Summary", "## Testing", "## Notes"];
1195
+ if (!requiredHeadings.every((heading) => body.includes(heading))) {
1196
+ throw new Error("Pull request draft body was missing one or more required sections.");
1197
+ }
1198
+
1199
+ return { title, body };
1200
+ }
1201
+
1202
+ function normalizeThreadTitleDraft(draft, fallbackMessage) {
1203
+ const title = sanitizeGeneratedThreadTitle(draft?.title || buildPromptThreadTitleFallback(fallbackMessage));
1204
+ return { title };
1205
+ }
1206
+
1207
+ function buildPromptThreadTitleFallback(message) {
1208
+ const words = tokenizeThreadTitleWords(message);
1209
+ if (words.length === 0) {
1210
+ return "New Thread";
1211
+ }
1212
+ return titleCaseThreadTitle(words.slice(0, 4).join(" "));
1213
+ }
1214
+
1215
+ function sanitizeGeneratedThreadTitle(rawTitle) {
1216
+ const words = tokenizeThreadTitleWords(rawTitle);
1217
+ const title = words.slice(0, 4).join(" ");
1218
+ return titleCaseThreadTitle(title || "New Thread").slice(0, 50).trim() || "New Thread";
1219
+ }
1220
+
1221
+ function tokenizeThreadTitleWords(value) {
1222
+ if (typeof value !== "string") {
1223
+ return [];
1224
+ }
1225
+
1226
+ return value
1227
+ .replace(/[`*_~#[\](){}<>]/g, " ")
1228
+ .replace(/[.!?;:,,。!?;:]+$/g, "")
1229
+ .replace(/["'“”‘’]/g, "")
1230
+ .split(/\s+/)
1231
+ .map((word) => word.trim().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
1232
+ .filter(Boolean);
1233
+ }
1234
+
1235
+ function titleCaseThreadTitle(value) {
1236
+ const trimmed = typeof value === "string" ? value.trim() : "";
1237
+ if (!trimmed) {
1238
+ return "";
1239
+ }
1240
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
1241
+ }
1242
+
1243
+ function normalizeCommitSubject(rawValue) {
1244
+ const trimmed = normalizeNonEmptyLine(rawValue);
1245
+ if (!trimmed) {
1246
+ return "";
1247
+ }
1248
+
1249
+ const withoutTrailingPeriod = trimmed.replace(/\.+$/, "");
1250
+ if (!withoutTrailingPeriod || withoutTrailingPeriod.length > 72) {
1251
+ return "";
1252
+ }
1253
+
1254
+ return withoutTrailingPeriod;
1255
+ }
1256
+
1257
+ function normalizeNonEmptyLine(rawValue) {
1258
+ if (typeof rawValue !== "string") {
1259
+ return "";
1260
+ }
1261
+
1262
+ return rawValue
1263
+ .split("\n")[0]
1264
+ .trim();
1265
+ }
1266
+
1267
+ function normalizeNonEmptyMultilineString(rawValue) {
1268
+ if (typeof rawValue !== "string") {
1269
+ return "";
1270
+ }
1271
+
1272
+ const trimmed = rawValue.trim();
1273
+ return trimmed || "";
1274
+ }
1275
+
1276
+ function wrapDraftGenerationError(error, kind) {
1277
+ const detail = normalizeDraftErrorDetail(error);
1278
+ if (kind === "commit") {
1279
+ return gitError(
1280
+ "commit_message_generation_failed",
1281
+ detail ? `Could not generate a commit message. ${detail}` : "Could not generate a commit message."
1282
+ );
1283
+ }
1284
+
1285
+ if (kind === "thread_title") {
1286
+ return gitError(
1287
+ "thread_title_generation_failed",
1288
+ detail ? `Could not generate a thread title. ${detail}` : "Could not generate a thread title."
1289
+ );
1290
+ }
1291
+
1292
+ return gitError(
1293
+ "pull_request_draft_generation_failed",
1294
+ detail ? `Could not generate a pull request draft. ${detail}` : "Could not generate a pull request draft."
1295
+ );
1296
+ }
1297
+
1298
+ function normalizeDraftErrorDetail(error) {
1299
+ const rawMessage = typeof error?.userMessage === "string"
1300
+ ? error.userMessage
1301
+ : typeof error?.message === "string"
1302
+ ? error.message
1303
+ : "";
1304
+ const trimmed = rawMessage.trim();
1305
+ if (!trimmed) {
1306
+ return "";
1307
+ }
1308
+
1309
+ const singleLine = trimmed.split("\n").map((line) => line.trim()).filter(Boolean).pop() || trimmed;
1310
+ return singleLine.endsWith(".") ? singleLine : `${singleLine}.`;
1311
+ }
1312
+
1313
+ function normalizeNonNegativeInteger(value) {
1314
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
1315
+ }
1316
+
1317
+ function resolveThreadTitleCwd(rawCwd) {
1318
+ const normalized = normalizeExistingPath(typeof rawCwd === "string" ? rawCwd : "");
1319
+ if (normalized && isExistingDirectory(normalized)) {
1320
+ return normalized;
1321
+ }
1322
+ return process.cwd();
1323
+ }
1324
+
1325
+ async function runStructuredCodexJson({
1326
+ cwd,
1327
+ model,
1328
+ prompt,
1329
+ schema,
1330
+ codexAppPath,
1331
+ skipGitRepoCheck = false,
1332
+ sandboxMode = null,
1333
+ }) {
1334
+ const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "remodex-git-ai-"));
1335
+ const schemaPath = path.join(tempDirectory, "schema.json");
1336
+ const outputPath = path.join(tempDirectory, "output.json");
1337
+ const commands = resolveCodexExecCommands(codexAppPath);
1338
+
1339
+ fs.writeFileSync(schemaPath, JSON.stringify(schema), "utf8");
1340
+
1341
+ try {
1342
+ let lastError = null;
1343
+
1344
+ for (const command of commands) {
1345
+ try {
1346
+ return await spawnCodexExecJson({
1347
+ command,
1348
+ cwd,
1349
+ model,
1350
+ prompt,
1351
+ schemaPath,
1352
+ outputPath,
1353
+ skipGitRepoCheck,
1354
+ sandboxMode,
1355
+ });
1356
+ } catch (error) {
1357
+ lastError = error;
1358
+ if (!shouldRetryCodexExecWithNextCommand(error)) {
1359
+ throw error;
1360
+ }
1361
+ }
1362
+ }
1363
+
1364
+ throw lastError || new Error("Codex CLI is not available on this Mac.");
1365
+ } finally {
1366
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
1367
+ }
1368
+ }
1369
+
1370
+ function resolveCodexExecCommands(codexAppPath) {
1371
+ const commands = ["codex"];
1372
+ const bundledCommand = resolveBundledCodexCommand(codexAppPath);
1373
+ if (bundledCommand && !commands.includes(bundledCommand)) {
1374
+ commands.push(bundledCommand);
1375
+ }
1376
+ return commands;
1377
+ }
1378
+
1379
+ function resolveBundledCodexCommand(codexAppPath) {
1380
+ const trimmedAppPath = typeof codexAppPath === "string" ? codexAppPath.trim() : "";
1381
+ if (!trimmedAppPath) {
1382
+ return "";
1383
+ }
1384
+
1385
+ const candidate = path.join(trimmedAppPath, "Contents", "Resources", "codex");
1386
+ return isLaunchableFile(candidate) ? candidate : "";
1387
+ }
1388
+
1389
+ function isLaunchableFile(candidatePath) {
1390
+ try {
1391
+ return fs.statSync(candidatePath).isFile();
1392
+ } catch {
1393
+ return false;
1394
+ }
1395
+ }
1396
+
1397
+ function shouldRetryCodexExecWithNextCommand(error) {
1398
+ return error?.code === "ENOENT";
1399
+ }
1400
+
1401
+ function spawnCodexExecJson({
1402
+ command,
1403
+ cwd,
1404
+ model,
1405
+ prompt,
1406
+ schemaPath,
1407
+ outputPath,
1408
+ skipGitRepoCheck = false,
1409
+ sandboxMode = null,
1410
+ }) {
1411
+ const args = [
1412
+ "exec",
1413
+ "--ephemeral",
1414
+ "-C",
1415
+ cwd,
1416
+ "-m",
1417
+ model,
1418
+ ];
1419
+ if (skipGitRepoCheck) {
1420
+ args.push("--skip-git-repo-check");
1421
+ }
1422
+ if (sandboxMode) {
1423
+ args.push("-s", sandboxMode);
1424
+ }
1425
+ args.push("--output-schema", schemaPath, "-o", outputPath, "-");
1426
+
1427
+ return new Promise((resolve, reject) => {
1428
+ const child = spawn(command, args, {
1429
+ cwd,
1430
+ env: process.env,
1431
+ stdio: ["pipe", "pipe", "pipe"],
1432
+ });
1433
+
1434
+ let stdout = "";
1435
+ let stderr = "";
1436
+ let timedOut = false;
1437
+ const timeout = setTimeout(() => {
1438
+ timedOut = true;
1439
+ child.kill("SIGKILL");
1440
+ }, GIT_DRAFT_TIMEOUT_MS);
1441
+
1442
+ child.stdout.on("data", (chunk) => {
1443
+ stdout += chunk.toString("utf8");
1444
+ });
1445
+ child.stderr.on("data", (chunk) => {
1446
+ stderr += chunk.toString("utf8");
1447
+ });
1448
+
1449
+ child.on("error", (error) => {
1450
+ clearTimeout(timeout);
1451
+ reject(error);
1452
+ });
1453
+
1454
+ child.on("close", (code, signal) => {
1455
+ clearTimeout(timeout);
1456
+
1457
+ if (timedOut) {
1458
+ reject(new Error("Codex CLI timed out while generating the draft."));
1459
+ return;
1460
+ }
1461
+
1462
+ if (code !== 0) {
1463
+ reject(createCodexExecFailure(code, signal, stdout, stderr));
1464
+ return;
1465
+ }
1466
+
1467
+ try {
1468
+ const outputText = fs.readFileSync(outputPath, "utf8").trim();
1469
+ if (!outputText) {
1470
+ throw new Error("Codex CLI returned an empty structured response.");
1471
+ }
1472
+ resolve(JSON.parse(outputText));
1473
+ } catch (error) {
1474
+ reject(error);
1475
+ }
1476
+ });
1477
+
1478
+ child.stdin.end(prompt);
1479
+ });
1480
+ }
1481
+
1482
+ function createCodexExecFailure(code, signal, stdout, stderr) {
1483
+ const detail = [stderr, stdout]
1484
+ .map((value) => value.trim())
1485
+ .filter(Boolean)
1486
+ .flatMap((value) => value.split("\n"))
1487
+ .map((line) => line.trim())
1488
+ .filter(Boolean)
1489
+ .pop();
1490
+
1491
+ const suffix = detail ? ` ${detail}` : "";
1492
+ const error = new Error(
1493
+ signal
1494
+ ? `Codex CLI was interrupted while generating the draft.${suffix}`
1495
+ : `Codex CLI exited with code ${code} while generating the draft.${suffix}`
1496
+ );
1497
+ error.code = code;
1498
+ error.signal = signal;
1499
+ return error;
1500
+ }
1501
+
1502
+ function parseOwnerRepo(remoteUrl) {
1503
+ const match = remoteUrl.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
1504
+ return match ? match[1] : null;
1505
+ }
1506
+
1507
+ // ─── Git Branches With Status ─────────────────────────────────
1508
+
1509
+ async function gitBranchesWithStatus(cwd) {
1510
+ const initialStatus = await gitStatus(cwd);
1511
+ if (initialStatus.isRepo === false) {
1512
+ return {
1513
+ branches: [],
1514
+ branchesCheckedOutElsewhere: [],
1515
+ worktreePathByBranch: {},
1516
+ localCheckoutPath: null,
1517
+ current: null,
1518
+ default: null,
1519
+ defaultBranch: null,
1520
+ status: initialStatus,
1521
+ };
1522
+ }
1523
+
1524
+ const [branchResult, statusResult] = await Promise.all([
1525
+ gitBranches(cwd),
1526
+ gitStatus(cwd),
1527
+ ]);
1528
+ return { ...branchResult, status: statusResult };
1529
+ }
1530
+
1531
+ async function gitWorktreePathByBranch(cwd, options = {}) {
1532
+ const output = await git(cwd, "worktree", "list", "--porcelain");
1533
+ return parseWorktreePathByBranch(output, options);
1534
+ }
1535
+
1536
+ async function stashChangesForWorktreeHandoff(cwd, pathspecArgs = []) {
1537
+ const stashLabel = `remodex-worktree-handoff-${randomBytes(6).toString("hex")}`;
1538
+ const output = await git(
1539
+ cwd,
1540
+ "stash",
1541
+ "push",
1542
+ "--include-untracked",
1543
+ "--message",
1544
+ stashLabel,
1545
+ ...pathspecArgs
1546
+ );
1547
+ if (output.includes("No local changes")) {
1548
+ return null;
1549
+ }
1550
+
1551
+ const stashRef = await findStashRefByLabel(cwd, stashLabel);
1552
+ if (!stashRef) {
1553
+ throw gitError("create_worktree_failed", "Could not prepare local changes for the worktree handoff.");
1554
+ }
1555
+
1556
+ return stashRef;
1557
+ }
1558
+
1559
+ async function captureLocalChangesPatch(cwd, pathspecArgs = []) {
1560
+ const trackedPatch = await git(cwd, "diff", "--binary", "--find-renames", "HEAD", ...pathspecArgs);
1561
+ const porcelain = await git(cwd, "status", "--porcelain=v1", ...pathspecArgs);
1562
+ const untrackedPaths = porcelain
1563
+ .trim()
1564
+ .split("\n")
1565
+ .filter((line) => line.startsWith("?? "))
1566
+ .map((line) => line.substring(3).trim())
1567
+ .filter(Boolean);
1568
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
1569
+ return [trackedPatch, untrackedPatch]
1570
+ .filter((patch) => typeof patch === "string" && patch.trim())
1571
+ .map(ensureTrailingNewline)
1572
+ .join("\n");
1573
+ }
1574
+
1575
+ async function findStashRefByLabel(cwd, stashLabel) {
1576
+ const output = await git(cwd, "stash", "list", "--format=%gd%x00%s");
1577
+ const records = output
1578
+ .trim()
1579
+ .split("\n")
1580
+ .map((line) => line.trim())
1581
+ .filter(Boolean);
1582
+
1583
+ for (const record of records) {
1584
+ const [ref, summary] = record.split("\0");
1585
+ if (ref && summary?.includes(stashLabel)) {
1586
+ return ref.trim();
1587
+ }
1588
+ }
1589
+
1590
+ return null;
1591
+ }
1592
+
1593
+ async function applyWorktreeHandoffStash(cwd, stashRef, options = {}) {
1594
+ const dropAfterApply = options.dropAfterApply === true;
1595
+ try {
1596
+ if (dropAfterApply) {
1597
+ await git(cwd, "stash", "apply", stashRef);
1598
+ await git(cwd, "stash", "drop", stashRef);
1599
+ } else {
1600
+ await git(cwd, "stash", "pop", stashRef);
1601
+ }
1602
+ } catch (err) {
1603
+ throw gitError(
1604
+ "create_worktree_failed",
1605
+ err.message || "Could not apply local changes in the new worktree."
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ async function applyCopiedLocalChangesToWorktree(cwd, patch) {
1611
+ if (!patch.trim()) {
1612
+ return;
1613
+ }
1614
+
1615
+ const patchFilePath = path.join(os.tmpdir(), `remodex-worktree-copy-${randomBytes(6).toString("hex")}.patch`);
1616
+ fs.writeFileSync(patchFilePath, ensureTrailingNewline(patch), "utf8");
1617
+
1618
+ try {
1619
+ await git(cwd, "apply", "--binary", "--whitespace=nowarn", patchFilePath);
1620
+ } catch (err) {
1621
+ throw gitError(
1622
+ "create_worktree_failed",
1623
+ err.message || "Could not copy local changes into the new worktree."
1624
+ );
1625
+ } finally {
1626
+ fs.rmSync(patchFilePath, { force: true });
1627
+ }
1628
+ }
1629
+
1630
+ async function restoreWorktreeHandoffStash(cwd, stashRef) {
1631
+ try {
1632
+ await git(cwd, "stash", "pop", stashRef);
1633
+ } catch {
1634
+ // Best effort: if restore fails we prefer surfacing the original worktree error without masking it.
1635
+ }
1636
+ }
1637
+
1638
+ async function rollbackFailedHandoffTransfer(cwd, pathspecArgs = []) {
1639
+ if (pathspecArgs.length > 0) {
1640
+ try {
1641
+ await git(cwd, "restore", "--source=HEAD", "--staged", "--worktree", ...pathspecArgs);
1642
+ } catch {
1643
+ // Best effort: leave the original transfer error as the primary failure.
1644
+ }
1645
+
1646
+ try {
1647
+ await git(cwd, "clean", "-fd", ...pathspecArgs);
1648
+ } catch {
1649
+ // Best effort: leave the original transfer error as the primary failure.
1650
+ }
1651
+ return;
1652
+ }
1653
+
1654
+ try {
1655
+ await git(cwd, "reset", "--hard", "HEAD");
1656
+ } catch {
1657
+ // Best effort: leave the original transfer error as the primary failure.
1658
+ }
1659
+
1660
+ try {
1661
+ await git(cwd, "clean", "-fd");
1662
+ } catch {
1663
+ // Best effort: leave the original transfer error as the primary failure.
1664
+ }
1665
+ }
1666
+
1667
+ async function cleanupManagedWorktree(repoRoot, worktreeRootPath, branchName = null) {
1668
+ try {
1669
+ await git(repoRoot, "worktree", "remove", "--force", worktreeRootPath);
1670
+ } catch {
1671
+ // Fall back to directory cleanup below.
1672
+ }
1673
+
1674
+ if (branchName) {
1675
+ try {
1676
+ await git(repoRoot, "branch", "-D", branchName);
1677
+ } catch {
1678
+ // Best effort: leave the branch around if Git refuses deletion for any reason.
1679
+ }
1680
+ }
1681
+
1682
+ fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
1683
+ }
1684
+
1685
+ function parseWorktreePathByBranch(output, options = {}) {
1686
+ const worktreePathByBranch = {};
1687
+ const records = typeof output === "string" ? output.split("\n\n") : [];
1688
+ const projectRelativePath = typeof options.projectRelativePath === "string"
1689
+ ? options.projectRelativePath
1690
+ : "";
1691
+
1692
+ for (const record of records) {
1693
+ const lines = record
1694
+ .split("\n")
1695
+ .map((line) => line.trim())
1696
+ .filter(Boolean);
1697
+
1698
+ if (!lines.length) {
1699
+ continue;
1700
+ }
1701
+
1702
+ const worktreeLine = lines.find((line) => line.startsWith("worktree "));
1703
+ const branchLine = lines.find((line) => line.startsWith("branch "));
1704
+ const worktreePath = worktreeLine?.slice("worktree ".length).trim();
1705
+ const branchName = normalizeWorktreeBranchRef(branchLine?.slice("branch ".length).trim());
1706
+
1707
+ if (!worktreePath || !branchName) {
1708
+ continue;
1709
+ }
1710
+
1711
+ worktreePathByBranch[branchName] = scopedWorktreePath(worktreePath, projectRelativePath);
1712
+ }
1713
+
1714
+ return worktreePathByBranch;
1715
+ }
1716
+
1717
+ // Normalizes `git branch` output so the UI never sees worktree markers like `+ main`.
1718
+ function normalizeBranchListEntry(rawLine) {
1719
+ const trimmed = typeof rawLine === "string" ? rawLine.trim() : "";
1720
+ if (!trimmed) {
1721
+ return null;
1722
+ }
1723
+
1724
+ const isCurrent = trimmed.startsWith("* ");
1725
+ const isCheckedOutElsewhere = trimmed.startsWith("+ ");
1726
+ const name = trimmed.replace(/^[*+]\s+/, "").trim();
1727
+
1728
+ if (!name) {
1729
+ return null;
1730
+ }
1731
+
1732
+ return { isCurrent, isCheckedOutElsewhere, name };
1733
+ }
1734
+
1735
+ function normalizeWorktreeBranchRef(rawRef) {
1736
+ const trimmed = typeof rawRef === "string" ? rawRef.trim() : "";
1737
+ if (!trimmed.startsWith("refs/heads/")) {
1738
+ return null;
1739
+ }
1740
+
1741
+ const branchName = trimmed.slice("refs/heads/".length).trim();
1742
+ return branchName || null;
1743
+ }
1744
+
1745
+ function normalizeCreatedBranchName(rawName) {
1746
+ const trimmed = typeof rawName === "string" ? rawName.trim() : "";
1747
+ if (!trimmed) {
1748
+ return "";
1749
+ }
1750
+
1751
+ // Keep slash-separated branch groups, but normalize user-entered whitespace into Git-friendly dashes.
1752
+ const normalized = trimmed
1753
+ .split("/")
1754
+ .map((segment) => segment.trim().replace(/\s+/g, "-"))
1755
+ .join("/");
1756
+
1757
+ if (normalized.startsWith("remodex/")) {
1758
+ return normalized;
1759
+ }
1760
+ return `remodex/${normalized}`;
1761
+ }
1762
+
1763
+ function resolveBaseBranchName(rawBaseBranch, fallbackBranch) {
1764
+ const trimmedBaseBranch = typeof rawBaseBranch === "string" ? rawBaseBranch.trim() : "";
1765
+ if (trimmedBaseBranch) {
1766
+ return trimmedBaseBranch;
1767
+ }
1768
+
1769
+ return typeof fallbackBranch === "string" && fallbackBranch.trim() ? fallbackBranch.trim() : "";
1770
+ }
1771
+
1772
+ // Mirrors Codex-managed worktree paths under CODEX_HOME/worktrees/<token>/<repo>.
1773
+ function allocateManagedWorktreePath(repoRoot) {
1774
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
1775
+ const worktreesRoot = path.join(codexHome, "worktrees");
1776
+ fs.mkdirSync(worktreesRoot, { recursive: true });
1777
+
1778
+ const repoName = path.basename(repoRoot) || "repo";
1779
+ for (let attempt = 0; attempt < 16; attempt += 1) {
1780
+ const token = randomBytes(2).toString("hex");
1781
+ const tokenDirectory = path.join(worktreesRoot, token);
1782
+ const worktreePath = path.join(tokenDirectory, repoName);
1783
+ if (fs.existsSync(tokenDirectory) || fs.existsSync(worktreePath)) {
1784
+ continue;
1785
+ }
1786
+ fs.mkdirSync(tokenDirectory, { recursive: true });
1787
+ return worktreePath;
1788
+ }
1789
+
1790
+ throw gitError("create_worktree_failed", "Could not allocate a managed worktree path.");
1791
+ }
1792
+
1793
+ async function localBranchExists(cwd, branchName) {
1794
+ try {
1795
+ await git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`);
1796
+ return true;
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+
1802
+ async function assertValidCreatedBranchName(cwd, branchName) {
1803
+ try {
1804
+ await git(cwd, "check-ref-format", "--branch", branchName);
1805
+ } catch {
1806
+ throw gitError("invalid_branch_name", `Branch '${branchName}' is not a valid Git branch name.`);
1807
+ }
1808
+ }
1809
+
1810
+ // Keeps branch creation local-only even when a same-named ref exists on origin.
1811
+ async function remoteBranchExists(cwd, branchName) {
1812
+ try {
1813
+ await git(cwd, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`);
1814
+ return true;
1815
+ } catch {
1816
+ return false;
1817
+ }
1818
+ }
1819
+
1820
+ // Uses the branch upstream when one exists; otherwise origin is the publish target.
1821
+ async function pushRemoteAvailable(cwd, tracking) {
1822
+ const remoteName = trackingRemoteName(tracking) || "origin";
1823
+ return remoteExists(cwd, remoteName);
1824
+ }
1825
+
1826
+ async function remoteExists(cwd, remoteName) {
1827
+ try {
1828
+ const output = await git(cwd, "config", "--get", `remote.${remoteName}.url`);
1829
+ return output.trim().length > 0;
1830
+ } catch {
1831
+ return false;
1832
+ }
1833
+ }
1834
+
1835
+ function trackingRemoteName(tracking) {
1836
+ const trimmed = typeof tracking === "string" ? tracking.trim() : "";
1837
+ const slashIndex = trimmed.indexOf("/");
1838
+ if (slashIndex <= 0) {
1839
+ return null;
1840
+ }
1841
+ return trimmed.slice(0, slashIndex);
1842
+ }
1843
+
1844
+ function sameFilePath(leftPath, rightPath) {
1845
+ const normalizedLeft = normalizeExistingPath(leftPath);
1846
+ const normalizedRight = normalizeExistingPath(rightPath);
1847
+ return normalizedLeft !== null && normalizedLeft === normalizedRight;
1848
+ }
1849
+
1850
+ function normalizeExistingPath(candidatePath) {
1851
+ if (typeof candidatePath !== "string") {
1852
+ return null;
1853
+ }
1854
+
1855
+ const trimmedPath = candidatePath.trim();
1856
+ if (!trimmedPath) {
1857
+ return null;
1858
+ }
1859
+
1860
+ try {
1861
+ return fs.realpathSync.native(trimmedPath);
1862
+ } catch {
1863
+ return path.resolve(trimmedPath);
1864
+ }
1865
+ }
1866
+
1867
+ function managedWorktreesRoot() {
1868
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
1869
+ return normalizeExistingPath(path.join(codexHome, "worktrees"));
1870
+ }
1871
+
1872
+ function isManagedWorktreePath(candidatePath) {
1873
+ const normalizedCandidate = normalizeExistingPath(candidatePath);
1874
+ const normalizedRoot = managedWorktreesRoot();
1875
+ if (!normalizedCandidate || !normalizedRoot) {
1876
+ return false;
1877
+ }
1878
+
1879
+ const relativePath = path.relative(normalizedRoot, normalizedCandidate);
1880
+ return !!relativePath && relativePath !== "." && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
1881
+ }
1882
+
1883
+ function resolveProjectRelativePath(cwd, repoRoot) {
1884
+ const normalizedCwd = normalizeExistingPath(cwd);
1885
+ const normalizedRepoRoot = normalizeExistingPath(repoRoot);
1886
+ if (!normalizedCwd || !normalizedRepoRoot) {
1887
+ return "";
1888
+ }
1889
+
1890
+ const relativePath = path.relative(normalizedRepoRoot, normalizedCwd);
1891
+ if (!relativePath || relativePath === ".") {
1892
+ return "";
1893
+ }
1894
+
1895
+ return relativePath;
1896
+ }
1897
+
1898
+ // Preserves package-scoped threads by reopening the matching subpath inside sibling worktrees.
1899
+ function scopedWorktreePath(worktreeRootPath, projectRelativePath) {
1900
+ const normalizedWorktreeRootPath = normalizeExistingPath(worktreeRootPath);
1901
+ if (!normalizedWorktreeRootPath) {
1902
+ return worktreeRootPath;
1903
+ }
1904
+ if (!projectRelativePath) {
1905
+ return normalizedWorktreeRootPath;
1906
+ }
1907
+
1908
+ const candidatePath = path.join(normalizedWorktreeRootPath, projectRelativePath);
1909
+ return isExistingDirectory(candidatePath) ? normalizeExistingPath(candidatePath) ?? candidatePath : normalizedWorktreeRootPath;
1910
+ }
1911
+
1912
+ // Resolves a Local checkout path only when the matching subpath actually exists there.
1913
+ function scopedLocalCheckoutPath(checkoutRootPath, projectRelativePath) {
1914
+ const normalizedCheckoutRootPath = normalizeExistingPath(checkoutRootPath);
1915
+ if (!normalizedCheckoutRootPath) {
1916
+ return null;
1917
+ }
1918
+ if (!projectRelativePath) {
1919
+ return normalizedCheckoutRootPath;
1920
+ }
1921
+
1922
+ const candidatePath = path.join(normalizedCheckoutRootPath, projectRelativePath);
1923
+ return isExistingDirectory(candidatePath) ? normalizeExistingPath(candidatePath) ?? candidatePath : null;
1924
+ }
1925
+
1926
+ // Computes the local repo delta that still exists on this machine and is not on the remote.
1927
+ async function repoDiffTotals(cwd, context) {
1928
+ const baseRef = await resolveRepoDiffBase(cwd, context.tracking);
1929
+ const trackedTotals = await diffTotalsAgainstBase(cwd, baseRef);
1930
+ const untrackedPaths = context.fileLines
1931
+ .filter((line) => line.startsWith("?? "))
1932
+ .map((line) => line.substring(3).trim())
1933
+ .filter(Boolean);
1934
+ const untrackedTotals = await diffTotalsForUntrackedFiles(cwd, untrackedPaths);
1935
+
1936
+ return {
1937
+ additions: trackedTotals.additions + untrackedTotals.additions,
1938
+ deletions: trackedTotals.deletions + untrackedTotals.deletions,
1939
+ binaryFiles: trackedTotals.binaryFiles + untrackedTotals.binaryFiles,
1940
+ };
1941
+ }
1942
+
1943
+ // Uses upstream when available; otherwise falls back to commits not yet present on any remote.
1944
+ async function resolveRepoDiffBase(cwd, tracking) {
1945
+ if (!(await refExists(cwd, "HEAD"))) {
1946
+ return EMPTY_TREE_HASH;
1947
+ }
1948
+
1949
+ if (tracking) {
1950
+ try {
1951
+ return (await git(cwd, "merge-base", "HEAD", "@{u}")).trim();
1952
+ } catch {
1953
+ // Fall through to the local-only commit scan if upstream metadata is stale.
1954
+ }
1955
+ }
1956
+
1957
+ const firstLocalOnlyCommit = (
1958
+ await git(cwd, "rev-list", "--reverse", "--topo-order", "HEAD", "--not", "--remotes")
1959
+ )
1960
+ .trim()
1961
+ .split("\n")
1962
+ .find(Boolean);
1963
+
1964
+ if (!firstLocalOnlyCommit) {
1965
+ return "HEAD";
1966
+ }
1967
+
1968
+ try {
1969
+ return (await git(cwd, "rev-parse", `${firstLocalOnlyCommit}^`)).trim();
1970
+ } catch {
1971
+ return EMPTY_TREE_HASH;
1972
+ }
1973
+ }
1974
+
1975
+ async function diffTotalsAgainstBase(cwd, baseRef) {
1976
+ const output = await git(cwd, "diff", "--numstat", baseRef);
1977
+ return parseNumstatTotals(output);
1978
+ }
1979
+
1980
+ async function gitDiffAgainstBase(cwd, baseRef) {
1981
+ return git(cwd, "diff", "--binary", "--find-renames", baseRef);
1982
+ }
1983
+
1984
+ async function diffTotalsForUntrackedFiles(cwd, filePaths) {
1985
+ if (!filePaths.length) {
1986
+ return { additions: 0, deletions: 0, binaryFiles: 0 };
1987
+ }
1988
+
1989
+ const totals = await Promise.all(
1990
+ filePaths.map(async (filePath) => {
1991
+ const output = await gitDiffNoIndexNumstat(cwd, filePath);
1992
+ return parseNumstatTotals(output);
1993
+ })
1994
+ );
1995
+
1996
+ return totals.reduce(
1997
+ (aggregate, current) => ({
1998
+ additions: aggregate.additions + current.additions,
1999
+ deletions: aggregate.deletions + current.deletions,
2000
+ binaryFiles: aggregate.binaryFiles + current.binaryFiles,
2001
+ }),
2002
+ { additions: 0, deletions: 0, binaryFiles: 0 }
2003
+ );
2004
+ }
2005
+
2006
+ // Counts commits reachable from HEAD that are not present on any remote ref.
2007
+ async function countLocalOnlyCommits(cwd, context) {
2008
+ if (context.detached) {
2009
+ return 0;
2010
+ }
2011
+
2012
+ const remoteRefs = await git(cwd, "for-each-ref", "--format=%(refname)", "refs/remotes");
2013
+ const hasAnyRemoteRefs = remoteRefs
2014
+ .trim()
2015
+ .split("\n")
2016
+ .map((line) => line.trim())
2017
+ .filter(Boolean)
2018
+ .length > 0;
2019
+
2020
+ if (!hasAnyRemoteRefs) {
2021
+ return 0;
2022
+ }
2023
+
2024
+ const output = await git(cwd, "rev-list", "--count", "HEAD", "--not", "--remotes");
2025
+ return Number.parseInt(output.trim(), 10) || 0;
2026
+ }
2027
+
2028
+ function parseNumstatTotals(output) {
2029
+ return output
2030
+ .trim()
2031
+ .split("\n")
2032
+ .filter(Boolean)
2033
+ .reduce(
2034
+ (aggregate, line) => {
2035
+ const [rawAdditions, rawDeletions] = line.split("\t");
2036
+ const additions = Number.parseInt(rawAdditions, 10);
2037
+ const deletions = Number.parseInt(rawDeletions, 10);
2038
+ const isBinary = !Number.isFinite(additions) || !Number.isFinite(deletions);
2039
+
2040
+ return {
2041
+ additions: aggregate.additions + (Number.isFinite(additions) ? additions : 0),
2042
+ deletions: aggregate.deletions + (Number.isFinite(deletions) ? deletions : 0),
2043
+ binaryFiles: aggregate.binaryFiles + (isBinary ? 1 : 0),
2044
+ };
2045
+ },
2046
+ { additions: 0, deletions: 0, binaryFiles: 0 }
2047
+ );
2048
+ }
2049
+
2050
+ function resolveWorktreeChangeTransfer(rawValue) {
2051
+ const normalizedValue = typeof rawValue === "string" ? rawValue.trim().toLowerCase() : "";
2052
+ if (normalizedValue === "copy") {
2053
+ return "copy";
2054
+ }
2055
+ if (normalizedValue === "none") {
2056
+ return "none";
2057
+ }
2058
+ return "move";
2059
+ }
2060
+
2061
+ async function scopedProjectChanges(repoRoot, projectRelativePath) {
2062
+ const pathspecArgs = gitPathspecArgs(projectRelativePath);
2063
+ const porcelain = await git(repoRoot, "status", "--porcelain=v1", ...pathspecArgs);
2064
+ const fileLines = porcelain
2065
+ .trim()
2066
+ .split("\n")
2067
+ .filter(Boolean);
2068
+
2069
+ return {
2070
+ dirty: fileLines.length > 0,
2071
+ fileLines,
2072
+ pathspecArgs,
2073
+ };
2074
+ }
2075
+
2076
+ function gitPathspecArgs(projectRelativePath) {
2077
+ const normalizedPath = normalizeGitPathspec(projectRelativePath);
2078
+ if (!normalizedPath) {
2079
+ return [];
2080
+ }
2081
+
2082
+ return ["--", normalizedPath];
2083
+ }
2084
+
2085
+ function normalizeGitPathspec(projectRelativePath) {
2086
+ if (typeof projectRelativePath !== "string") {
2087
+ return "";
2088
+ }
2089
+
2090
+ const trimmedPath = projectRelativePath.trim();
2091
+ if (!trimmedPath) {
2092
+ return "";
2093
+ }
2094
+
2095
+ return trimmedPath.split(path.sep).join("/");
2096
+ }
2097
+
2098
+ function ensureTrailingNewline(value) {
2099
+ return value.endsWith("\n") ? value : `${value}\n`;
2100
+ }
2101
+
2102
+ async function gitDiffNoIndexNumstat(cwd, filePath) {
2103
+ try {
2104
+ const { stdout } = await execFileAsync(
2105
+ "git",
2106
+ ["diff", "--no-index", "--numstat", "--", "/dev/null", filePath],
2107
+ { cwd, timeout: GIT_TIMEOUT_MS }
2108
+ );
2109
+ return stdout;
2110
+ } catch (err) {
2111
+ if (typeof err?.code === "number" && err.code === 1) {
2112
+ return err.stdout || "";
2113
+ }
2114
+ const msg = (err.stderr || err.message || "").trim();
2115
+ throw new Error(msg || "git diff --no-index failed");
2116
+ }
2117
+ }
2118
+
2119
+ async function diffPatchForUntrackedFiles(cwd, filePaths) {
2120
+ if (!filePaths.length) {
2121
+ return "";
2122
+ }
2123
+
2124
+ const patches = await Promise.all(filePaths.map((filePath) => gitDiffNoIndexPatch(cwd, filePath)));
2125
+ return patches.filter(Boolean).join("\n\n");
2126
+ }
2127
+
2128
+ async function gitDiffNoIndexPatch(cwd, filePath) {
2129
+ try {
2130
+ const { stdout } = await execFileAsync(
2131
+ "git",
2132
+ ["diff", "--no-index", "--binary", "--", "/dev/null", filePath],
2133
+ { cwd, timeout: GIT_TIMEOUT_MS }
2134
+ );
2135
+ return stdout;
2136
+ } catch (err) {
2137
+ if (typeof err?.code === "number" && err.code === 1) {
2138
+ return err.stdout || "";
2139
+ }
2140
+ const msg = (err.stderr || err.message || "").trim();
2141
+ throw new Error(msg || "git diff --no-index failed");
2142
+ }
2143
+ }
2144
+
2145
+ // ─── Helpers ──────────────────────────────────────────────────
2146
+
2147
+ function git(cwd, ...args) {
2148
+ return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
2149
+ .then(({ stdout }) => stdout)
2150
+ .catch((err) => {
2151
+ const msg = (err.stderr || err.message || "").trim();
2152
+ const wrapped = new Error(msg || "git command failed");
2153
+ throw wrapped;
2154
+ });
2155
+ }
2156
+
2157
+ async function revListCounts(cwd) {
2158
+ const output = await git(cwd, "rev-list", "--left-right", "--count", "HEAD...@{u}");
2159
+ const parts = output.trim().split(/\s+/);
2160
+ return {
2161
+ ahead: parseInt(parts[0], 10) || 0,
2162
+ behind: parseInt(parts[1], 10) || 0,
2163
+ };
2164
+ }
2165
+
2166
+ function parseBranchFromStatus(line) {
2167
+ // "## main...origin/main" or "## main" or "## HEAD (no branch)"
2168
+ const match = line.match(/^## (.+?)(?:\.{3}|$)/);
2169
+ if (!match) return null;
2170
+ const branch = match[1].trim();
2171
+ if (branch.startsWith("No commits yet on ")) {
2172
+ return branch.substring("No commits yet on ".length).trim() || null;
2173
+ }
2174
+ if (branch === "HEAD (no branch)" || branch.includes("HEAD detached")) return null;
2175
+ return branch;
2176
+ }
2177
+
2178
+ function parseTrackingFromStatus(line) {
2179
+ const match = line.match(/\.{3}(.+?)(?:\s|$)/);
2180
+ return match ? match[1].trim() : null;
2181
+ }
2182
+
2183
+ function computeState(dirty, ahead, behind, detached, noUpstream) {
2184
+ if (detached) return "detached_head";
2185
+ if (noUpstream) return "no_upstream";
2186
+ if (dirty && behind > 0) return "dirty_and_behind";
2187
+ if (dirty) return "dirty";
2188
+ if (ahead > 0 && behind > 0) return "diverged";
2189
+ if (behind > 0) return "behind_only";
2190
+ if (ahead > 0) return "ahead_only";
2191
+ return "up_to_date";
2192
+ }
2193
+
2194
+ async function detectDefaultBranch(cwd, branches) {
2195
+ // Try symbolic-ref first
2196
+ try {
2197
+ const ref = await git(cwd, "symbolic-ref", "refs/remotes/origin/HEAD");
2198
+ const defaultBranch = ref.trim().replace("refs/remotes/origin/", "");
2199
+ // Repo default is metadata about origin, not a promise that the local selector should show it.
2200
+ if (defaultBranch) {
2201
+ return defaultBranch;
2202
+ }
2203
+ } catch {
2204
+ // ignore
2205
+ }
2206
+
2207
+ // Some repos never record origin/HEAD locally, so prefer the common remote defaults before local fallback.
2208
+ if (await remoteBranchExists(cwd, "main")) return "main";
2209
+ if (await remoteBranchExists(cwd, "master")) return "master";
2210
+
2211
+ // Fallback: prefer main, then master
2212
+ if (branches.includes("main")) return "main";
2213
+ if (branches.includes("master")) return "master";
2214
+ return branches[0] || null;
2215
+ }
2216
+
2217
+ function gitError(errorCode, userMessage) {
2218
+ const err = new Error(userMessage);
2219
+ err.errorCode = errorCode;
2220
+ err.userMessage = userMessage;
2221
+ return err;
2222
+ }
2223
+
2224
+ function nonRepositoryStatus(cwd) {
2225
+ return {
2226
+ isRepo: false,
2227
+ repoRoot: null,
2228
+ branch: null,
2229
+ tracking: null,
2230
+ dirty: false,
2231
+ hasHeadCommit: false,
2232
+ hasPushRemote: false,
2233
+ ahead: 0,
2234
+ behind: 0,
2235
+ localOnlyCommitCount: 0,
2236
+ state: "not_initialized",
2237
+ canPush: false,
2238
+ publishedToRemote: false,
2239
+ files: [],
2240
+ diff: { additions: 0, deletions: 0, binaryFiles: 0 },
2241
+ };
2242
+ }
2243
+
2244
+ async function isInsideGitWorkTree(cwd) {
2245
+ try {
2246
+ const output = await git(cwd, "rev-parse", "--is-inside-work-tree");
2247
+ return output.trim() === "true";
2248
+ } catch {
2249
+ return false;
2250
+ }
2251
+ }
2252
+
2253
+ async function currentBranchFromStatus(cwd) {
2254
+ const output = await git(cwd, "status", "--porcelain=v1", "-b");
2255
+ const branchLine = output.trim().split("\n").filter(Boolean)[0] || "";
2256
+ return parseBranchFromStatus(branchLine);
2257
+ }
2258
+
2259
+ function gitInitBranchFlagUnsupported(error) {
2260
+ const message = error?.message || "";
2261
+ return message.includes("unknown switch `b'")
2262
+ || message.includes("unknown option `b'")
2263
+ || message.includes("usage: git init");
2264
+ }
2265
+
2266
+ // Resolves git commands to a concrete local directory.
2267
+ async function resolveGitCwd(params) {
2268
+ const requestedCwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory]);
2269
+
2270
+ if (!requestedCwd) {
2271
+ throw gitError(
2272
+ "missing_working_directory",
2273
+ "Git actions require a bound local working directory."
2274
+ );
2275
+ }
2276
+
2277
+ if (!isExistingDirectory(requestedCwd)) {
2278
+ throw gitError(
2279
+ "missing_working_directory",
2280
+ "The requested local working directory does not exist on this Mac."
2281
+ );
2282
+ }
2283
+
2284
+ return requestedCwd;
2285
+ }
2286
+
2287
+ function firstNonEmptyString(candidates) {
2288
+ for (const candidate of candidates) {
2289
+ if (typeof candidate !== "string") {
2290
+ continue;
2291
+ }
2292
+
2293
+ const trimmed = candidate.trim();
2294
+ if (trimmed) {
2295
+ return trimmed;
2296
+ }
2297
+ }
2298
+
2299
+ return null;
2300
+ }
2301
+
2302
+ function isExistingDirectory(candidatePath) {
2303
+ try {
2304
+ return fs.statSync(candidatePath).isDirectory();
2305
+ } catch {
2306
+ return false;
2307
+ }
2308
+ }
2309
+
2310
+ async function resolveRepoRoot(cwd) {
2311
+ const output = await git(cwd, "rev-parse", "--show-toplevel");
2312
+ const repoRoot = output.trim();
2313
+ return repoRoot || null;
2314
+ }
2315
+
2316
+ async function resolveLocalCheckoutRoot(cwd) {
2317
+ const output = await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
2318
+ const commonDir = output.trim();
2319
+ if (!commonDir) {
2320
+ return null;
2321
+ }
2322
+
2323
+ const normalizedCommonDir = normalizeExistingPath(commonDir);
2324
+ if (!normalizedCommonDir) {
2325
+ return null;
2326
+ }
2327
+
2328
+ if (path.basename(normalizedCommonDir) !== ".git") {
2329
+ return await resolveRepoRoot(cwd);
2330
+ }
2331
+
2332
+ const checkoutRoot = normalizeExistingPath(path.dirname(normalizedCommonDir));
2333
+ return checkoutRoot || null;
2334
+ }
2335
+
2336
+ module.exports = {
2337
+ handleGitRequest,
2338
+ gitStatus,
2339
+ __test: {
2340
+ gitGenerateCommitMessage,
2341
+ gitGeneratePullRequestDraft,
2342
+ threadGenerateTitle,
2343
+ threadNameSet,
2344
+ gitBranches,
2345
+ gitBranchesWithStatus,
2346
+ gitInit,
2347
+ gitCreateBranch,
2348
+ gitCreateWorktree,
2349
+ gitCreateManagedWorktree,
2350
+ gitTransferManagedHandoff,
2351
+ gitCheckout,
2352
+ gitStash,
2353
+ gitRemoveWorktree,
2354
+ isManagedWorktreePath,
2355
+ normalizeBranchListEntry,
2356
+ normalizeCreatedBranchName,
2357
+ parseWorktreePathByBranch,
2358
+ ensureTrailingNewline,
2359
+ resolveWorktreeChangeTransfer,
2360
+ resolveLocalCheckoutRoot,
2361
+ scopedLocalCheckoutPath,
2362
+ scopedWorktreePath,
2363
+ resolveBaseBranchName,
2364
+ setRunStructuredCodexJsonImplementation(fn) {
2365
+ runStructuredCodexJsonImpl = typeof fn === "function" ? fn : runStructuredCodexJson;
2366
+ },
2367
+ resetRunStructuredCodexJsonImplementation() {
2368
+ runStructuredCodexJsonImpl = runStructuredCodexJson;
2369
+ },
2370
+ },
2371
+ };