dsh-git-ui 0.0.2 → 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.
@@ -1,11 +1,31 @@
1
- import { type GitStatusConfig, type SnapshotDeps } from './core.ts';
2
- import type { GitActionResult, GitActionRequest } from './types.ts';
1
+ import { resolveWorkspace, type GitStatusConfig, type SnapshotDeps } from './core.ts';
2
+ import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts';
3
+ /**
4
+ * A branch name is valid when it matches git's ref-name grammar at the level
5
+ * we care about: non-empty, ASCII ref chars only, no leading `-` (option
6
+ * injection guard, though argv never shells out), no `..` (path traversal of
7
+ * refs), no trailing `/`, and no double slashes.
8
+ */
9
+ export declare function isValidBranchName(name: string): boolean;
3
10
  /**
4
11
  * A path is safe when it is repo-relative and stays inside the work tree:
5
12
  * reject absolute paths, drive letters / backslashes, and `..` escapes
6
13
  * (checked via path resolution against the realpath'd root).
7
14
  */
8
15
  export declare function isSafePath(path: string, root: string): boolean;
16
+ /** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
17
+ export declare function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, {
18
+ ok: false;
19
+ }>['error']): GitActionResult & {
20
+ ok: false;
21
+ };
22
+ /**
23
+ * 把 git 命令失败归类为可预期的业务错误(其余保持 git-error)。
24
+ * 切分支被工作区未提交变更阻止是最常见的可预期失败:git 输出
25
+ * "would be overwritten by checkout"(或中文本地化 "将被 checkout 覆盖"),
26
+ * 归一化为 local-changes-block,client 据此给友好提示 + 处理变更引导。
27
+ */
28
+ export declare function classifyOperationError(kind: GitAction['kind'], message: string): GitOperationErrorCode;
9
29
  /**
10
30
  * Execute one management action against the session's repository and return
11
31
  * the refreshed snapshot on success (the caller re-renders from it, so the
@@ -71,7 +71,13 @@ export declare function resolveWorkspace(deps: SnapshotDeps, sessionId: string):
71
71
  * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
72
72
  * 2. `git branch --show-current` — null when detached
73
73
  * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
74
- * 4. `git status --porcelain=v1 -z --branch`
74
+ * 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
75
75
  * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
76
+ *
77
+ * --untracked-files=all:git 默认 normal 模式会把整目录未跟踪折叠为单条
78
+ * `?? dir/`(尾斜杠)且不枚举其内部文件——隐藏目录(.agent/.tianqi 等)的
79
+ * 变更因此从不进入变更清单。`all` 强制逐文件枚举(与 IDEA / VSCode 一致),
80
+ * 内部文件得以展示;maxChanges 截断列表、maxStatusBytes spill 保计数精确,
81
+ * 超大未跟踪树(如未 gitignore 的构建产物)经此路径优雅降级。
76
82
  */
77
83
  export declare function snapshotForSession(deps: SnapshotDeps, config: GitStatusConfig, sessionId: string): Promise<GitSnapshotResult>;
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * dsh-git-ui host half: the `gitInfo` Remote service.
3
3
  *
4
- * Cordis shell only — every behavior lives in `core.ts`/`actions.ts` behind
5
- * injected structural faces, so tests never need a cordis runtime. The class
6
- * is a plugin in its own right (class form), mounted by the bundle patch row
7
- * with the package name; the gateway exposes `gitInfo/snapshot` and
8
- * `gitInfo/run` through SRC discovery (`typertRemote` binding + `@Remote`
9
- * marker).
4
+ * Cordis shell only — every behavior lives in `core.ts`/`actions.ts`/
5
+ * `queries.ts` behind injected structural faces, so tests never need a
6
+ * cordis runtime. The class is a plugin in its own right (class form),
7
+ * mounted by the bundle patch row with the package name; the gateway exposes
8
+ * `gitInfo/snapshot`, `gitInfo/run` and `gitInfo/query` through SRC
9
+ * discovery (`typertRemote` binding + `@Remote` marker).
10
10
  */
11
11
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
12
12
  import type { Context } from '@deepseek-ai/cordis';
