dsh-git-ui 0.0.1 → 0.1.0

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.
package/lib/host/index.js CHANGED
@@ -136,10 +136,8 @@ function parseStatusHeader(line) {
136
136
  const branch = core.split("...", 1)[0] ?? core;
137
137
  return { branch: branch === "" ? null : branch, unborn: false, ahead, behind };
138
138
  }
139
- function changeStatus(x, y) {
140
- if (x === "?" && y === "?") return "untracked";
141
- if (x === "U" || y === "U" || x !== " " && y !== " ") return "conflicted";
142
- switch (x) {
139
+ function singleStatus(code) {
140
+ switch (code) {
143
141
  case "A":
144
142
  return "added";
145
143
  case "M":
@@ -156,6 +154,9 @@ function changeStatus(x, y) {
156
154
  return "modified";
157
155
  }
158
156
  }
157
+ function isConflicted(x, y) {
158
+ return x === "U" || y === "U" || x === "A" && y === "A" || x === "D" && y === "D";
159
+ }
159
160
  function parseStatusOutput(output, maxChanges) {
160
161
  const raw = output.split(NUL);
161
162
  const segments = raw[raw.length - 1] === "" ? raw.slice(0, -1) : raw;
@@ -165,6 +166,13 @@ function parseStatusOutput(output, maxChanges) {
165
166
  let untracked = 0;
166
167
  const changes = [];
167
168
  let truncated = false;
169
+ const pushChange = (path, status, isStaged) => {
170
+ if (changes.length < maxChanges) {
171
+ changes.push({ path, status, staged: isStaged, isDirectory: path.endsWith("/") });
172
+ } else {
173
+ truncated = true;
174
+ }
175
+ };
168
176
  for (let index = 1; index < segments.length; index += 1) {
169
177
  const entry = segments[index] ?? "";
170
178
  const x = entry[0] ?? " ";
@@ -176,14 +184,19 @@ function parseStatusOutput(output, maxChanges) {
176
184
  }
177
185
  if (x === "?" && y === "?") {
178
186
  untracked += 1;
179
- } else {
180
- if (x !== " " && x !== "?") staged += 1;
181
- if (y !== " " && y !== "?") modified += 1;
187
+ pushChange(path, "untracked", false);
188
+ continue;
182
189
  }
183
- if (changes.length < maxChanges) {
184
- changes.push({ path, status: changeStatus(x, y), staged: x !== " " && x !== "?" });
190
+ if (x !== " " && x !== "?") staged += 1;
191
+ if (y !== " " && y !== "?") modified += 1;
192
+ if (isConflicted(x, y)) {
193
+ pushChange(path, "conflicted", true);
194
+ } else if (x !== " " && y !== " ") {
195
+ pushChange(path, singleStatus(x), true);
196
+ pushChange(path, singleStatus(y), false);
185
197
  } else {
186
- truncated = true;
198
+ const isStaged = x !== " ";
199
+ pushChange(path, singleStatus(isStaged ? x : y), isStaged);
187
200
  }
188
201
  }
189
202
  return {
@@ -214,6 +227,92 @@ function parseLogOutput(output) {
214
227
  }
215
228
  return commits;
216
229
  }
230
+ function parseGraphLogOutput(output, remotes = []) {
231
+ const commits = [];
232
+ for (const line of output.split("\n")) {
233
+ if (line === "") continue;
234
+ const [hash, shortHash, subject, author, dateIso, parentField, decoField] = line.split(LOG_SEP);
235
+ if (hash === void 0 || hash === "") continue;
236
+ const parents = (parentField ?? "").split(" ").filter((p) => p !== "");
237
+ commits.push({
238
+ hash,
239
+ shortHash: shortHash ?? "",
240
+ subject: subject ?? "",
241
+ author: author ?? "",
242
+ dateIso: dateIso ?? "",
243
+ parents,
244
+ refs: parseDecorations(decoField ?? "", remotes)
245
+ });
246
+ }
247
+ return commits;
248
+ }
249
+ function parseDecorations(decorations, remotes) {
250
+ const trimmed = decorations.trim();
251
+ if (trimmed === "") return [];
252
+ const refs = [];
253
+ for (const token of trimmed.split(", ")) {
254
+ if (token.startsWith("HEAD -> ")) {
255
+ refs.push({ kind: "branch", name: token.slice(8), head: true });
256
+ } else if (token.startsWith("tag: ")) {
257
+ refs.push({ kind: "tag", name: token.slice(5), head: false });
258
+ } else if (remotes.some((remote) => token === remote || token.startsWith(`${remote}/`))) {
259
+ refs.push({ kind: "remote", name: token, head: false });
260
+ } else {
261
+ refs.push({ kind: "branch", name: token, head: false });
262
+ }
263
+ }
264
+ return refs;
265
+ }
266
+ function parseShowMeta(output) {
267
+ const trimmed = output.trimEnd();
268
+ if (trimmed === "") return null;
269
+ const [hash, shortHash, subject, author, dateIso, ...bodyParts] = trimmed.split(LOG_SEP);
270
+ if (hash === void 0 || hash === "") return null;
271
+ return {
272
+ commit: {
273
+ hash,
274
+ shortHash: shortHash ?? "",
275
+ subject: subject ?? "",
276
+ author: author ?? "",
277
+ dateIso: dateIso ?? ""
278
+ },
279
+ body: bodyParts.join(LOG_SEP).trimEnd()
280
+ };
281
+ }
282
+ function nameStatusCode(code) {
283
+ switch (code) {
284
+ case "A":
285
+ return "added";
286
+ case "D":
287
+ return "deleted";
288
+ case "R":
289
+ return "renamed";
290
+ case "T":
291
+ return "typechange";
292
+ case "U":
293
+ return "conflicted";
294
+ default:
295
+ return "modified";
296
+ }
297
+ }
298
+ function parseNameStatusOutput(output) {
299
+ const raw = output.split(NUL);
300
+ const segments = raw[raw.length - 1] === "" ? raw.slice(0, -1) : raw;
301
+ const rows = [];
302
+ for (let i = 0; i < segments.length; i += 1) {
303
+ const entry = segments[i] ?? "";
304
+ if (entry === "") continue;
305
+ const code = entry[0] ?? " ";
306
+ if (code === "R" || code === "C") {
307
+ rows.push({ path: segments[i + 2] ?? "", status: nameStatusCode(code) });
308
+ i += 2;
309
+ } else {
310
+ rows.push({ path: segments[i + 1] ?? "", status: nameStatusCode(code) });
311
+ i += 1;
312
+ }
313
+ }
314
+ return rows;
315
+ }
217
316
  function parseBranchOutput(output) {
218
317
  const trimmed = output.trim();
219
318
  return trimmed === "" ? null : trimmed;
@@ -257,7 +356,7 @@ async function runCommand(runner, argv, cwd, label, signal) {
257
356
  return { failure: { code: "git-unavailable", detail: `${label}: ${error instanceof Error ? error.message : String(error)}` } };
258
357
  }
259
358
  }
260
- async function snapshotForSession(deps, config, sessionId) {
359
+ async function resolveWorkspace(deps, sessionId) {
261
360
  const resolved = await resolveCwd(deps.sessions, sessionId);
262
361
  if (!resolved.ok) return { ok: false, error: resolved.error };
263
362
  let realCwd;
@@ -282,6 +381,12 @@ async function snapshotForSession(deps, config, sessionId) {
282
381
  }
283
382
  const root = toplevel.run.stdout.trim();
284
383
  if (root === "") return { ok: false, error: { code: "not-a-git-repo" } };
384
+ return { ok: true, cwd: realCwd, root };
385
+ }
386
+ async function snapshotForSession(deps, config, sessionId) {
387
+ const workspace = await resolveWorkspace(deps, sessionId);
388
+ if (!workspace.ok) return { ok: false, error: workspace.error };
389
+ const root = workspace.root;
285
390
  const branchRun = await runCommand(deps.run, ["git", "branch", "--show-current"], root, "branch", deps.signal);
286
391
  if ("failure" in branchRun) return { ok: false, error: branchRun.failure };
287
392
  if (branchRun.run.timedOut) return { ok: false, error: { code: "timeout" } };
@@ -290,7 +395,7 @@ async function snapshotForSession(deps, config, sessionId) {
290
395
  if ("failure" in headRun) return { ok: false, error: headRun.failure };
291
396
  if (headRun.run.timedOut) return { ok: false, error: { code: "timeout" } };
292
397
  const head = headRun.run.exitCode === 0 ? headRun.run.stdout.trim() || null : null;
293
- const status = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--branch"], root, "status", deps.signal);
398
+ const status = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--branch", "--untracked-files=all"], root, "status", deps.signal);
294
399
  if ("failure" in status) return { ok: false, error: status.failure };
295
400
  if (status.run.timedOut) return { ok: false, error: { code: "timeout" } };
296
401
  if (status.run.exitCode !== 0) {
@@ -323,25 +428,337 @@ async function snapshotForSession(deps, config, sessionId) {
323
428
  return { ok: true, value: snapshot };
324
429
  }
325
430
 
431
+ // src/host/actions.ts
432
+ import { resolve, sep } from "node:path";
433
+ function buildArgv(action, root) {
434
+ switch (action.kind) {
435
+ case "stage":
436
+ return withPaths([["git", "add", "--"]], action.paths, root);
437
+ case "stage-all":
438
+ return { argv: [["git", "add", "-A"]] };
439
+ case "unstage":
440
+ return withPaths([["git", "restore", "--staged", "--"]], action.paths, root);
441
+ case "unstage-all":
442
+ return { argv: [["git", "restore", "--staged", "--", "."]] };
443
+ case "discard":
444
+ return withPaths([["git", "restore", "--"]], action.paths, root);
445
+ case "discard-all":
446
+ return { argv: [["git", "restore", "--staged", "--", "."], ["git", "restore", "--", "."]] };
447
+ case "commit": {
448
+ const message = action.message.trim();
449
+ if (action.paths === void 0 || action.paths.length === 0) {
450
+ return { argv: [["git", "commit", "-m", message]] };
451
+ }
452
+ return withPaths([["git", "add", "--"], ["git", "commit", "-m", message, "--"]], action.paths, root);
453
+ }
454
+ case "branch-create": {
455
+ const from = action.from === void 0 || action.from === "" ? [] : [action.from];
456
+ return { argv: [["git", "branch", action.name, ...from]] };
457
+ }
458
+ case "branch-checkout":
459
+ return { argv: [["git", "checkout", action.name]] };
460
+ case "branch-delete":
461
+ return { argv: [["git", "branch", action.force === true ? "-D" : "-d", action.name]] };
462
+ case "fetch":
463
+ return { argv: [["git", "fetch", "--all", "--prune"]] };
464
+ }
465
+ }
466
+ function isValidBranchName(name) {
467
+ if (name === "" || name.startsWith("-") || name.includes("..") || name.endsWith("/") || name.includes("//")) return false;
468
+ return /^[A-Za-z0-9._/-]+$/.test(name);
469
+ }
470
+ function withPaths(prefixes, paths, root) {
471
+ if (paths.length === 0) return { error: "no paths given" };
472
+ for (const path of paths) {
473
+ if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` };
474
+ }
475
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) };
476
+ }
477
+ function isSafePath(path, root) {
478
+ if (path === "") return false;
479
+ if (path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:/.test(path)) return false;
480
+ const resolved = resolve(root, path);
481
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
482
+ return resolved === root || resolved.startsWith(prefix);
483
+ }
484
+ function operationError(failure) {
485
+ if (failure.code === "git-unavailable") {
486
+ return { ok: false, error: { code: "git-error", message: failure.detail } };
487
+ }
488
+ return { ok: false, error: failure };
489
+ }
490
+ function classifyOperationError(kind, message) {
491
+ if (kind === "branch-checkout" && /would be overwritten by checkout|将被 checkout 覆盖|有未跟踪工作区文件将会被 checkout 覆盖/i.test(message)) {
492
+ return "local-changes-block";
493
+ }
494
+ return "git-error";
495
+ }
496
+ async function runAction(deps, config, request) {
497
+ const workspace = await resolveWorkspace(deps, request.sessionId);
498
+ if (!workspace.ok) return operationError(workspace.error);
499
+ const root = workspace.root;
500
+ if (request.action.kind === "commit" && request.action.message.trim() === "") {
501
+ return { ok: false, error: { code: "git-error", message: "commit message is empty" } };
502
+ }
503
+ const kind = request.action.kind;
504
+ if (kind === "branch-create" || kind === "branch-checkout" || kind === "branch-delete") {
505
+ const name = request.action.name;
506
+ if (!isValidBranchName(name)) {
507
+ return { ok: false, error: { code: "invalid-name", message: `invalid branch name: ${name}` } };
508
+ }
509
+ }
510
+ const built = buildArgv(request.action, root);
511
+ if ("error" in built) return { ok: false, error: { code: "invalid-path", message: built.error } };
512
+ let lastStdout = "";
513
+ for (const argv of built.argv) {
514
+ const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal);
515
+ if ("failure" in outcome) return operationError(outcome.failure);
516
+ if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
517
+ if (outcome.run.exitCode !== 0) {
518
+ const message = outcome.run.stderr.trim() || outcome.run.stdout.trim();
519
+ const code = classifyOperationError(request.action.kind, message);
520
+ return {
521
+ ok: false,
522
+ error: {
523
+ code,
524
+ message: message !== "" ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`
525
+ }
526
+ };
527
+ }
528
+ lastStdout = outcome.run.stdout.trim();
529
+ }
530
+ const snapshot = await snapshotForSession(deps, config, request.sessionId);
531
+ if (!snapshot.ok) return operationError(snapshot.error);
532
+ return { ok: true, snapshot: snapshot.value, ...lastStdout === "" ? {} : { output: lastStdout } };
533
+ }
534
+
535
+ // src/host/queries.ts
536
+ var LOG_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%aI";
537
+ var GRAPH_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%P%x1f%D";
538
+ var MAX_HISTORY_LIMIT = 1e3;
539
+ function isValidRef(ref) {
540
+ return ref !== "" && !/\s/.test(ref);
541
+ }
542
+ async function runQuery(deps, config, request) {
543
+ void config;
544
+ const workspace = await resolveWorkspace(deps, request.sessionId);
545
+ if (!workspace.ok) return { ok: false, error: operationError(workspace.error).error };
546
+ const root = workspace.root;
547
+ const query = request.query;
548
+ switch (query.kind) {
549
+ case "history":
550
+ return historyQuery(deps, root, query);
551
+ case "diff":
552
+ return diffQuery(deps, root, query.path, query.base);
553
+ case "show":
554
+ return showQuery(deps, root, query.ref);
555
+ case "branches":
556
+ return branchesQuery(deps, root);
557
+ case "tags":
558
+ return tagsQuery(deps, root);
559
+ case "authors":
560
+ return authorsQuery(deps, root);
561
+ }
562
+ }
563
+ async function historyQuery(deps, root, query) {
564
+ const safeLimit = Math.min(Math.max(Math.floor(query.limit), 0), MAX_HISTORY_LIMIT);
565
+ const safeSkip = Math.max(Math.floor(query.skip), 0);
566
+ if (query.ref !== void 0 && !isValidRef(query.ref)) {
567
+ return { ok: false, error: { code: "invalid-name", message: `invalid ref: ${query.ref}` } };
568
+ }
569
+ const search = query.search?.trim() ?? "";
570
+ const hexLike = /^[0-9a-f]{7,40}$/i.test(search);
571
+ const scope = hexLike ? [] : query.ref === void 0 ? ["--all"] : [query.ref];
572
+ const noWalk = hexLike ? ["--no-walk", search] : [];
573
+ const filters = [];
574
+ if (search !== "" && !hexLike) filters.push("--regexp-ignore-case", "--extended-regexp", `--grep=${search}`);
575
+ const author = query.author?.trim() ?? "";
576
+ if (author !== "") filters.push(`--author=${author}`);
577
+ const since = query.since?.trim() ?? "";
578
+ if (since !== "") filters.push(`--since=${since}`);
579
+ const log = await runCommand(
580
+ deps.run,
581
+ // -n/--skip 前置:git 的 `-n N` 出现在 `--no-walk` 之后会重置 no-walk
582
+ // (hexLike 会错误列出全部祖先),前置则 `-n 1000 --no-walk x` 恒返回单条。
583
+ ["git", "log", ...filters, `--skip=${String(safeSkip)}`, "-n", String(safeLimit), ...noWalk, ...scope, `--format=${GRAPH_FORMAT}`],
584
+ root,
585
+ "log",
586
+ deps.signal
587
+ );
588
+ if ("failure" in log) return { ok: false, error: operationError(log.failure).error };
589
+ if (log.run.timedOut) return { ok: false, error: { code: "timeout" } };
590
+ if (log.run.exitCode !== 0) {
591
+ if (log.run.stderr.includes("does not have any commits")) {
592
+ return { ok: true, value: { kind: "history", commits: [], total: 0 } };
593
+ }
594
+ if (hexLike && /unknown revision|bad revision|ambiguous/i.test(log.run.stderr)) {
595
+ return { ok: true, value: { kind: "history", commits: [], total: 0 } };
596
+ }
597
+ return gitError("log", log.run.stderr, log.run.stdout);
598
+ }
599
+ let total = 0;
600
+ const count = await runCommand(deps.run, ["git", "rev-list", "--count", ...noWalk, ...scope, ...filters], root, "rev-list", deps.signal);
601
+ if ("run" in count && count.run.exitCode === 0) {
602
+ const parsed = Number(count.run.stdout.trim());
603
+ if (Number.isFinite(parsed) && parsed >= 0) total = parsed;
604
+ }
605
+ let remotes = [];
606
+ const remoteRun = await runCommand(deps.run, ["git", "remote"], root, "remote", deps.signal);
607
+ if ("run" in remoteRun && remoteRun.run.exitCode === 0) {
608
+ remotes = remoteRun.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== "");
609
+ }
610
+ return { ok: true, value: { kind: "history", commits: parseGraphLogOutput(log.run.stdout, remotes), total } };
611
+ }
612
+ async function diffQuery(deps, root, path, base) {
613
+ if (!isSafePath(path, root)) return { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } };
614
+ const argv = base === "staged" ? ["git", "diff", "--cached", "-U999999", "--", path] : ["git", "diff", "-U999999", "--", path];
615
+ const run = await runCommand(deps.run, argv, root, "diff", deps.signal);
616
+ if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
617
+ if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
618
+ if (run.run.exitCode !== 0) return gitError("diff", run.run.stderr, run.run.stdout);
619
+ if (run.run.stdout !== "" || base === "staged") {
620
+ return { ok: true, value: { kind: "diff", path, text: run.run.stdout } };
621
+ }
622
+ const ni = await runCommand(deps.run, ["git", "diff", "--no-index", "-U999999", "--", "/dev/null", path], root, "diff --no-index", deps.signal);
623
+ if ("failure" in ni) return { ok: false, error: operationError(ni.failure).error };
624
+ if (ni.run.timedOut) return { ok: false, error: { code: "timeout" } };
625
+ if (ni.run.exitCode !== 0 && ni.run.exitCode !== 1) return gitError("diff", ni.run.stderr, ni.run.stdout);
626
+ return { ok: true, value: { kind: "diff", path, text: ni.run.stdout } };
627
+ }
628
+ async function showQuery(deps, root, ref) {
629
+ if (!isValidRef(ref)) return { ok: false, error: { code: "invalid-name", message: `invalid ref: ${ref}` } };
630
+ const meta = await runCommand(
631
+ deps.run,
632
+ ["git", "show", "-s", `--format=${LOG_FORMAT}%x1f%b`, ref],
633
+ root,
634
+ "show",
635
+ deps.signal
636
+ );
637
+ if ("failure" in meta) return { ok: false, error: operationError(meta.failure).error };
638
+ if (meta.run.timedOut) return { ok: false, error: { code: "timeout" } };
639
+ if (meta.run.exitCode !== 0) return gitError("show", meta.run.stderr, meta.run.stdout);
640
+ const stat2 = await runCommand(
641
+ deps.run,
642
+ ["git", "-c", "core.quotePath=false", "show", "--format=", "--name-status", "-z", ref],
643
+ root,
644
+ "show --name-status",
645
+ deps.signal
646
+ );
647
+ if ("failure" in stat2) return { ok: false, error: operationError(stat2.failure).error };
648
+ if (stat2.run.timedOut) return { ok: false, error: { code: "timeout" } };
649
+ if (stat2.run.exitCode !== 0) return gitError("show", stat2.run.stderr, stat2.run.stdout);
650
+ const parsed = parseShowMeta(meta.run.stdout);
651
+ return {
652
+ ok: true,
653
+ value: {
654
+ kind: "show",
655
+ ref,
656
+ commit: parsed?.commit ?? null,
657
+ body: parsed?.body ?? "",
658
+ stats: parseNameStatusOutput(stat2.run.stdout)
659
+ }
660
+ };
661
+ }
662
+ async function authorsQuery(deps, root) {
663
+ const run = await runCommand(deps.run, ["git", "log", "--all", "-n", "1000", "--format=%an"], root, "log authors", deps.signal);
664
+ if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
665
+ if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
666
+ if (run.run.exitCode !== 0) return { ok: true, value: { kind: "authors", authors: [] } };
667
+ const authors = [...new Set(run.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== ""))].sort().slice(0, 100);
668
+ return { ok: true, value: { kind: "authors", authors } };
669
+ }
670
+ async function tagsQuery(deps, root) {
671
+ const FORMAT = "--format=%(refname:short)%09%(objectname:short)";
672
+ const run = await runCommand(deps.run, ["git", "tag", FORMAT], root, "tag", deps.signal);
673
+ if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
674
+ if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
675
+ if (run.run.exitCode !== 0) return gitError("tag", run.run.stderr, run.run.stdout);
676
+ return { ok: true, value: { kind: "tags", tags: parseBranchList(run.run.stdout) } };
677
+ }
678
+ async function branchesQuery(deps, root) {
679
+ const LOCAL_FORMAT = "--format=%(refname:short)%09%(objectname:short)%09%(upstream:short)%09%(upstream:track)";
680
+ const REMOTE_FORMAT = "--format=%(refname:short)%09%(objectname:short)";
681
+ const local = await runCommand(deps.run, ["git", "branch", LOCAL_FORMAT], root, "branch", deps.signal);
682
+ if ("failure" in local) return { ok: false, error: operationError(local.failure).error };
683
+ if (local.run.timedOut) return { ok: false, error: { code: "timeout" } };
684
+ if (local.run.exitCode !== 0) return gitError("branch", local.run.stderr, local.run.stdout);
685
+ const remote = await runCommand(deps.run, ["git", "branch", "-r", REMOTE_FORMAT], root, "branch -r", deps.signal);
686
+ if ("failure" in remote) return { ok: false, error: operationError(remote.failure).error };
687
+ if (remote.run.timedOut) return { ok: false, error: { code: "timeout" } };
688
+ if (remote.run.exitCode !== 0) return gitError("branch -r", remote.run.stderr, remote.run.stdout);
689
+ const current = await runCommand(deps.run, ["git", "branch", "--show-current"], root, "branch", deps.signal);
690
+ const currentName = "run" in current && current.run.exitCode === 0 ? parseBranchOutput(current.run.stdout) : null;
691
+ let defaultBranch = null;
692
+ const def = await runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root, "symbolic-ref", deps.signal);
693
+ if ("run" in def && def.run.exitCode === 0) {
694
+ const value = def.run.stdout.trim();
695
+ const slash = value.indexOf("/");
696
+ defaultBranch = value === "" ? null : slash === -1 ? value : value.slice(slash + 1);
697
+ }
698
+ return {
699
+ ok: true,
700
+ value: {
701
+ kind: "branches",
702
+ current: currentName,
703
+ defaultBranch,
704
+ local: parseBranchList(local.run.stdout),
705
+ remote: parseBranchList(remote.run.stdout).filter((branch) => !branch.name.endsWith("/HEAD"))
706
+ }
707
+ };
708
+ }
709
+ function parseBranchList(output) {
710
+ const branches = [];
711
+ for (const line of output.split("\n")) {
712
+ if (line === "") continue;
713
+ const parts = line.split(" ");
714
+ const name = parts[0];
715
+ const hash = parts[1];
716
+ if (name === void 0 || name === "") continue;
717
+ const track = parts[3] ?? "";
718
+ const aheadMatch = /ahead (\d+)/.exec(track);
719
+ const behindMatch = /behind (\d+)/.exec(track);
720
+ const ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
721
+ const behind = behindMatch ? Number(behindMatch[1]) : 0;
722
+ branches.push({
723
+ name,
724
+ shortHash: hash === void 0 || hash === "" ? null : hash,
725
+ ...ahead > 0 ? { ahead } : {},
726
+ ...behind > 0 ? { behind } : {}
727
+ });
728
+ }
729
+ return branches;
730
+ }
731
+ function gitError(label, stderr, stdout) {
732
+ const message = stderr.trim() || stdout.trim();
733
+ return {
734
+ ok: false,
735
+ error: {
736
+ code: "git-error",
737
+ message: message !== "" ? message : `git ${label} failed`
738
+ }
739
+ };
740
+ }
741
+
326
742
  // src/host/index.ts
327
- var _snapshot_dec, _a, _init;
328
- var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _a) {
743
+ var _query_dec, _run_dec, _snapshot_dec, _a, _init;
744
+ var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _a) {
329
745
  constructor(ctx, config) {
330
746
  super(ctx, "gitInfo");
331
747
  __runInitializers(_init, 5, this);
332
748
  __publicField(this, "config");
333
749
  this.config = normalizeConfig(config);
334
750
  }
335
- async snapshot(request, signal) {
751
+ /** Adapter face shared by both endpoints (injected services + runner). */
752
+ deps(signal) {
336
753
  const subprocess = this.ctx.get("subprocess");
337
754
  if (subprocess === void 0) {
338
- return { ok: false, error: { code: "git-unavailable", detail: "subprocess service unavailable" } };
755
+ return { failure: { code: "git-unavailable", detail: "subprocess service unavailable" } };
339
756
  }
340
757
  const sessions = this.ctx.get("sessions");
341
758
  const persistence = this.ctx.get("sessionPersistence");
342
759
  const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes);
343
- return snapshotForSession(
344
- {
760
+ return {
761
+ deps: {
345
762
  run: runner,
346
763
  fs: { realpath, stat },
347
764
  sessions: {
@@ -357,14 +774,33 @@ var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec =
357
774
  }
358
775
  },
359
776
  signal
360
- },
361
- this.config,
362
- request.sessionId
363
- );
777
+ }
778
+ };
779
+ }
780
+ async snapshot(request, signal) {
781
+ const adapted = this.deps(signal);
782
+ if ("failure" in adapted) return { ok: false, error: adapted.failure };
783
+ return snapshotForSession(adapted.deps, this.config, request.sessionId);
784
+ }
785
+ async run(request, signal) {
786
+ const adapted = this.deps(signal);
787
+ if ("failure" in adapted) {
788
+ return { ok: false, error: { code: "git-error", message: adapted.failure.detail } };
789
+ }
790
+ return runAction(adapted.deps, this.config, request);
791
+ }
792
+ async query(request, signal) {
793
+ const adapted = this.deps(signal);
794
+ if ("failure" in adapted) {
795
+ return { ok: false, error: { code: "git-error", message: adapted.failure.detail } };
796
+ }
797
+ return runQuery(adapted.deps, this.config, request);
364
798
  }
365
799
  };
366
800
  _init = __decoratorStart(_a);
367
801
  __decorateElement(_init, 1, "snapshot", _snapshot_dec, GitStatusService);
802
+ __decorateElement(_init, 1, "run", _run_dec, GitStatusService);
803
+ __decorateElement(_init, 1, "query", _query_dec, GitStatusService);
368
804
  __decoratorMetadata(_init, GitStatusService);
369
805
  __publicField(GitStatusService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
370
806
  var index_default = GitStatusService;
@@ -372,9 +808,14 @@ export {
372
808
  DEFAULT_CONFIG,
373
809
  GitStatusService,
374
810
  index_default as default,
811
+ isSafePath,
812
+ isValidBranchName,
375
813
  normalizeConfig,
376
814
  parseBranchOutput,
377
815
  parseLogOutput,
378
- parseStatusOutput
816
+ parseNameStatusOutput,
817
+ parseStatusOutput,
818
+ runAction,
819
+ runQuery
379
820
  };
380
821
  //# sourceMappingURL=index.js.map