androdex 1.1.3

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,672 @@
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
6
+
7
+ const { execFile } = require("child_process");
8
+ const fs = require("fs");
9
+ const { promisify } = require("util");
10
+
11
+ const execFileAsync = promisify(execFile);
12
+ const GIT_TIMEOUT_MS = 30_000;
13
+ const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
14
+
15
+ /**
16
+ * Intercepts git/* JSON-RPC methods and executes git commands locally.
17
+ * @param {string} rawMessage - Raw WebSocket message
18
+ * @param {(response: string) => void} sendResponse - Callback to send response back
19
+ * @returns {boolean} true if message was handled, false if it should pass through
20
+ */
21
+ function handleGitRequest(rawMessage, sendResponse) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(rawMessage);
25
+ } catch {
26
+ return false;
27
+ }
28
+
29
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
30
+ if (!method.startsWith("git/")) {
31
+ return false;
32
+ }
33
+
34
+ const id = parsed.id;
35
+ const params = parsed.params || {};
36
+
37
+ handleGitMethod(method, params)
38
+ .then((result) => {
39
+ sendResponse(JSON.stringify({ id, result }));
40
+ })
41
+ .catch((err) => {
42
+ const errorCode = err.errorCode || "git_error";
43
+ const message = err.userMessage || err.message || "Unknown git error";
44
+ sendResponse(
45
+ JSON.stringify({
46
+ id,
47
+ error: {
48
+ code: -32000,
49
+ message,
50
+ data: { errorCode },
51
+ },
52
+ })
53
+ );
54
+ });
55
+
56
+ return true;
57
+ }
58
+
59
+ async function handleGitMethod(method, params) {
60
+ const cwd = await resolveGitCwd(params);
61
+
62
+ switch (method) {
63
+ case "git/status":
64
+ return gitStatus(cwd);
65
+ case "git/diff":
66
+ return gitDiff(cwd);
67
+ case "git/commit":
68
+ return gitCommit(cwd, params);
69
+ case "git/push":
70
+ return gitPush(cwd);
71
+ case "git/pull":
72
+ return gitPull(cwd);
73
+ case "git/branches":
74
+ return gitBranches(cwd);
75
+ case "git/checkout":
76
+ return gitCheckout(cwd, params);
77
+ case "git/log":
78
+ return gitLog(cwd);
79
+ case "git/createBranch":
80
+ return gitCreateBranch(cwd, params);
81
+ case "git/stash":
82
+ return gitStash(cwd);
83
+ case "git/stashPop":
84
+ return gitStashPop(cwd);
85
+ case "git/resetToRemote":
86
+ return gitResetToRemote(cwd, params);
87
+ case "git/remoteUrl":
88
+ return gitRemoteUrl(cwd);
89
+ case "git/branchesWithStatus":
90
+ return gitBranchesWithStatus(cwd);
91
+ default:
92
+ throw gitError("unknown_method", `Unknown git method: ${method}`);
93
+ }
94
+ }
95
+
96
+ // ─── Git Status ───────────────────────────────────────────────
97
+
98
+ async function gitStatus(cwd) {
99
+ const [porcelain, branchInfo, repoRoot] = await Promise.all([
100
+ git(cwd, "status", "--porcelain=v1", "-b"),
101
+ revListCounts(cwd).catch(() => ({ ahead: 0, behind: 0 })),
102
+ resolveRepoRoot(cwd).catch(() => null),
103
+ ]);
104
+
105
+ const lines = porcelain.trim().split("\n").filter(Boolean);
106
+ const branchLine = lines[0] || "";
107
+ const fileLines = lines.slice(1);
108
+
109
+ const branch = parseBranchFromStatus(branchLine);
110
+ const tracking = parseTrackingFromStatus(branchLine);
111
+ const files = fileLines.map((line) => ({
112
+ path: line.substring(3).trim(),
113
+ status: line.substring(0, 2).trim(),
114
+ }));
115
+
116
+ const dirty = files.length > 0;
117
+ const { ahead, behind } = branchInfo;
118
+ const detached = branchLine.includes("HEAD detached") || branchLine.includes("no branch");
119
+ const noUpstream = tracking === null && !detached;
120
+ const state = computeState(dirty, ahead, behind, detached, noUpstream);
121
+ const canPush = (ahead > 0 || noUpstream) && !detached;
122
+ const diff = await repoDiffTotals(cwd, {
123
+ tracking,
124
+ fileLines,
125
+ }).catch(() => ({ additions: 0, deletions: 0, binaryFiles: 0 }));
126
+
127
+ return { repoRoot, branch, tracking, dirty, ahead, behind, state, canPush, files, diff };
128
+ }
129
+
130
+ // ─── Git Diff ─────────────────────────────────────────────────
131
+
132
+ async function gitDiff(cwd) {
133
+ const porcelain = await git(cwd, "status", "--porcelain=v1", "-b");
134
+ const lines = porcelain.trim().split("\n").filter(Boolean);
135
+ const branchLine = lines[0] || "";
136
+ const fileLines = lines.slice(1);
137
+ const tracking = parseTrackingFromStatus(branchLine);
138
+ const baseRef = await resolveRepoDiffBase(cwd, tracking);
139
+ const trackedPatch = await gitDiffAgainstBase(cwd, baseRef);
140
+ const untrackedPaths = fileLines
141
+ .filter((line) => line.startsWith("?? "))
142
+ .map((line) => line.substring(3).trim())
143
+ .filter(Boolean);
144
+ const untrackedPatch = await diffPatchForUntrackedFiles(cwd, untrackedPaths);
145
+ const patch = [trackedPatch.trim(), untrackedPatch.trim()].filter(Boolean).join("\n\n").trim();
146
+ return { patch };
147
+ }
148
+
149
+ // ─── Git Commit ───────────────────────────────────────────────
150
+
151
+ async function gitCommit(cwd, params) {
152
+ const message =
153
+ typeof params.message === "string" && params.message.trim()
154
+ ? params.message.trim()
155
+ : "Changes from Codex";
156
+
157
+ // Check for changes first
158
+ const statusCheck = await git(cwd, "status", "--porcelain");
159
+ if (!statusCheck.trim()) {
160
+ throw gitError("nothing_to_commit", "Nothing to commit.");
161
+ }
162
+
163
+ await git(cwd, "add", "-A");
164
+ const output = await git(cwd, "commit", "-m", message);
165
+
166
+ const hashMatch = output.match(/\[(\S+)\s+([a-f0-9]+)\]/);
167
+ const hash = hashMatch ? hashMatch[2] : "";
168
+ const branch = hashMatch ? hashMatch[1] : "";
169
+ const summaryMatch = output.match(/\d+ files? changed/);
170
+ const summary = summaryMatch ? summaryMatch[0] : output.split("\n").pop()?.trim() || "";
171
+
172
+ return { hash, branch, summary };
173
+ }
174
+
175
+ // ─── Git Push ─────────────────────────────────────────────────
176
+
177
+ async function gitPush(cwd) {
178
+ try {
179
+ const branchOutput = await git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
180
+ const branch = branchOutput.trim();
181
+
182
+ // Try normal push first; if no upstream, set it
183
+ try {
184
+ await git(cwd, "push");
185
+ } catch (pushErr) {
186
+ if (
187
+ pushErr.message?.includes("no upstream") ||
188
+ pushErr.message?.includes("has no upstream branch")
189
+ ) {
190
+ await git(cwd, "push", "--set-upstream", "origin", branch);
191
+ } else {
192
+ throw pushErr;
193
+ }
194
+ }
195
+
196
+ const remote = "origin";
197
+ const status = await gitStatus(cwd);
198
+ return { branch, remote, status };
199
+ } catch (err) {
200
+ if (err.errorCode) throw err;
201
+ if (err.message?.includes("rejected")) {
202
+ throw gitError("push_rejected", "Push rejected. Pull changes first.");
203
+ }
204
+ throw gitError("push_failed", err.message || "Push failed.");
205
+ }
206
+ }
207
+
208
+ // ─── Git Pull ─────────────────────────────────────────────────
209
+
210
+ async function gitPull(cwd) {
211
+ try {
212
+ await git(cwd, "pull", "--rebase");
213
+ const status = await gitStatus(cwd);
214
+ return { success: true, status };
215
+ } catch (err) {
216
+ // Abort rebase on conflict
217
+ try {
218
+ await git(cwd, "rebase", "--abort");
219
+ } catch {
220
+ // ignore abort errors
221
+ }
222
+ if (err.errorCode) throw err;
223
+ throw gitError("pull_conflict", "Pull failed due to conflicts. Rebase aborted.");
224
+ }
225
+ }
226
+
227
+ // ─── Git Branches ─────────────────────────────────────────────
228
+
229
+ async function gitBranches(cwd) {
230
+ const output = await git(cwd, "branch", "-a", "--no-color");
231
+ const lines = output
232
+ .trim()
233
+ .split("\n")
234
+ .filter(Boolean)
235
+ .map((l) => l.trim());
236
+
237
+ let current = "";
238
+ const branchSet = new Set();
239
+
240
+ for (const line of lines) {
241
+ const isCurrent = line.startsWith("* ");
242
+ const name = line.replace(/^\*\s*/, "").trim();
243
+
244
+ if (name.includes("HEAD detached") || name === "(no branch)") {
245
+ if (isCurrent) current = "HEAD";
246
+ continue;
247
+ }
248
+
249
+ // Skip remotes/origin/HEAD -> ...
250
+ if (name.includes("->")) continue;
251
+
252
+ if (name.startsWith("remotes/origin/")) {
253
+ branchSet.add(name.replace("remotes/origin/", ""));
254
+ } else {
255
+ branchSet.add(name);
256
+ }
257
+
258
+ if (isCurrent) current = name;
259
+ }
260
+
261
+ const branches = [...branchSet].sort();
262
+ const defaultBranch = await detectDefaultBranch(cwd, branches);
263
+
264
+ return { branches, current, default: defaultBranch };
265
+ }
266
+
267
+ // ─── Git Checkout ─────────────────────────────────────────────
268
+
269
+ async function gitCheckout(cwd, params) {
270
+ const branch = typeof params.branch === "string" ? params.branch.trim() : "";
271
+ if (!branch) {
272
+ throw gitError("missing_branch", "Branch name is required.");
273
+ }
274
+
275
+ try {
276
+ await git(cwd, "checkout", "--", branch);
277
+ } catch (err) {
278
+ if (err.message?.includes("would be overwritten")) {
279
+ throw gitError(
280
+ "checkout_conflict_dirty_tree",
281
+ "Cannot switch branches: you have uncommitted changes."
282
+ );
283
+ }
284
+ throw gitError("checkout_failed", err.message || "Checkout failed.");
285
+ }
286
+
287
+ const status = await gitStatus(cwd);
288
+ return { current: branch, tracking: status.tracking, status };
289
+ }
290
+
291
+ // ─── Git Log ──────────────────────────────────────────────────
292
+
293
+ async function gitLog(cwd) {
294
+ const output = await git(
295
+ cwd,
296
+ "log",
297
+ "-20",
298
+ "--format=%H%x00%s%x00%an%x00%aI"
299
+ );
300
+
301
+ const commits = output
302
+ .trim()
303
+ .split("\n")
304
+ .filter(Boolean)
305
+ .map((line) => {
306
+ const [hash, message, author, date] = line.split("\0");
307
+ return {
308
+ hash: hash?.substring(0, 7) || "",
309
+ message: message || "",
310
+ author: author || "",
311
+ date: date || "",
312
+ };
313
+ });
314
+
315
+ return { commits };
316
+ }
317
+
318
+ // ─── Git Create Branch ────────────────────────────────────────
319
+
320
+ async function gitCreateBranch(cwd, params) {
321
+ const name = typeof params.name === "string" ? params.name.trim() : "";
322
+ if (!name) {
323
+ throw gitError("missing_branch_name", "Branch name is required.");
324
+ }
325
+
326
+ try {
327
+ await git(cwd, "checkout", "-b", name);
328
+ } catch (err) {
329
+ if (err.message?.includes("already exists")) {
330
+ throw gitError("branch_exists", `Branch '${name}' already exists.`);
331
+ }
332
+ throw gitError("create_branch_failed", err.message || "Failed to create branch.");
333
+ }
334
+
335
+ const status = await gitStatus(cwd);
336
+ return { branch: name, status };
337
+ }
338
+
339
+ // ─── Git Stash ────────────────────────────────────────────────
340
+
341
+ async function gitStash(cwd) {
342
+ const output = await git(cwd, "stash");
343
+ const saved = !output.includes("No local changes");
344
+ return { success: saved, message: output.trim() };
345
+ }
346
+
347
+ // ─── Git Stash Pop ────────────────────────────────────────────
348
+
349
+ async function gitStashPop(cwd) {
350
+ try {
351
+ const output = await git(cwd, "stash", "pop");
352
+ return { success: true, message: output.trim() };
353
+ } catch (err) {
354
+ throw gitError("stash_pop_conflict", err.message || "Stash pop failed due to conflicts.");
355
+ }
356
+ }
357
+
358
+ // ─── Git Reset to Remote ──────────────────────────────────────
359
+
360
+ async function gitResetToRemote(cwd, params) {
361
+ if (params.confirm !== "discard_runtime_changes") {
362
+ throw gitError(
363
+ "confirmation_required",
364
+ 'This action requires params.confirm === "discard_runtime_changes".'
365
+ );
366
+ }
367
+
368
+ let hasUpstream = true;
369
+ try {
370
+ await git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}");
371
+ } catch {
372
+ hasUpstream = false;
373
+ }
374
+
375
+ if (hasUpstream) {
376
+ await git(cwd, "fetch");
377
+ await git(cwd, "reset", "--hard", "@{u}");
378
+ } else {
379
+ await git(cwd, "checkout", "--", ".");
380
+ }
381
+ await git(cwd, "clean", "-fd");
382
+
383
+ const status = await gitStatus(cwd);
384
+ return { success: true, status };
385
+ }
386
+
387
+ // ─── Git Remote URL ───────────────────────────────────────────
388
+
389
+ async function gitRemoteUrl(cwd) {
390
+ const raw = (await git(cwd, "config", "--get", "remote.origin.url")).trim();
391
+ const ownerRepo = parseOwnerRepo(raw);
392
+ return { url: raw, ownerRepo };
393
+ }
394
+
395
+ function parseOwnerRepo(remoteUrl) {
396
+ const match = remoteUrl.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
397
+ return match ? match[1] : null;
398
+ }
399
+
400
+ // ─── Git Branches With Status ─────────────────────────────────
401
+
402
+ async function gitBranchesWithStatus(cwd) {
403
+ const [branchResult, statusResult] = await Promise.all([
404
+ gitBranches(cwd),
405
+ gitStatus(cwd),
406
+ ]);
407
+ return { ...branchResult, status: statusResult };
408
+ }
409
+
410
+ // Computes the local repo delta that still exists on this machine and is not on the remote.
411
+ async function repoDiffTotals(cwd, context) {
412
+ const baseRef = await resolveRepoDiffBase(cwd, context.tracking);
413
+ const trackedTotals = await diffTotalsAgainstBase(cwd, baseRef);
414
+ const untrackedPaths = context.fileLines
415
+ .filter((line) => line.startsWith("?? "))
416
+ .map((line) => line.substring(3).trim())
417
+ .filter(Boolean);
418
+ const untrackedTotals = await diffTotalsForUntrackedFiles(cwd, untrackedPaths);
419
+
420
+ return {
421
+ additions: trackedTotals.additions + untrackedTotals.additions,
422
+ deletions: trackedTotals.deletions + untrackedTotals.deletions,
423
+ binaryFiles: trackedTotals.binaryFiles + untrackedTotals.binaryFiles,
424
+ };
425
+ }
426
+
427
+ // Uses upstream when available; otherwise falls back to commits not yet present on any remote.
428
+ async function resolveRepoDiffBase(cwd, tracking) {
429
+ if (tracking) {
430
+ try {
431
+ return (await git(cwd, "merge-base", "HEAD", "@{u}")).trim();
432
+ } catch {
433
+ // Fall through to the local-only commit scan if upstream metadata is stale.
434
+ }
435
+ }
436
+
437
+ const firstLocalOnlyCommit = (
438
+ await git(cwd, "rev-list", "--reverse", "--topo-order", "HEAD", "--not", "--remotes")
439
+ )
440
+ .trim()
441
+ .split("\n")
442
+ .find(Boolean);
443
+
444
+ if (!firstLocalOnlyCommit) {
445
+ return "HEAD";
446
+ }
447
+
448
+ try {
449
+ return (await git(cwd, "rev-parse", `${firstLocalOnlyCommit}^`)).trim();
450
+ } catch {
451
+ return EMPTY_TREE_HASH;
452
+ }
453
+ }
454
+
455
+ async function diffTotalsAgainstBase(cwd, baseRef) {
456
+ const output = await git(cwd, "diff", "--numstat", baseRef);
457
+ return parseNumstatTotals(output);
458
+ }
459
+
460
+ async function gitDiffAgainstBase(cwd, baseRef) {
461
+ return git(cwd, "diff", "--binary", "--find-renames", baseRef);
462
+ }
463
+
464
+ async function diffTotalsForUntrackedFiles(cwd, filePaths) {
465
+ if (!filePaths.length) {
466
+ return { additions: 0, deletions: 0, binaryFiles: 0 };
467
+ }
468
+
469
+ const totals = await Promise.all(
470
+ filePaths.map(async (filePath) => {
471
+ const output = await gitDiffNoIndexNumstat(cwd, filePath);
472
+ return parseNumstatTotals(output);
473
+ })
474
+ );
475
+
476
+ return totals.reduce(
477
+ (aggregate, current) => ({
478
+ additions: aggregate.additions + current.additions,
479
+ deletions: aggregate.deletions + current.deletions,
480
+ binaryFiles: aggregate.binaryFiles + current.binaryFiles,
481
+ }),
482
+ { additions: 0, deletions: 0, binaryFiles: 0 }
483
+ );
484
+ }
485
+
486
+ function parseNumstatTotals(output) {
487
+ return output
488
+ .trim()
489
+ .split("\n")
490
+ .filter(Boolean)
491
+ .reduce(
492
+ (aggregate, line) => {
493
+ const [rawAdditions, rawDeletions] = line.split("\t");
494
+ const additions = Number.parseInt(rawAdditions, 10);
495
+ const deletions = Number.parseInt(rawDeletions, 10);
496
+ const isBinary = !Number.isFinite(additions) || !Number.isFinite(deletions);
497
+
498
+ return {
499
+ additions: aggregate.additions + (Number.isFinite(additions) ? additions : 0),
500
+ deletions: aggregate.deletions + (Number.isFinite(deletions) ? deletions : 0),
501
+ binaryFiles: aggregate.binaryFiles + (isBinary ? 1 : 0),
502
+ };
503
+ },
504
+ { additions: 0, deletions: 0, binaryFiles: 0 }
505
+ );
506
+ }
507
+
508
+ async function gitDiffNoIndexNumstat(cwd, filePath) {
509
+ try {
510
+ const { stdout } = await execFileAsync(
511
+ "git",
512
+ ["diff", "--no-index", "--numstat", "--", "/dev/null", filePath],
513
+ { cwd, timeout: GIT_TIMEOUT_MS }
514
+ );
515
+ return stdout;
516
+ } catch (err) {
517
+ if (typeof err?.code === "number" && err.code === 1) {
518
+ return err.stdout || "";
519
+ }
520
+ const msg = (err.stderr || err.message || "").trim();
521
+ throw new Error(msg || "git diff --no-index failed");
522
+ }
523
+ }
524
+
525
+ async function diffPatchForUntrackedFiles(cwd, filePaths) {
526
+ if (!filePaths.length) {
527
+ return "";
528
+ }
529
+
530
+ const patches = await Promise.all(filePaths.map((filePath) => gitDiffNoIndexPatch(cwd, filePath)));
531
+ return patches.filter(Boolean).join("\n\n");
532
+ }
533
+
534
+ async function gitDiffNoIndexPatch(cwd, filePath) {
535
+ try {
536
+ const { stdout } = await execFileAsync(
537
+ "git",
538
+ ["diff", "--no-index", "--binary", "--", "/dev/null", filePath],
539
+ { cwd, timeout: GIT_TIMEOUT_MS }
540
+ );
541
+ return stdout;
542
+ } catch (err) {
543
+ if (typeof err?.code === "number" && err.code === 1) {
544
+ return err.stdout || "";
545
+ }
546
+ const msg = (err.stderr || err.message || "").trim();
547
+ throw new Error(msg || "git diff --no-index failed");
548
+ }
549
+ }
550
+
551
+ // ─── Helpers ──────────────────────────────────────────────────
552
+
553
+ function git(cwd, ...args) {
554
+ return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
555
+ .then(({ stdout }) => stdout)
556
+ .catch((err) => {
557
+ const msg = (err.stderr || err.message || "").trim();
558
+ const wrapped = new Error(msg || "git command failed");
559
+ throw wrapped;
560
+ });
561
+ }
562
+
563
+ async function revListCounts(cwd) {
564
+ const output = await git(cwd, "rev-list", "--left-right", "--count", "HEAD...@{u}");
565
+ const parts = output.trim().split(/\s+/);
566
+ return {
567
+ ahead: parseInt(parts[0], 10) || 0,
568
+ behind: parseInt(parts[1], 10) || 0,
569
+ };
570
+ }
571
+
572
+ function parseBranchFromStatus(line) {
573
+ // "## main...origin/main" or "## main" or "## HEAD (no branch)"
574
+ const match = line.match(/^## (.+?)(?:\.{3}|$)/);
575
+ if (!match) return null;
576
+ const branch = match[1].trim();
577
+ if (branch === "HEAD (no branch)" || branch.includes("HEAD detached")) return null;
578
+ return branch;
579
+ }
580
+
581
+ function parseTrackingFromStatus(line) {
582
+ const match = line.match(/\.{3}(.+?)(?:\s|$)/);
583
+ return match ? match[1].trim() : null;
584
+ }
585
+
586
+ function computeState(dirty, ahead, behind, detached, noUpstream) {
587
+ if (detached) return "detached_head";
588
+ if (noUpstream) return "no_upstream";
589
+ if (dirty && behind > 0) return "dirty_and_behind";
590
+ if (dirty) return "dirty";
591
+ if (ahead > 0 && behind > 0) return "diverged";
592
+ if (behind > 0) return "behind_only";
593
+ if (ahead > 0) return "ahead_only";
594
+ return "up_to_date";
595
+ }
596
+
597
+ async function detectDefaultBranch(cwd, branches) {
598
+ // Try symbolic-ref first
599
+ try {
600
+ const ref = await git(cwd, "symbolic-ref", "refs/remotes/origin/HEAD");
601
+ const defaultBranch = ref.trim().replace("refs/remotes/origin/", "");
602
+ if (defaultBranch && branches.includes(defaultBranch)) {
603
+ return defaultBranch;
604
+ }
605
+ } catch {
606
+ // ignore
607
+ }
608
+
609
+ // Fallback: prefer main, then master
610
+ if (branches.includes("main")) return "main";
611
+ if (branches.includes("master")) return "master";
612
+ return branches[0] || null;
613
+ }
614
+
615
+ function gitError(errorCode, userMessage) {
616
+ const err = new Error(userMessage);
617
+ err.errorCode = errorCode;
618
+ err.userMessage = userMessage;
619
+ return err;
620
+ }
621
+
622
+ // Resolves git commands to a concrete local directory.
623
+ async function resolveGitCwd(params) {
624
+ const requestedCwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory]);
625
+
626
+ if (!requestedCwd) {
627
+ throw gitError(
628
+ "missing_working_directory",
629
+ "Git actions require a bound local working directory."
630
+ );
631
+ }
632
+
633
+ if (!isExistingDirectory(requestedCwd)) {
634
+ throw gitError(
635
+ "missing_working_directory",
636
+ "The requested local working directory does not exist on this Mac."
637
+ );
638
+ }
639
+
640
+ return requestedCwd;
641
+ }
642
+
643
+ function firstNonEmptyString(candidates) {
644
+ for (const candidate of candidates) {
645
+ if (typeof candidate !== "string") {
646
+ continue;
647
+ }
648
+
649
+ const trimmed = candidate.trim();
650
+ if (trimmed) {
651
+ return trimmed;
652
+ }
653
+ }
654
+
655
+ return null;
656
+ }
657
+
658
+ function isExistingDirectory(candidatePath) {
659
+ try {
660
+ return fs.statSync(candidatePath).isDirectory();
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+
666
+ async function resolveRepoRoot(cwd) {
667
+ const output = await git(cwd, "rev-parse", "--show-toplevel");
668
+ const repoRoot = output.trim();
669
+ return repoRoot || null;
670
+ }
671
+
672
+ module.exports = { handleGitRequest, gitStatus };