@xbzbing/dsh-git-panel 0.1.0 → 0.2.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
@@ -77,19 +77,22 @@ function createGitRunner(subprocess, timeoutMs, maxBytes) {
77
77
  try {
78
78
  outcome = await handle.done;
79
79
  } catch (error) {
80
+ const cancelled2 = opts.signal?.aborted === true && !controller.signal.aborted;
80
81
  if (controller.signal.aborted || opts.signal?.aborted === true) {
81
- return { exitCode: null, stdout: "", stderr: "", timedOut: true, stdoutLossy: false };
82
+ return { exitCode: null, stdout: "", stderr: "", timedOut: !cancelled2, cancelled: cancelled2, stdoutLossy: false };
82
83
  }
83
84
  throw error;
84
85
  }
85
86
  const stdout = handle.collected.stdout?.readFrom(0);
86
87
  const stderr = handle.collected.stderr?.readFrom(0);
87
88
  const resolved = await resolveStdout(stdout);
89
+ const cancelled = opts.signal?.aborted === true && !controller.signal.aborted;
88
90
  return {
89
91
  exitCode: outcome.exitCode,
90
92
  stdout: resolved.text,
91
93
  stderr: stderr?.text ?? "",
92
- timedOut: controller.signal.aborted || opts.signal?.aborted === true,
94
+ timedOut: controller.signal.aborted,
95
+ cancelled,
93
96
  stdoutLossy: resolved.lossy
94
97
  };
95
98
  } finally {
@@ -172,12 +175,13 @@ function parseGraphLog(stdout) {
172
175
  if (rec.trim() === "") continue;
173
176
  const parts = rec.split("");
174
177
  if (parts.length < 7) continue;
175
- const [hash, shortHash, parentsRaw, author, dateIso, decoration, subject] = parts;
178
+ const [hash, shortHash, parentsRaw, author, dateIso, decoration] = parts;
179
+ const subject = parts.slice(6).join("");
176
180
  const parents = (parentsRaw ?? "").trim() === "" ? [] : parentsRaw.trim().split(/\s+/);
177
181
  out.push({
178
182
  hash: hash ?? "",
179
183
  shortHash: shortHash ?? "",
180
- subject: subject ?? "",
184
+ subject,
181
185
  author: author ?? "",
182
186
  dateIso: dateIso ?? "",
183
187
  parents,
@@ -226,23 +230,38 @@ function parseBranches(stdout) {
226
230
  }
227
231
  return out;
228
232
  }
233
+ function parseTags(stdout) {
234
+ const out = [];
235
+ for (const line of stdout.split("\n")) {
236
+ if (line.trim() === "") continue;
237
+ const [name, shortHash = ""] = line.split("\0");
238
+ if (name) out.push({ name, shortHash: shortHash === "" ? null : shortHash });
239
+ }
240
+ return out;
241
+ }
229
242
  function parseNameStatus(stdout) {
230
243
  const out = [];
231
244
  const tokens = stdout.split("\0");
232
- for (let i = 0; i < tokens.length; i++) {
245
+ let i = 0;
246
+ while (i < tokens.length) {
233
247
  const tok = tokens[i];
234
- if (tok === void 0 || tok === "") continue;
248
+ if (tok === void 0 || tok === "") {
249
+ i += 1;
250
+ continue;
251
+ }
235
252
  const code = tok[0];
236
- if (code === void 0 || !/[AMDRCTU]/.test(code)) continue;
253
+ if (code === void 0 || !/[AMDRCTU]/.test(code)) break;
237
254
  if (code === "R" || code === "C") {
238
255
  const newPath = tokens[i + 2];
256
+ if (newPath === void 0) break;
257
+ out.push({ path: newPath, status: statusOf(code) });
258
+ i += 3;
259
+ } else {
260
+ const path = tokens[i + 1];
261
+ if (path === void 0) break;
262
+ if (path !== "") out.push({ path, status: statusOf(code) });
239
263
  i += 2;
240
- if (newPath) out.push({ path: newPath, status: statusOf(code) });
241
- continue;
242
264
  }
243
- const path = tokens[i + 1];
244
- i += 1;
245
- if (path) out.push({ path, status: statusOf(code) });
246
265
  }
247
266
  return out;
248
267
  }
@@ -261,6 +280,25 @@ function sumNumstat(stdout) {
261
280
  return { insertions, deletions };
262
281
  }
263
282
 
283
+ // src/host/validate.ts
284
+ import { isAbsolute, normalize } from "node:path";
285
+ function isSafePath(path) {
286
+ if (path === "" || isAbsolute(path)) return false;
287
+ const norm = normalize(path);
288
+ if (norm === ".." || norm.startsWith("../") || norm.startsWith("..\\")) return false;
289
+ return true;
290
+ }
291
+ var REV_META = /[\x00-\x20\x7f~^:?*[\\]/;
292
+ function isSafeRev(input) {
293
+ if (input === "" || input.startsWith("-")) return false;
294
+ if (REV_META.test(input)) return false;
295
+ if (input.includes("..") || input.includes("@{")) return false;
296
+ return true;
297
+ }
298
+ function isSafeBranchName(name) {
299
+ return isSafeRev(name) && !name.startsWith("/");
300
+ }
301
+
264
302
  // src/host/core.ts
265
303
  var DEFAULT_CONFIG = {
266
304
  timeoutMs: 8e3,
@@ -271,7 +309,7 @@ var DEFAULT_CONFIG = {
271
309
  };
272
310
  function normalizeConfig(raw) {
273
311
  const c = raw ?? {};
274
- const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : d;
312
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
275
313
  return {
276
314
  timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
277
315
  maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
@@ -284,6 +322,7 @@ function readBool(value, fallback) {
284
322
  const raw = value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
285
323
  return typeof raw === "boolean" ? raw : fallback;
286
324
  }
325
+ var NEG_CACHE_MS = 15e3;
287
326
  async function resolveWorkspace(deps, sessionId) {
288
327
  let cwd = deps.sessions.liveCwd(sessionId);
289
328
  if (cwd === void 0 || cwd === "") {
@@ -295,16 +334,26 @@ async function resolveWorkspace(deps, sessionId) {
295
334
  }
296
335
  const cached = deps.rootCache?.get(cwd);
297
336
  if (cached !== void 0) return { ok: true, root: cached };
337
+ const negAt = deps.rootNegCache?.get(cwd);
338
+ if (negAt !== void 0) {
339
+ if (Date.now() < negAt) return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
340
+ deps.rootNegCache?.delete(cwd);
341
+ }
298
342
  const top = await runCommand(deps.run, ["git", "rev-parse", "--show-toplevel"], cwd, "toplevel", deps.signal);
299
343
  if ("failure" in top) {
300
344
  return { ok: false, failure: { ok: false, error: mapRunFailure(top.failure) } };
301
345
  }
346
+ if (top.run.cancelled) return { ok: false, failure: { ok: false, error: { code: "cancelled" } } };
302
347
  if (top.run.timedOut) return { ok: false, failure: { ok: false, error: { code: "timeout" } } };
303
348
  if (top.run.exitCode !== 0) {
349
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
304
350
  return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
305
351
  }
306
352
  const raw = top.run.stdout.trim();
307
- if (raw === "") return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
353
+ if (raw === "") {
354
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
355
+ return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
356
+ }
308
357
  let root = raw;
309
358
  try {
310
359
  root = await deps.fs.realpath(raw);
@@ -326,6 +375,11 @@ function mapRunFailure(failure) {
326
375
  const message = failure instanceof Error ? failure.message : String(failure);
327
376
  return { code: "git-unavailable", detail: message };
328
377
  }
378
+ function mapWorkspaceFailure(failure) {
379
+ const error = failure.error;
380
+ const message = "detail" in error ? error.detail : void 0;
381
+ return { code: error.code, ...message !== void 0 ? { message } : {} };
382
+ }
329
383
  async function snapshotForSession(deps, config, sessionId) {
330
384
  const workspace = await resolveWorkspace(deps, sessionId);
331
385
  if (!workspace.ok) {
@@ -336,26 +390,30 @@ async function snapshotForSession(deps, config, sessionId) {
336
390
  return failure;
337
391
  }
338
392
  const root = workspace.root;
339
- const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes] = await Promise.all([
393
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes] = await Promise.all([
340
394
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
341
395
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
342
396
  runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
343
397
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
344
- runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal)
398
+ runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
399
+ runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
400
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal)
345
401
  ]);
346
402
  const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
347
403
  const unborn = "run" in headRes && headRes.run.exitCode !== 0;
348
404
  const head = unborn ? null : "run" in headRes ? headRes.run.stdout.trim() || null : null;
349
405
  let allChanges = [];
406
+ let statusLossy = false;
350
407
  if ("run" in statusRes && !statusRes.run.timedOut && statusRes.run.exitCode === 0) {
351
408
  allChanges = parseStatus(statusRes.run.stdout);
409
+ statusLossy = statusRes.run.stdoutLossy;
352
410
  }
353
- const truncated = allChanges.length > config.maxChanges;
354
- const changes = truncated ? allChanges.slice(0, config.maxChanges) : allChanges;
411
+ const truncated = allChanges.length > config.maxChanges || statusLossy;
412
+ const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
355
413
  let staged = 0;
356
414
  let modified = 0;
357
415
  let untracked = 0;
358
- for (const c of changes) {
416
+ for (const c of allChanges) {
359
417
  if (c.status === "untracked") untracked++;
360
418
  else if (c.staged) staged++;
361
419
  else modified++;
@@ -380,12 +438,35 @@ async function snapshotForSession(deps, config, sessionId) {
380
438
  };
381
439
  }
382
440
  }
441
+ const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
442
+ const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
443
+ let untrackedInsertions = 0;
444
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
445
+ const safeUntracked = untrackedPaths.filter(isSafePath).slice(0, 200);
446
+ if (safeUntracked.length > 0) {
447
+ const perFile = await Promise.all(
448
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
449
+ );
450
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
451
+ }
452
+ const lastChangeAt = await maxChangeMtime(deps, root, allChanges);
453
+ const distinct = new Set(allChanges.map((c) => c.path));
454
+ const stats = {
455
+ fileCount: distinct.size,
456
+ staged,
457
+ modified,
458
+ untracked,
459
+ insertions: wt.insertions + stg.insertions + untrackedInsertions,
460
+ deletions: wt.deletions + stg.deletions,
461
+ lastChangeAt,
462
+ headCommittedAt: lastCommit?.dateIso ?? null
463
+ };
383
464
  const snapshot = {
384
465
  root,
385
466
  branch,
386
467
  head,
387
468
  unborn,
388
- dirty: changes.length > 0,
469
+ dirty: allChanges.length > 0,
389
470
  staged,
390
471
  modified,
391
472
  untracked,
@@ -393,6 +474,7 @@ async function snapshotForSession(deps, config, sessionId) {
393
474
  behind,
394
475
  lastCommit,
395
476
  changes,
477
+ stats,
396
478
  truncated,
397
479
  refreshIntervalMs: config.refreshIntervalMs,
398
480
  showInputPill: config.showInputPill,
@@ -417,13 +499,7 @@ async function maxChangeMtime(deps, root, changes, cap = 200) {
417
499
  }
418
500
 
419
501
  // src/host/actions.ts
420
- import { isAbsolute, normalize } from "node:path";
421
- function isSafePath(path) {
422
- if (path === "" || isAbsolute(path)) return false;
423
- const norm = normalize(path);
424
- if (norm === ".." || norm.startsWith("../") || norm.startsWith("..\\")) return false;
425
- return true;
426
- }
502
+ import { join as join2, sep } from "node:path";
427
503
  function withPaths(prefixes, paths) {
428
504
  if (paths.length === 0) return { error: "invalid-path", message: "no paths given" };
429
505
  for (const path of paths) {
@@ -445,55 +521,66 @@ function planAction(action, unborn) {
445
521
  return withPaths([["git", "restore", "--"]], action.paths);
446
522
  case "commit": {
447
523
  const message = action.message.trim();
448
- const amendFlag = action.amend === true ? ["--amend"] : [];
449
- if (message === "" && action.amend !== true) return { error: "empty-message" };
450
- const msgArgs = message === "" ? [] : ["-m", message];
524
+ const amend = action.amend === true;
525
+ if (message === "" && !amend) return { error: "empty-message" };
526
+ const amendFlag = amend ? ["--amend"] : [];
527
+ const msgArgs = message === "" ? ["--no-edit"] : ["-m", message];
451
528
  if (action.paths === void 0 || action.paths.length === 0) {
452
529
  return { argv: [["git", "commit", ...amendFlag, ...msgArgs]] };
453
530
  }
454
531
  const staged = withPaths([["git", "add", "--"]], action.paths);
455
532
  if ("error" in staged) return staged;
456
533
  const commitCmd = ["git", "commit", ...amendFlag, ...msgArgs, "--", ...action.paths];
457
- for (const path of action.paths) {
458
- if (!isSafePath(path)) return { error: "invalid-path", message: `unsafe path: ${path}` };
459
- }
460
534
  return { argv: [...staged.argv, commitCmd] };
461
535
  }
462
536
  case "branch-checkout":
463
- return { argv: [["git", "checkout", action.name]] };
537
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
538
+ return { argv: [["git", "checkout", "--end-of-options", action.name]] };
464
539
  case "fetch":
465
540
  return { argv: [["git", "fetch", "--all", "--prune"]] };
466
541
  }
467
542
  }
468
543
  async function runAction(deps, config, request) {
469
544
  const workspace = await resolveWorkspace(deps, request.sessionId);
470
- if (!workspace.ok) {
471
- const error = workspace.failure.error;
472
- return { ok: false, error: { code: error.code, message: "detail" in error ? error.detail : void 0 } };
473
- }
545
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
474
546
  const root = workspace.root;
475
547
  const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
476
548
  const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
549
+ if (request.action.kind === "discard") {
550
+ const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
551
+ if (removed !== null) {
552
+ if (!removed.ok) return removed.result;
553
+ if (removed.remainingTracked.length === 0) {
554
+ const snapshot2 = await snapshotForSession(deps, config, request.sessionId);
555
+ if (!snapshot2.ok) return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
556
+ return { ok: true, snapshot: snapshot2.value, output: "" };
557
+ }
558
+ request = { ...request, action: { kind: "discard", paths: removed.remainingTracked } };
559
+ }
560
+ }
477
561
  const plan = planAction(request.action, unborn);
478
562
  if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
479
563
  let lastOutput = "";
480
- for (const argv of plan.argv) {
564
+ for (let step = 0; step < plan.argv.length; step += 1) {
565
+ const argv = plan.argv[step];
481
566
  const outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
567
+ const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
482
568
  if ("failure" in outcome) {
483
569
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
484
- return { ok: false, error: { code: "git-unavailable", message } };
570
+ return { ok: false, error: { code: "git-unavailable", message: message + where } };
485
571
  }
572
+ if (outcome.run.cancelled) return { ok: false, error: { code: "cancelled" } };
486
573
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
487
574
  lastOutput = outcome.run.stdout || outcome.run.stderr;
488
575
  if (outcome.run.exitCode !== 0) {
489
576
  const stderr = outcome.run.stderr;
490
577
  if (/nothing to commit|no changes added/i.test(stderr + lastOutput)) {
491
- return { ok: false, error: { code: "git-error", message: stderr.trim() || "nothing to commit" } };
578
+ return { ok: false, error: { code: "git-error", message: (stderr.trim() || "nothing to commit") + where } };
492
579
  }
493
580
  if (/would be overwritten by checkout|local changes/i.test(stderr)) {
494
- return { ok: false, error: { code: "local-changes-block", message: stderr.trim() } };
581
+ return { ok: false, error: { code: "local-changes-block", message: stderr.trim() + where } };
495
582
  }
496
- return { ok: false, error: { code: "git-error", message: stderr.trim() || `git exited ${outcome.run.exitCode}` } };
583
+ return { ok: false, error: { code: "git-error", message: (stderr.trim() || `git exited ${outcome.run.exitCode}`) + where } };
497
584
  }
498
585
  }
499
586
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -502,9 +589,39 @@ async function runAction(deps, config, request) {
502
589
  }
503
590
  return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
504
591
  }
592
+ async function discardUntracked(deps, config, root, sessionId, paths) {
593
+ const snap = await snapshotForSession(deps, config, sessionId);
594
+ if (!snap.ok) return null;
595
+ const untrackedSet = new Set(snap.value.changes.filter((c) => c.status === "untracked").map((c) => c.path));
596
+ const untracked = paths.filter((p) => untrackedSet.has(p));
597
+ if (untracked.length === 0) return null;
598
+ const tracked = paths.filter((p) => !untrackedSet.has(p));
599
+ let rootReal;
600
+ try {
601
+ rootReal = await deps.fs.realpath(root);
602
+ } catch {
603
+ return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
604
+ }
605
+ for (const path of untracked) {
606
+ if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
607
+ const target = join2(root, path);
608
+ let targetReal;
609
+ try {
610
+ targetReal = await deps.fs.realpath(target);
611
+ } catch {
612
+ continue;
613
+ }
614
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
615
+ return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
616
+ }
617
+ await deps.fs.remove(target).catch(() => {
618
+ });
619
+ }
620
+ return { ok: true, remainingTracked: tracked };
621
+ }
505
622
 
506
623
  // src/host/queries.ts
507
- import { join as join2 } from "node:path";
624
+ import { join as join3, sep as sep2 } from "node:path";
508
625
 
509
626
  // src/host/types.ts
510
627
  var IMAGE_MIME = {
@@ -533,16 +650,7 @@ function isHexLike(text) {
533
650
  }
534
651
  async function runQuery(deps, config, request) {
535
652
  const workspace = await resolveWorkspace(deps, request.sessionId);
536
- if (!workspace.ok) {
537
- const error = workspace.failure.error;
538
- return {
539
- ok: false,
540
- error: {
541
- code: error.code === "not-a-git-repo" || error.code === "cwd-unavailable" || error.code === "session-not-found" || error.code === "timeout" || error.code === "git-unavailable" ? error.code : "git-error",
542
- ..."detail" in error ? { message: error.detail } : {}
543
- }
544
- };
545
- }
653
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
546
654
  const root = workspace.root;
547
655
  const q = request.query;
548
656
  try {
@@ -551,6 +659,8 @@ async function runQuery(deps, config, request) {
551
659
  return await queryHistory(deps, root, q);
552
660
  case "diff":
553
661
  return await queryDiff(deps, root, q);
662
+ case "file-lines":
663
+ return await queryFileLines(deps, root, q);
554
664
  case "image-diff":
555
665
  return await queryImageDiff(deps, config, root, q);
556
666
  case "show":
@@ -564,7 +674,7 @@ async function runQuery(deps, config, request) {
564
674
  case "last-commit-message":
565
675
  return await queryLastCommitMessage(deps, root);
566
676
  case "worktree-stats":
567
- return await queryWorktreeStats(deps, config, root, request.sessionId);
677
+ return await queryWorktreeStats(deps, config, request.sessionId);
568
678
  }
569
679
  } catch (error) {
570
680
  const message = error instanceof Error ? error.message : String(error);
@@ -572,18 +682,13 @@ async function runQuery(deps, config, request) {
572
682
  }
573
683
  }
574
684
  async function queryHistory(deps, root, q) {
575
- const args = ["git", "log", GRAPH_FORMAT, `--max-count=${q.limit}`, `--skip=${q.skip}`];
685
+ const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
686
+ const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
687
+ const args = ["git", "log", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
576
688
  const search = q.search?.trim() ?? "";
577
689
  const hexJump = search !== "" && isHexLike(search);
578
690
  const countArgs = ["git", "rev-list", "--count"];
579
691
  if (!hexJump) {
580
- if (q.ref !== void 0 && q.ref !== "") {
581
- args.push(q.ref);
582
- countArgs.push(q.ref);
583
- } else {
584
- args.push("--all");
585
- countArgs.push("--all");
586
- }
587
692
  if (search !== "") {
588
693
  args.push("-i", "-E", `--grep=${search}`);
589
694
  countArgs.push("-i", "-E", `--grep=${search}`);
@@ -596,22 +701,35 @@ async function queryHistory(deps, root, q) {
596
701
  args.push(`--since=${q.since}`);
597
702
  countArgs.push(`--since=${q.since}`);
598
703
  }
704
+ if (q.ref !== void 0 && q.ref !== "") {
705
+ if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
706
+ args.push("--end-of-options", q.ref);
707
+ countArgs.push("--end-of-options", q.ref);
708
+ } else {
709
+ args.push("--all");
710
+ countArgs.push("--all");
711
+ }
599
712
  } else {
600
- args.push(search);
713
+ args.push("--end-of-options", search);
601
714
  }
602
715
  const [res, countRes] = await Promise.all([
603
716
  runCommand(deps.run, args, root, "history", deps.signal),
604
717
  hexJump ? Promise.resolve(null) : runCommand(deps.run, countArgs, root, "history-count", deps.signal)
605
718
  ]);
606
719
  if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
720
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
607
721
  if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
608
722
  if (res.run.exitCode !== 0) {
609
- return { ok: true, value: { kind: "history", commits: [], total: 0 } };
723
+ const stderr = res.run.stderr.trim();
724
+ if (hexJump || /unknown revision|bad revision|does not have any commits|ambiguous argument/i.test(stderr)) {
725
+ return { ok: true, value: { kind: "history", commits: [], total: 0 } };
726
+ }
727
+ return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
610
728
  }
611
729
  const commits = parseGraphLog(res.run.stdout);
612
730
  let total = -1;
613
731
  if (hexJump) {
614
- total = commits.length;
732
+ total = -1;
615
733
  } else if (countRes !== null && "run" in countRes && countRes.run.exitCode === 0) {
616
734
  const n = Number(countRes.run.stdout.trim());
617
735
  if (Number.isFinite(n)) total = n;
@@ -626,22 +744,75 @@ async function queryDiff(deps, root, q) {
626
744
  if (q.base === "staged") {
627
745
  args = ["git", "diff", unified, "--cached", "--", q.path];
628
746
  } else if (q.base === "commit") {
629
- args = ["git", "show", unified, `${q.commit}`, "--", q.path];
747
+ if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
748
+ args = ["git", "show", unified, "--end-of-options", q.commit, "--", q.path];
630
749
  } else {
631
750
  args = ["git", "diff", unified, "--", q.path];
632
751
  }
633
752
  const res = await runCommand(deps.run, args, root, "diff", deps.signal);
634
753
  if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
754
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
635
755
  if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
636
756
  let text = res.run.stdout;
637
757
  if (q.base === "worktree" && text.trim() === "") {
638
- const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
639
- if ("run" in noIndex) text = noIndex.run.stdout;
758
+ const tracked = await runCommand(deps.run, ["git", "ls-files", "--error-unmatch", "--", q.path], root, "diff-tracked-probe", deps.signal);
759
+ const isUntracked = !("run" in tracked) || tracked.run.exitCode !== 0;
760
+ if (isUntracked) {
761
+ const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
762
+ if ("run" in noIndex) text = noIndex.run.stdout;
763
+ }
640
764
  }
641
765
  return { ok: true, value: { kind: "diff", path: q.path, text } };
642
766
  }
767
+ async function queryFileLines(deps, root, q) {
768
+ if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
769
+ const start = Number.isFinite(q.start) ? Math.max(1, Math.floor(q.start)) : 1;
770
+ const end = Number.isFinite(q.end) ? Math.max(start, Math.floor(q.end)) : start;
771
+ let args;
772
+ if (q.base === "commit") {
773
+ if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
774
+ args = ["git", "show", "--end-of-options", `${q.commit}:${q.path}`];
775
+ } else {
776
+ args = ["git", "show", `--end-of-options`, `:${q.path}`];
777
+ if (q.base === "worktree") args = ["git", "cat-file", "-p", `:${q.path}`];
778
+ }
779
+ const res = await runCommand(deps.run, args, root, "file-lines", deps.signal);
780
+ if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
781
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
782
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
783
+ if (res.run.exitCode !== 0) {
784
+ if (q.base === "worktree") {
785
+ const wt = await readWorktreeText(deps, root, q.path);
786
+ if (wt !== null) return sliceLines(q.path, wt, start, end);
787
+ }
788
+ return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "no such blob" } };
789
+ }
790
+ return sliceLines(q.path, res.run.stdout, start, end);
791
+ }
792
+ async function readWorktreeText(deps, root, path) {
793
+ try {
794
+ const file = join3(root, path);
795
+ const real = await deps.fs.realpath(file);
796
+ const rootReal = await deps.fs.realpath(root);
797
+ if (real !== rootReal && !real.startsWith(rootReal + sep2)) return null;
798
+ const buf = await deps.fs.readFile(file);
799
+ return buf.toString("utf8");
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+ function sliceLines(path, content, start, end) {
805
+ const all = content.split("\n");
806
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
807
+ const from = Math.min(start, all.length + 1);
808
+ const to = Math.min(end, all.length);
809
+ const lines = from <= to ? all.slice(from - 1, to) : [];
810
+ const eof = to >= all.length;
811
+ return { ok: true, value: { kind: "file-lines", path, start: from, lines, eof } };
812
+ }
643
813
  async function queryImageDiff(deps, config, root, q) {
644
814
  if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
815
+ if (q.base === "commit" && !isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
645
816
  const mime = imageMimeFor(q.path);
646
817
  if (mime === null) return { ok: true, value: { kind: "image-diff", path: q.path, mime } };
647
818
  const oldSpec = q.base === "staged" ? `HEAD:${q.path}` : q.base === "commit" ? `${q.commit}^1:${q.path}` : `:${q.path}`;
@@ -694,7 +865,7 @@ async function blobSide(deps, gitDir, oid, cap) {
694
865
  if (!("run" in nameRes) || nameRes.run.exitCode !== 0) return void 0;
695
866
  const name = nameRes.run.stdout.trim();
696
867
  if (!/^[A-Za-z0-9._-]+$/.test(name)) return void 0;
697
- const file = join2(gitDir, name);
868
+ const file = join3(gitDir, name);
698
869
  try {
699
870
  const buf = await deps.fs.readFile(file);
700
871
  if (buf.length > cap) return { tooLarge: true };
@@ -708,7 +879,10 @@ async function blobSide(deps, gitDir, oid, cap) {
708
879
  }
709
880
  async function worktreeSide(deps, root, path, cap) {
710
881
  try {
711
- const file = join2(root, path);
882
+ const file = join3(root, path);
883
+ const real = await deps.fs.realpath(file);
884
+ const rootReal = await deps.fs.realpath(root);
885
+ if (real !== rootReal && !real.startsWith(rootReal + sep2)) return void 0;
712
886
  const info = await deps.fs.stat(file);
713
887
  if (info.size > cap) return { tooLarge: true };
714
888
  const buf = await deps.fs.readFile(file);
@@ -719,12 +893,14 @@ async function worktreeSide(deps, root, path, cap) {
719
893
  }
720
894
  }
721
895
  async function queryShow(deps, root, ref) {
896
+ if (!isSafeRev(ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${ref}` } };
722
897
  const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
723
898
  const [metaRes, statRes] = await Promise.all([
724
- runCommand(deps.run, ["git", "show", "-s", metaFormat, ref], root, "show-meta", deps.signal),
725
- runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", ref], root, "show-stat", deps.signal)
899
+ runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
900
+ runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal)
726
901
  ]);
727
902
  if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
903
+ if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
728
904
  if (metaRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
729
905
  if (metaRes.run.exitCode !== 0) {
730
906
  return { ok: false, error: { code: "git-error", message: metaRes.run.stderr.trim() || "unknown ref" } };
@@ -740,7 +916,7 @@ async function queryShow(deps, root, ref) {
740
916
  author: parts[3] ?? "",
741
917
  dateIso: parts[4] ?? ""
742
918
  };
743
- body = (parts[5] ?? "").trim();
919
+ body = parts.slice(5).join("").trim();
744
920
  }
745
921
  const stats = "run" in statRes && statRes.run.exitCode === 0 ? parseNameStatus(statRes.run.stdout) : [];
746
922
  return { ok: true, value: { kind: "show", ref, commit, body, stats } };
@@ -765,11 +941,7 @@ async function queryBranches(deps, root) {
765
941
  }
766
942
  async function queryTags(deps, root) {
767
943
  const res = await runCommand(deps.run, ["git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname:short)", "refs/tags"], root, "tags", deps.signal);
768
- const tags = "run" in res && res.run.exitCode === 0 ? res.run.stdout.split("\n").flatMap((line) => {
769
- if (line.trim() === "") return [];
770
- const [name, shortHash = ""] = line.split("\0");
771
- return name ? [{ name, shortHash: shortHash === "" ? null : shortHash }] : [];
772
- }) : [];
944
+ const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
773
945
  return { ok: true, value: { kind: "tags", tags } };
774
946
  }
775
947
  async function queryAuthors(deps, root) {
@@ -782,48 +954,18 @@ async function queryLastCommitMessage(deps, root) {
782
954
  const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
783
955
  return { ok: true, value: { kind: "last-commit-message", message } };
784
956
  }
785
- async function queryWorktreeStats(deps, config, root, sessionId) {
957
+ async function queryWorktreeStats(deps, config, sessionId) {
786
958
  const snapshot = await snapshotForSession(deps, config, sessionId);
787
959
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
788
- const snap = snapshot.value;
789
- const distinct = new Set(snap.changes.map((c) => c.path));
790
- const [worktreeNum, stagedNum, headTime] = await Promise.all([
791
- runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
792
- runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
793
- runCommand(deps.run, ["git", "log", "-1", "--format=%aI"], root, "head-time", deps.signal)
794
- ]);
795
- const a = "run" in worktreeNum && worktreeNum.run.exitCode === 0 ? sumNumstat(worktreeNum.run.stdout) : { insertions: 0, deletions: 0 };
796
- const b = "run" in stagedNum && stagedNum.run.exitCode === 0 ? sumNumstat(stagedNum.run.stdout) : { insertions: 0, deletions: 0 };
797
- let untrackedInsertions = 0;
798
- const untrackedPaths = snap.changes.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
799
- if (untrackedPaths.length > 0) {
800
- const safe = untrackedPaths.filter(isSafePath).slice(0, 200);
801
- if (safe.length > 0) {
802
- const noIndex = await runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", ...safe], root, "numstat-untracked", deps.signal);
803
- if ("run" in noIndex) untrackedInsertions = sumNumstat(noIndex.run.stdout).insertions;
804
- }
805
- }
806
- const lastChangeAt = await maxChangeMtime(deps, root, snap.changes);
807
- const headCommittedAt = "run" in headTime && headTime.run.exitCode === 0 ? headTime.run.stdout.trim() || null : null;
808
- const stats = {
809
- fileCount: distinct.size,
810
- staged: snap.staged,
811
- modified: snap.modified,
812
- untracked: snap.untracked,
813
- insertions: a.insertions + b.insertions + untrackedInsertions,
814
- deletions: a.deletions + b.deletions,
815
- lastChangeAt,
816
- headCommittedAt
817
- };
818
- return { ok: true, value: { kind: "worktree-stats", stats } };
960
+ return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
819
961
  }
820
962
 
821
963
  // src/host/version.ts
822
964
  import { readFile as readFile2 } from "node:fs/promises";
823
- import { dirname, join as join3 } from "node:path";
965
+ import { dirname, join as join4 } from "node:path";
824
966
  import { fileURLToPath } from "node:url";
825
967
  function manifestPath() {
826
- return join3(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
968
+ return join4(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
827
969
  }
828
970
  function stringField(value) {
829
971
  return typeof value === "string" && value !== "" ? value : void 0;
@@ -841,9 +983,10 @@ function compareVersions(a, b) {
841
983
  const split = (value) => {
842
984
  const trimmed = value.trim().replace(/^v/i, "");
843
985
  const [core, ...rest] = trimmed.split("-");
986
+ const preRaw = rest.join("-");
844
987
  return {
845
988
  core: core.split(".").map((part) => Number.parseInt(part, 10)).filter((part) => Number.isFinite(part)),
846
- prerelease: rest.length > 0 && rest.join("-") !== ""
989
+ pre: preRaw === "" ? [] : preRaw.split(".")
847
990
  };
848
991
  };
849
992
  const left = split(a);
@@ -855,17 +998,39 @@ function compareVersions(a, b) {
855
998
  if (l > r) return 1;
856
999
  if (l < r) return -1;
857
1000
  }
858
- if (left.prerelease === right.prerelease) return 0;
859
- return left.prerelease ? -1 : 1;
1001
+ if (left.pre.length === 0 && right.pre.length === 0) return 0;
1002
+ if (left.pre.length === 0) return 1;
1003
+ if (right.pre.length === 0) return -1;
1004
+ const preLen = Math.max(left.pre.length, right.pre.length);
1005
+ for (let i = 0; i < preLen; i += 1) {
1006
+ const lp = left.pre[i];
1007
+ const rp = right.pre[i];
1008
+ if (lp === void 0) return -1;
1009
+ if (rp === void 0) return 1;
1010
+ const ln = Number.parseInt(lp, 10);
1011
+ const rn = Number.parseInt(rp, 10);
1012
+ const lNum = Number.isFinite(ln) && String(ln) === lp;
1013
+ const rNum = Number.isFinite(rn) && String(rn) === rp;
1014
+ if (lNum && rNum) {
1015
+ if (ln !== rn) return ln > rn ? 1 : -1;
1016
+ } else if (lp !== rp) return lp > rp ? 1 : -1;
1017
+ }
1018
+ return 0;
860
1019
  }
1020
+ var manifestCache;
861
1021
  async function readManifest(path) {
862
1022
  const raw = await readFile2(path, "utf8");
863
1023
  const parsed = JSON.parse(raw);
864
1024
  if (typeof parsed !== "object" || parsed === null) throw new Error("package.json is not an object");
865
1025
  return parsed;
866
1026
  }
1027
+ async function loadManifest(path) {
1028
+ if (path !== void 0) return readManifest(path);
1029
+ if (manifestCache === void 0) manifestCache = await readManifest(manifestPath());
1030
+ return manifestCache;
1031
+ }
867
1032
  async function readVersionInfo(options = {}) {
868
- const manifest = await readManifest(options.manifestPath ?? manifestPath());
1033
+ const manifest = await loadManifest(options.manifestPath);
869
1034
  const current = stringField(manifest.version) ?? "0.0.0";
870
1035
  const repo = parseRepository(manifest.repository);
871
1036
  const repositoryUrl = repo === void 0 ? void 0 : `https://github.com/${repo.owner}/${repo.repo}`;
@@ -876,16 +1041,26 @@ async function readVersionInfo(options = {}) {
876
1041
  checkedRemote: false
877
1042
  };
878
1043
  }
1044
+ function safeReleaseUrl(url) {
1045
+ if (url === void 0) return void 0;
1046
+ return /^https:\/\/github\.com\//i.test(url) ? url : void 0;
1047
+ }
1048
+ var REMOTE_CACHE_MS = 10 * 60 * 1e3;
1049
+ var remoteCache;
879
1050
  async function checkLatestVersion(options = {}) {
1051
+ if (options.manifestPath === void 0 && options.fetchFn === void 0 && remoteCache !== void 0 && Date.now() - remoteCache.at < REMOTE_CACHE_MS) {
1052
+ return remoteCache.info;
1053
+ }
880
1054
  const base = await readVersionInfo({ manifestPath: options.manifestPath });
881
- const repo = parseRepository((await readManifest(options.manifestPath ?? manifestPath())).repository);
1055
+ const repo = parseRepository((await loadManifest(options.manifestPath)).repository);
882
1056
  if (repo === void 0) {
883
1057
  return { ...base, checkedRemote: false, error: "repository is not configured" };
884
1058
  }
885
1059
  const fetchFn = options.fetchFn ?? fetch;
886
1060
  try {
887
1061
  const response = await fetchFn(`https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`, {
888
- headers: { accept: "application/vnd.github+json", "user-agent": "dsh-git-panel" }
1062
+ headers: { accept: "application/vnd.github+json", "user-agent": "dsh-git-panel" },
1063
+ signal: AbortSignal.timeout(5e3)
889
1064
  });
890
1065
  if (!response.ok) {
891
1066
  return { ...base, checkedRemote: true, error: `GitHub responded with ${response.status}` };
@@ -897,13 +1072,15 @@ async function checkLatestVersion(options = {}) {
897
1072
  return { ...base, checkedRemote: true, error: "GitHub response did not include a release tag" };
898
1073
  }
899
1074
  const latest = tag.replace(/^v/i, "");
900
- return {
1075
+ const info = {
901
1076
  ...base,
902
1077
  checkedRemote: true,
903
1078
  latest,
904
1079
  updateAvailable: compareVersions(latest, base.current) > 0,
905
- releaseUrl: htmlUrl ?? `${base.repositoryUrl ?? ""}/releases/latest`
1080
+ releaseUrl: safeReleaseUrl(htmlUrl) ?? (base.repositoryUrl !== void 0 ? `${base.repositoryUrl}/releases/latest` : void 0)
906
1081
  };
1082
+ if (options.manifestPath === void 0 && options.fetchFn === void 0) remoteCache = { at: Date.now(), info };
1083
+ return info;
907
1084
  } catch (error) {
908
1085
  return { ...base, checkedRemote: true, error: error instanceof Error ? error.message : "Unable to reach GitHub" };
909
1086
  }
@@ -924,27 +1101,25 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
924
1101
  }
925
1102
  buildDeps(ctx, config) {
926
1103
  const rootCache = /* @__PURE__ */ new Map();
1104
+ const rootNegCache = /* @__PURE__ */ new Map();
927
1105
  const get = (key) => ctx.get(key);
1106
+ const fs = {
1107
+ realpath,
1108
+ stat: async (p) => stat(p),
1109
+ readFile: readFile3,
1110
+ remove: async (p) => {
1111
+ await rm(p, { force: true });
1112
+ }
1113
+ };
928
1114
  const subprocess = get("subprocess");
929
- if (subprocess === void 0) {
930
- return {
931
- run: { run: async () => {
932
- throw new Error("subprocess service unavailable");
933
- } },
934
- fs: { realpath, stat: async (p) => stat(p), readFile: readFile3, remove: async (p) => {
935
- await rm(p, { force: true });
936
- } },
937
- sessions: { liveCwd: () => void 0, persistedMeta: async () => void 0 },
938
- rootCache
939
- };
940
- }
1115
+ const run = subprocess === void 0 ? { run: async () => {
1116
+ throw new Error("subprocess service unavailable");
1117
+ } } : createGitRunner(subprocess, config.timeoutMs, config.maxBytes);
941
1118
  const sessions = get("sessions");
942
1119
  const persistence = get("sessionPersistence");
943
1120
  return {
944
- run: createGitRunner(subprocess, config.timeoutMs, config.maxBytes),
945
- fs: { realpath, stat: async (p) => stat(p), readFile: readFile3, remove: async (p) => {
946
- await rm(p, { force: true });
947
- } },
1121
+ run,
1122
+ fs,
948
1123
  sessions: {
949
1124
  liveCwd: (id) => sessions?.get(id)?.header?.cwd,
950
1125
  persistedMeta: async (id) => {
@@ -957,7 +1132,8 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
957
1132
  }
958
1133
  }
959
1134
  },
960
- rootCache
1135
+ rootCache,
1136
+ rootNegCache
961
1137
  };
962
1138
  }
963
1139
  async snapshot(request, signal) {
@@ -970,7 +1146,11 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
970
1146
  return runQuery(this.withSignal(signal), this.liveConfig(), request);
971
1147
  }
972
1148
  async version(request) {
973
- return request.check === true ? checkLatestVersion() : readVersionInfo();
1149
+ try {
1150
+ return request.check === true ? await checkLatestVersion() : await readVersionInfo();
1151
+ } catch (error) {
1152
+ return { current: "0.0.0", updateAvailable: false, checkedRemote: false, error: error instanceof Error ? error.message : "version unavailable" };
1153
+ }
974
1154
  }
975
1155
  /** Re-read config so a live-edited volatile field (showInputPill) is current. */
976
1156
  liveConfig() {