13
- import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts';
14
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts';
13
+ import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts';
14
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest, GitQuery, GitQueryResult, GitQueryRequest, GitQueryResponse, GitBranch, GitFileStat, GitRef } from './types.ts';
15
15
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts';
16
- export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts';
17
- export { isSafePath, runAction } from './actions.ts';
16
+ export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts';
17
+ export { isSafePath, isValidBranchName, runAction } from './actions.ts';
18
+ export { runQuery } from './queries.ts';
18
19
  /** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
19
20
  export declare class GitStatusService extends TypertRemoteService {
20
21
  static inject: string[];
@@ -24,5 +25,6 @@ export declare class GitStatusService extends TypertRemoteService {
24
25
  private deps;
25
26
  snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult>;
26
27
  run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult>;
28
+ query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse>;
27
29
  }
28
30
  export default GitStatusService;
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;
@@ -296,7 +395,7 @@ async function snapshotForSession(deps, config, sessionId) {
296
395
  if ("failure" in headRun) return { ok: false, error: headRun.failure };
297
396
  if (headRun.run.timedOut) return { ok: false, error: { code: "timeout" } };
298
397
  const head = headRun.run.exitCode === 0 ? headRun.run.stdout.trim() || null : null;
299
- 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);
300
399
  if ("failure" in status) return { ok: false, error: status.failure };
301
400
  if (status.run.timedOut) return { ok: false, error: { code: "timeout" } };
302
401
  if (status.run.exitCode !== 0) {
@@ -334,15 +433,15 @@ import { resolve, sep } from "node:path";
334
433
  function buildArgv(action, root) {
335
434
  switch (action.kind) {
336
435
  case "stage":
337
- return withPaths(["git", "add", "--"], action.paths, root);
436
+ return withPaths([["git", "add", "--"]], action.paths, root);
338
437
  case "stage-all":
339
438
  return { argv: [["git", "add", "-A"]] };
340
439
  case "unstage":
341
- return withPaths(["git", "restore", "--staged", "--"], action.paths, root);
440
+ return withPaths([["git", "restore", "--staged", "--"]], action.paths, root);
342
441
  case "unstage-all":
343
442
  return { argv: [["git", "restore", "--staged", "--", "."]] };
344
443
  case "discard":
345
- return withPaths(["git", "restore", "--"], action.paths, root);
444
+ return withPaths([["git", "restore", "--"]], action.paths, root);
346
445
  case "discard-all":
347
446
  return { argv: [["git", "restore", "--staged", "--", "."], ["git", "restore", "--", "."]] };
348
447
  case "commit": {
@@ -350,16 +449,30 @@ function buildArgv(action, root) {
350
449
  if (action.paths === void 0 || action.paths.length === 0) {
351
450
  return { argv: [["git", "commit", "-m", message]] };
352
451
  }
353
- return withPaths(["git", "commit", "-m", message, "--"], action.paths, root);
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]] };
354
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"]] };
355
464
  }
356
465
  }
357
- function withPaths(prefix, paths, root) {
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) {
358
471
  if (paths.length === 0) return { error: "no paths given" };
359
472
  for (const path of paths) {
360
473
  if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` };
361
474
  }
362
- return { argv: [[...prefix, ...paths]] };
475
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) };
363
476
  }
364
477
  function isSafePath(path, root) {
365
478
  if (path === "") return false;
@@ -374,6 +487,12 @@ function operationError(failure) {
374
487
  }
375
488
  return { ok: false, error: failure };
376
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
+ }
377
496
  async function runAction(deps, config, request) {
378
497
  const workspace = await resolveWorkspace(deps, request.sessionId);
379
498
  if (!workspace.ok) return operationError(workspace.error);
@@ -381,6 +500,13 @@ async function runAction(deps, config, request) {
381
500
  if (request.action.kind === "commit" && request.action.message.trim() === "") {
382
501
  return { ok: false, error: { code: "git-error", message: "commit message is empty" } };
383
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
+ }
384
510
  const built = buildArgv(request.action, root);
385
511
  if ("error" in built) return { ok: false, error: { code: "invalid-path", message: built.error } };
386
512
  let lastStdout = "";
@@ -390,10 +516,11 @@ async function runAction(deps, config, request) {
390
516
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
391
517
  if (outcome.run.exitCode !== 0) {
392
518
  const message = outcome.run.stderr.trim() || outcome.run.stdout.trim();
519
+ const code = classifyOperationError(request.action.kind, message);
393
520
  return {
394
521
  ok: false,
395
522
  error: {
396
- code: "git-error",
523
+ code,
397
524
  message: message !== "" ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`
398
525
  }
399
526
  };
@@ -405,9 +532,216 @@ async function runAction(deps, config, request) {
405
532
  return { ok: true, snapshot: snapshot.value, ...lastStdout === "" ? {} : { output: lastStdout } };
406
533
  }
407
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
+
408
742
  // src/host/index.ts
409
- var _run_dec, _snapshot_dec, _a, _init;
410
- var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _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) {
411
745
  constructor(ctx, config) {
412
746
  super(ctx, "gitInfo");
413
747
  __runInitializers(_init, 5, this);
@@ -455,10 +789,18 @@ var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec =
455
789
  }
456
790
  return runAction(adapted.deps, this.config, request);
457
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);
798
+ }
458
799
  };
459
800
  _init = __decoratorStart(_a);
460
801
  __decorateElement(_init, 1, "snapshot", _snapshot_dec, GitStatusService);
461
802
  __decorateElement(_init, 1, "run", _run_dec, GitStatusService);
803
+ __decorateElement(_init, 1, "query", _query_dec, GitStatusService);
462
804
  __decoratorMetadata(_init, GitStatusService);
463
805
  __publicField(GitStatusService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
464
806
  var index_default = GitStatusService;
@@ -467,10 +809,13 @@ export {
467
809
  GitStatusService,
468
810
  index_default as default,
469
811
  isSafePath,
812
+ isValidBranchName,
470
813
  normalizeConfig,
471
814
  parseBranchOutput,
472
815
  parseLogOutput,
816
+ parseNameStatusOutput,
473
817
  parseStatusOutput,
474
- runAction
818
+ runAction,
819
+ runQuery
475
820
  };
476
821
  //# sourceMappingURL=index.js.map