@xbzbing/dsh-git-panel 1.3.0 → 1.5.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.
@@ -11,5 +11,24 @@ type PlanResult = CommandPlan | {
11
11
  };
12
12
  /** Build the git command sequence for an action. */
13
13
  export declare function planAction(action: GitAction, unborn: boolean): PlanResult;
14
+ /**
15
+ * Classify a non-zero git exit into a wire error code + message. Order is
16
+ * semantic and must not be reshuffled:
17
+ * - conflict first — a stash apply/pop conflict prints both "CONFLICT …" and
18
+ * "no changes added to commit", so it must win over the nothing-to-commit
19
+ * rule below (git keeps the stash entry on conflict → recoverable).
20
+ * - index.lock busy (another process holds the lock).
21
+ * - nothing-to-commit / no changes added (a real commit no-op).
22
+ * - not-found (missing ref / tag / stash).
23
+ * - local-changes-block (a dirty-worktree refusal; neutral wording covers both
24
+ * `git checkout` and `git stash apply`).
25
+ * - revert-merge (reverting a merge commit without a mainline parent — git
26
+ * refuses before touching the work tree, so it is a plain refusal).
27
+ * - git-error fallback (surface the repo's own stderr).
28
+ */
29
+ export declare function classifyActionFailure(stdout: string, stderr: string, exitCode: number): {
30
+ code: GitErrorCode;
31
+ message: string;
32
+ };
14
33
  /** Execute a management action, returning the fresh snapshot on success. */
15
34
  export declare function runAction(deps: SnapshotDeps, config: GitPanelConfig, request: GitActionRequest): Promise<GitActionResult>;
@@ -1,6 +1,6 @@
1
1
  import type { GitRunner } from './git.ts';
2
2
  import type { AgentDefaultModelFace, LlmFace } from './llm-face.ts';
3
- import type { DiffViewMode, GitChange, GitErrorCode, GitSnapshotResult } from './types.ts';
3
+ import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.ts';
4
4
  export interface GitPanelConfig {
5
5
  readonly timeoutMs: number;
6
6
  /** Per-command stdout cap; also the per-side image-diff payload cap. */
@@ -11,6 +11,8 @@ export interface GitPanelConfig {
11
11
  readonly showInputPill: boolean;
12
12
  /** Default diff layout the views open with (user can switch per-diff). */
13
13
  readonly defaultDiffView: DiffViewMode;
14
+ /** Commit-graph line style (parallel = VSCode-like, compact = git log). */
15
+ readonly graphStyle: GraphStyle;
14
16
  /** Whether the "AI suggest" button shows in the commit box. */
15
17
  readonly suggestEnabled: boolean;
16
18
  /** Max diff bytes sent to the model; beyond this the diff is truncated. */
@@ -25,6 +27,8 @@ export declare const DEFAULT_CONFIG: GitPanelConfig;
25
27
  export declare function normalizeConfig(raw: unknown): GitPanelConfig;
26
28
  /** Read the default-diff-view field, unwrapping a schemastery volatile ref. */
27
29
  export declare function readDiffView(value: unknown, fallback: DiffViewMode): DiffViewMode;
30
+ /** Read the commit-graph style field, unwrapping a schemastery volatile ref. */
31
+ export declare function readGraphStyle(value: unknown, fallback: GraphStyle): GraphStyle;
28
32
  /**
29
33
  * Read a boolean config field, unwrapping a schemastery volatile reference
30
34
  * (`{ get() }`) so a live-editable toggle reflects the latest value. Absent or
@@ -81,6 +85,12 @@ export interface SnapshotDeps {
81
85
  readonly getLlm?: () => LlmFace | undefined;
82
86
  readonly getAgentDefaultModel?: () => AgentDefaultModelFace | undefined;
83
87
  }
88
+ /**
89
+ * Pick the primary remote from `git remote -v` output and parse its fetch URL.
90
+ * Prefers `origin`; otherwise the first remote listed. Lines look like
91
+ * `origin\thttps://…\t(fetch)` — only `(fetch)` rows are considered.
92
+ */
93
+ export declare function parsePrimaryRemote(stdout: string): GitRemote | null;
84
94
  export type WorkspaceResolution = {
85
95
  readonly ok: true;
86
96
  readonly root: string;
package/lib/host/git.d.ts CHANGED
@@ -18,6 +18,8 @@ interface SpawnSpec {
18
18
  };
19
19
  readonly graceMs: number;
20
20
  readonly signal?: AbortSignal;
21
+ /** Explicit child env entries merged onto the service's scrubbed base. */
22
+ readonly env?: Readonly<Record<string, string | undefined>>;
21
23
  }
22
24
  interface OutputRead {
23
25
  readonly text: string;
@@ -10,11 +10,12 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
11
  import Schema from '@deepseek-ai/schemastery';
12
12
  import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.ts';
13
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, GitVersionRequest, GitVersionInfo, DiffViewMode, DirEntry, GitSuggestRequest, GitSuggestResult, } from './types.ts';
14
- export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace } from './core.ts';
13
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, } from './types.ts';
14
+ export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote } from './core.ts';
15
15
  export { createGitRunner } from './git.ts';
16
- export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, sumNumstat } from './parser.ts';
17
- export { isSafePath, planAction, runAction } from './actions.ts';
16
+ export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.ts';
17
+ export { parseRemote, classifyHost } from './remote.ts';
18
+ export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.ts';
18
19
  export { runQuery } from './queries.ts';
19
20
  export { runSuggest } from './suggest.ts';
20
21
  export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.ts';
@@ -29,10 +30,12 @@ export declare class GitPanelService extends TypertRemoteService {
29
30
  static Config: Schema<Schemastery.ObjectS<NoInfer<{
30
31
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
31
32
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
33
+ graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
32
34
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
33
35
  }>>, Schemastery.ObjectT<NoInfer<{
34
36
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
35
37
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
38
+ graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
36
39
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
37
40
  }>>, "plain">;
38
41
  private readonly deps;
package/lib/host/index.js CHANGED
@@ -65,6 +65,11 @@ function createGitRunner(subprocess, timeoutMs, maxBytes) {
65
65
  const handle = subprocess.spawn({
66
66
  argv,
67
67
  cwd: opts.cwd,
68
+ // Never let a network command (fetch/pull) block on an interactive
69
+ // credential or host-key prompt: with stdin ignored git would hang
70
+ // until the timeout. GIT_TERMINAL_PROMPT=0 makes it fail fast instead,
71
+ // and is harmless for the non-network commands that share this runner.
72
+ env: { GIT_TERMINAL_PROMPT: "0" },
68
73
  stdio: {
69
74
  stdin: opts.stdinData === void 0 ? "ignore" : { data: opts.stdinData },
70
75
  stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
@@ -214,7 +219,15 @@ function parseRefs(decoration) {
214
219
  continue;
215
220
  }
216
221
  if (token.startsWith("tag: ")) {
217
- refs.push({ kind: "tag", name: token.slice("tag: ".length).trim(), head: false });
222
+ refs.push({ kind: "tag", name: token.slice("tag: ".length).trim().replace(/^refs\/tags\//, ""), head: false });
223
+ } else if (token.startsWith("refs/tags/")) {
224
+ refs.push({ kind: "tag", name: token.slice("refs/tags/".length), head: false });
225
+ } else if (token.startsWith("refs/remotes/")) {
226
+ refs.push({ kind: "remote", name: token.slice("refs/remotes/".length), head });
227
+ } else if (token.startsWith("refs/heads/")) {
228
+ refs.push({ kind: "branch", name: token.slice("refs/heads/".length), head });
229
+ } else if (head) {
230
+ refs.push({ kind: "branch", name: token, head });
218
231
  } else if (token.startsWith("origin/") || token.includes("/")) {
219
232
  refs.push({ kind: "remote", name: token, head });
220
233
  } else {
@@ -250,6 +263,25 @@ function parseTags(stdout) {
250
263
  }
251
264
  return out;
252
265
  }
266
+ function parseStashList(stdout) {
267
+ const out = [];
268
+ for (const record of stdout.split("\0")) {
269
+ if (record.trim() === "") continue;
270
+ const [selector = "", sha = "", subject = "", relTime = ""] = record.split("");
271
+ const idxMatch = /stash@\{(\d+)\}/.exec(selector);
272
+ if (idxMatch === null) continue;
273
+ const index = Number(idxMatch[1]);
274
+ let branch = null;
275
+ let message = subject;
276
+ const m = /^(?:WIP on|On) (.+?): (.*)$/s.exec(subject);
277
+ if (m !== null) {
278
+ branch = m[1] === "(no branch)" ? null : m[1] ?? null;
279
+ message = m[2] ?? "";
280
+ }
281
+ out.push({ index, sha: sha.trim(), message: message.trim(), branch, relTime: relTime.trim() });
282
+ }
283
+ return out;
284
+ }
253
285
  function parseNameStatus(stdout) {
254
286
  const out = [];
255
287
  const tokens = stdout.split("\0");
@@ -291,6 +323,52 @@ function sumNumstat(stdout) {
291
323
  return { insertions, deletions };
292
324
  }
293
325
 
326
+ // src/host/remote.ts
327
+ function classifyHost(host) {
328
+ const h = host.toLowerCase();
329
+ if (h === "github.com" || h.endsWith(".github.com")) return "github";
330
+ if (h === "gitlab.com" || h.endsWith(".gitlab.com")) return "gitlab";
331
+ if (h === "gitee.com" || h.endsWith(".gitee.com")) return "gitee";
332
+ if (h === "bitbucket.org" || h.endsWith(".bitbucket.org")) return "bitbucket";
333
+ return "other";
334
+ }
335
+ function normalizePath(path) {
336
+ return path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
337
+ }
338
+ function splitHostPath(raw) {
339
+ const url = raw.trim();
340
+ if (url === "") return null;
341
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
342
+ if (scp !== null && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
343
+ const host = scp[1];
344
+ const path = normalizePath(scp[2]);
345
+ return host !== "" && path !== "" ? { host, path } : null;
346
+ }
347
+ const scheme = url.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)\/(.+)$/i);
348
+ if (scheme !== null) {
349
+ let authority = scheme[1];
350
+ const at = authority.lastIndexOf("@");
351
+ if (at >= 0) authority = authority.slice(at + 1);
352
+ const host = authority.replace(/:\d+$/, "");
353
+ const path = normalizePath(scheme[2]);
354
+ return host !== "" && path !== "" ? { host, path } : null;
355
+ }
356
+ return null;
357
+ }
358
+ function parseRemote(name, url) {
359
+ const parsed = splitHostPath(url);
360
+ if (parsed === null) {
361
+ return { name, url, webUrl: null, host: null, hostKind: "other" };
362
+ }
363
+ return {
364
+ name,
365
+ url,
366
+ webUrl: `https://${parsed.host}/${parsed.path}`,
367
+ host: parsed.host,
368
+ hostKind: classifyHost(parsed.host)
369
+ };
370
+ }
371
+
294
372
  // src/host/validate.ts
295
373
  import { isAbsolute, normalize } from "node:path";
296
374
  function isSafePath(path) {
@@ -318,6 +396,7 @@ var DEFAULT_CONFIG = {
318
396
  refreshIntervalMs: 3e4,
319
397
  showInputPill: true,
320
398
  defaultDiffView: "unified",
399
+ graphStyle: "parallel",
321
400
  suggestEnabled: true,
322
401
  suggestMaxBytes: 64 * 1024,
323
402
  suggestMaxOutputTokens: 200,
@@ -336,6 +415,7 @@ function normalizeConfig(raw) {
336
415
  refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
337
416
  showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
338
417
  defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
418
+ graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
339
419
  suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
340
420
  suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
341
421
  suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
@@ -350,11 +430,32 @@ function readDiffView(value, fallback) {
350
430
  const raw = unwrapVolatile(value);
351
431
  return raw === "unified" || raw === "split" ? raw : fallback;
352
432
  }
433
+ function readGraphStyle(value, fallback) {
434
+ const raw = unwrapVolatile(value);
435
+ return raw === "parallel" || raw === "compact" ? raw : fallback;
436
+ }
353
437
  function readBool(value, fallback) {
354
438
  const raw = unwrapVolatile(value);
355
439
  return typeof raw === "boolean" ? raw : fallback;
356
440
  }
357
441
  var NEG_CACHE_MS = 15e3;
442
+ function parsePrimaryRemote(stdout) {
443
+ const fetchUrls = /* @__PURE__ */ new Map();
444
+ const order = [];
445
+ for (const line of stdout.split("\n")) {
446
+ const m = line.match(/^(\S+)\t(\S+)\s+\(fetch\)$/);
447
+ if (m === null) continue;
448
+ const name2 = m[1];
449
+ if (!fetchUrls.has(name2)) {
450
+ fetchUrls.set(name2, m[2]);
451
+ order.push(name2);
452
+ }
453
+ }
454
+ if (order.length === 0) return null;
455
+ const name = fetchUrls.has("origin") ? "origin" : order[0];
456
+ return parseRemote(name, fetchUrls.get(name));
457
+ }
458
+ var STATS_FANOUT_LIMIT = 200;
358
459
  async function resolveBrowseRoot(deps, sessionId) {
359
460
  const ws = await resolveWorkspace(deps, sessionId);
360
461
  if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
@@ -458,14 +559,17 @@ async function snapshotForSession(deps, config, sessionId) {
458
559
  return failure;
459
560
  }
460
561
  const root = workspace.root;
461
- const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes] = await Promise.all([
562
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
462
563
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
463
564
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
464
565
  runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
465
566
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
466
567
  runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
467
568
  runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
468
- runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal)
569
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
570
+ // `git remote -v` lists every remote's fetch/push URL; parsed into the
571
+ // primary remote (origin, else the first) for the left-column status bar.
572
+ runCommand(deps.run, ["git", "remote", "-v"], root, "remote", deps.signal)
469
573
  ]);
470
574
  const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
471
575
  const unborn = "run" in headRes && headRes.run.exitCode !== 0;
@@ -488,27 +592,32 @@ async function snapshotForSession(deps, config, sessionId) {
488
592
  }
489
593
  let ahead = 0;
490
594
  let behind = 0;
491
- if ("run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0) {
595
+ const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
596
+ if (hasUpstream && "run" in aheadBehindRes) {
492
597
  const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
493
598
  behind = Number(parts[0]) || 0;
494
599
  ahead = Number(parts[1]) || 0;
495
600
  }
601
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
496
602
  let lastCommit = null;
497
603
  if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
498
604
  lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
499
605
  }
500
606
  const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
501
607
  const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
608
+ const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
502
609
  let untrackedInsertions = 0;
503
- const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
504
- const safeUntracked = untrackedPaths.filter(isSafePath).slice(0, 200);
505
- if (safeUntracked.length > 0) {
506
- const perFile = await Promise.all(
507
- safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
508
- );
509
- for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
510
- }
511
- const lastChangeAt = await maxChangeMtime(deps, root, allChanges);
610
+ if (statsDetailed) {
611
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
612
+ const safeUntracked = untrackedPaths.filter(isSafePath);
613
+ if (safeUntracked.length > 0) {
614
+ const perFile = await Promise.all(
615
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
616
+ );
617
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
618
+ }
619
+ }
620
+ const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
512
621
  const distinct = new Set(allChanges.map((c) => c.path));
513
622
  const stats = {
514
623
  fileCount: distinct.size,
@@ -518,7 +627,8 @@ async function snapshotForSession(deps, config, sessionId) {
518
627
  insertions: wt.insertions + stg.insertions + untrackedInsertions,
519
628
  deletions: wt.deletions + stg.deletions,
520
629
  lastChangeAt,
521
- headCommittedAt: lastCommit?.dateIso ?? null
630
+ headCommittedAt: lastCommit?.dateIso ?? null,
631
+ partial: !statsDetailed
522
632
  };
523
633
  const snapshot = {
524
634
  root,
@@ -531,6 +641,8 @@ async function snapshotForSession(deps, config, sessionId) {
531
641
  untracked,
532
642
  ahead,
533
643
  behind,
644
+ hasUpstream,
645
+ remote,
534
646
  lastCommit,
535
647
  changes,
536
648
  stats,
@@ -538,6 +650,7 @@ async function snapshotForSession(deps, config, sessionId) {
538
650
  refreshIntervalMs: config.refreshIntervalMs,
539
651
  showInputPill: config.showInputPill,
540
652
  defaultDiffView: config.defaultDiffView,
653
+ graphStyle: config.graphStyle,
541
654
  suggestEnabled: config.suggestEnabled,
542
655
  checkedAt: Date.now()
543
656
  };
@@ -599,7 +712,73 @@ function planAction(action, unborn) {
599
712
  return { argv: [["git", "checkout", "--end-of-options", action.name]] };
600
713
  case "fetch":
601
714
  return { argv: [["git", "fetch", "--all", "--prune"]] };
715
+ case "pull-ff":
716
+ return { argv: [["git", "pull", "--ff-only"]] };
717
+ case "tag-create": {
718
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
719
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
720
+ const msg = action.message?.trim() ?? "";
721
+ const flags = msg === "" ? [] : ["-a", "-m", msg];
722
+ return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
723
+ }
724
+ case "tag-delete":
725
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
726
+ return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
727
+ case "stash-push": {
728
+ const msg = action.message?.trim() ?? "";
729
+ return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
730
+ }
731
+ case "stash-apply":
732
+ case "stash-pop":
733
+ case "stash-drop": {
734
+ if (!Number.isInteger(action.index) || action.index < 0) {
735
+ return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
736
+ }
737
+ const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
738
+ return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
739
+ }
740
+ case "revert": {
741
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
742
+ return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
743
+ }
744
+ case "reset": {
745
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
746
+ if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
747
+ return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
748
+ }
749
+ return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
750
+ }
751
+ }
752
+ }
753
+ function isIndexBusy(outcome) {
754
+ if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
755
+ return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
756
+ }
757
+ function classifyActionFailure(stdout, stderr, exitCode) {
758
+ const combined = stderr + stdout;
759
+ const err = stderr.trim();
760
+ if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
761
+ return { code: "conflict", message: err || "merge conflict" };
762
+ }
763
+ if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
764
+ return { code: "index-busy", message: err };
602
765
  }
766
+ if (/nothing to commit|no changes added/i.test(combined)) {
767
+ return { code: "git-error", message: err || "nothing to commit" };
768
+ }
769
+ if (/No such ref|not a valid reference|is not a stash|no tag|tag .* not found|unknown revision|bad revision|Could not parse object|ambiguous argument/i.test(combined)) {
770
+ return { code: "not-found", message: err || "not found" };
771
+ }
772
+ if (/Not possible to fast-forward|cannot fast-forward|not a fast-forward|Need to specify how to reconcile divergent branches/i.test(combined)) {
773
+ return { code: "not-ff", message: err || "cannot fast-forward (branches diverged)" };
774
+ }
775
+ if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
776
+ return { code: "local-changes-block", message: err };
777
+ }
778
+ if (/is a merge but no -m|mainline parent/i.test(combined)) {
779
+ return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
780
+ }
781
+ return { code: "git-error", message: err || `git exited ${exitCode}` };
603
782
  }
604
783
  async function runAction(deps, config, request) {
605
784
  const workspace = await resolveWorkspace(deps, request.sessionId);
@@ -607,6 +786,17 @@ async function runAction(deps, config, request) {
607
786
  const root = workspace.root;
608
787
  const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
609
788
  const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
789
+ const act = request.action;
790
+ if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
791
+ if (!Number.isInteger(act.index) || act.index < 0) {
792
+ return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
793
+ }
794
+ const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
795
+ const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
796
+ if (sha === "" || sha !== act.sha) {
797
+ return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
798
+ }
799
+ }
610
800
  if (request.action.kind === "discard") {
611
801
  const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
612
802
  if (removed !== null) {
@@ -624,7 +814,11 @@ async function runAction(deps, config, request) {
624
814
  let lastOutput = "";
625
815
  for (let step = 0; step < plan.argv.length; step += 1) {
626
816
  const argv = plan.argv[step];
627
- const outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
817
+ let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
818
+ if (isIndexBusy(outcome)) {
819
+ await new Promise((resolve) => setTimeout(resolve, 150));
820
+ outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
821
+ }
628
822
  const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
629
823
  if ("failure" in outcome) {
630
824
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
@@ -634,14 +828,20 @@ async function runAction(deps, config, request) {
634
828
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
635
829
  lastOutput = outcome.run.stdout || outcome.run.stderr;
636
830
  if (outcome.run.exitCode !== 0) {
637
- const stderr = outcome.run.stderr;
638
- if (/nothing to commit|no changes added/i.test(stderr + lastOutput)) {
639
- return { ok: false, error: { code: "git-error", message: (stderr.trim() || "nothing to commit") + where } };
640
- }
641
- if (/would be overwritten by checkout|local changes/i.test(stderr)) {
642
- return { ok: false, error: { code: "local-changes-block", message: stderr.trim() + where } };
831
+ const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
832
+ if (request.action.kind === "revert" && failure.code === "conflict") {
833
+ let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
834
+ if (isIndexBusy(abort)) {
835
+ await new Promise((resolve) => setTimeout(resolve, 150));
836
+ abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
837
+ }
838
+ const aborted = "run" in abort && abort.run.exitCode === 0;
839
+ return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
643
840
  }
644
- return { ok: false, error: { code: "git-error", message: (stderr.trim() || `git exited ${outcome.run.exitCode}`) + where } };
841
+ return { ok: false, error: { code: failure.code, message: failure.message + where } };
842
+ }
843
+ if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
844
+ return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
645
845
  }
646
846
  }
647
847
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -740,12 +940,16 @@ async function runQuery(deps, config, request) {
740
940
  return await queryBranches(deps, root);
741
941
  case "tags":
742
942
  return await queryTags(deps, root);
943
+ case "stash-list":
944
+ return await queryStashList(deps, root);
743
945
  case "authors":
744
946
  return await queryAuthors(deps, root);
745
947
  case "last-commit-message":
746
948
  return await queryLastCommitMessage(deps, root);
747
949
  case "worktree-stats":
748
950
  return await queryWorktreeStats(deps, config, request.sessionId);
951
+ case "pull-preview":
952
+ return await queryPullPreview(deps, root);
749
953
  }
750
954
  } catch (error) {
751
955
  const message = error instanceof Error ? error.message : String(error);
@@ -755,7 +959,7 @@ async function runQuery(deps, config, request) {
755
959
  async function queryHistory(deps, root, q) {
756
960
  const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
757
961
  const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
758
- const args = ["git", "log", "--topo-order", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
962
+ const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
759
963
  const search = q.search?.trim() ?? "";
760
964
  const hexJump = search !== "" && isHexLike(search);
761
965
  const countArgs = ["git", "rev-list", "--count"];
@@ -1096,6 +1300,11 @@ async function queryTags(deps, root) {
1096
1300
  const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
1097
1301
  return { ok: true, value: { kind: "tags", tags } };
1098
1302
  }
1303
+ async function queryStashList(deps, root) {
1304
+ const res = await runCommand(deps.run, ["git", "stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs%x1f%cr"], root, "stash-list", deps.signal);
1305
+ const entries = "run" in res && res.run.exitCode === 0 ? parseStashList(res.run.stdout) : [];
1306
+ return { ok: true, value: { kind: "stash-list", entries } };
1307
+ }
1099
1308
  async function queryAuthors(deps, root) {
1100
1309
  const res = await runCommand(deps.run, ["git", "log", ...VISIBLE_REFS, "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1101
1310
  const authors = "run" in res && res.run.exitCode === 0 ? [...new Set(res.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== ""))].sort((a, b) => a.localeCompare(b)) : [];
@@ -1106,6 +1315,23 @@ async function queryLastCommitMessage(deps, root) {
1106
1315
  const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
1107
1316
  return { ok: true, value: { kind: "last-commit-message", message } };
1108
1317
  }
1318
+ async function queryPullPreview(deps, root) {
1319
+ const empty = { kind: "pull-preview", hasUpstream: false, commits: 0, files: 0, insertions: 0, deletions: 0 };
1320
+ const commitsRes = await runCommand(deps.run, ["git", "rev-list", "--count", "--end-of-options", "HEAD..@{upstream}"], root, "pull-preview-count", deps.signal);
1321
+ if (!("run" in commitsRes) || commitsRes.run.exitCode !== 0) return { ok: true, value: empty };
1322
+ const commits = Number(commitsRes.run.stdout.trim()) || 0;
1323
+ const diffRes = await runCommand(deps.run, ["git", "diff", "--numstat", "--end-of-options", "HEAD", "@{upstream}"], root, "pull-preview-diff", deps.signal);
1324
+ let files = 0;
1325
+ let insertions = 0;
1326
+ let deletions = 0;
1327
+ if ("run" in diffRes && diffRes.run.exitCode === 0) {
1328
+ files = diffRes.run.stdout.split("\n").filter((l) => l.trim() !== "").length;
1329
+ const sum = sumNumstat(diffRes.run.stdout);
1330
+ insertions = sum.insertions;
1331
+ deletions = sum.deletions;
1332
+ }
1333
+ return { ok: true, value: { kind: "pull-preview", hasUpstream: true, commits, files, insertions, deletions } };
1334
+ }
1109
1335
  async function queryWorktreeStats(deps, config, sessionId) {
1110
1336
  const snapshot = await snapshotForSession(deps, config, sessionId);
1111
1337
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
@@ -1557,6 +1783,10 @@ __publicField(GitPanelService, "Config", Schema.object({
1557
1783
  Schema.const("unified").description("\u7EDF\u4E00\u89C6\u56FE\uFF08\u5355\u680F\u884C\u5185\u5BF9\u6BD4\uFF09"),
1558
1784
  Schema.const("split").description("\u5E76\u6392\u89C6\u56FE\uFF08\u5DE6\u53F3\u5206\u680F\u5BF9\u6BD4\uFF09")
1559
1785
  ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE"),
1786
+ graphStyle: Schema.union([
1787
+ Schema.const("parallel").description("\u5E73\u884C\u7EBF\uFF08VSCode \u98CE\u683C\uFF0C\u5206\u652F\u5404\u5360\u4E00\u5217\uFF09"),
1788
+ Schema.const("compact").description("\u7D27\u51D1\uFF08git log \u98CE\u683C\uFF0C\u5171\u4EAB\u7956\u5148\u5408\u5E76\u4E3A\u4E00\u5217\uFF09")
1789
+ ]).default("parallel").volatile().description("\u63D0\u4EA4\u5386\u53F2\u56FE\u7EBF\u6761\u6837\u5F0F"),
1560
1790
  suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE")
1561
1791
  }));
1562
1792
  var index_default = GitPanelService;
@@ -1564,6 +1794,8 @@ export {
1564
1794
  DEFAULT_CONFIG,
1565
1795
  GitPanelService,
1566
1796
  checkLatestVersion,
1797
+ classifyActionFailure,
1798
+ classifyHost,
1567
1799
  compareVersions,
1568
1800
  createGitRunner,
1569
1801
  index_default as default,
@@ -1572,8 +1804,12 @@ export {
1572
1804
  parseBranches,
1573
1805
  parseGraphLog,
1574
1806
  parseNameStatus,
1807
+ parsePrimaryRemote,
1808
+ parseRemote,
1575
1809
  parseRepository,
1810
+ parseStashList,
1576
1811
  parseStatus,
1812
+ parseTags,
1577
1813
  planAction,
1578
1814
  readVersionInfo,
1579
1815
  resolveWorkspace,
@@ -2,7 +2,7 @@
2
2
  * git output parsers: porcelain status, log, branch, numstat, name-status.
3
3
  * Pure functions over raw stdout, no I/O.
4
4
  */
5
- import type { GitBranch, GitChange, GitCommit, GitFileStat, GraphCommit, GitRef } from './types.ts';
5
+ import type { GitBranch, GitChange, GitCommit, GitFileStat, GraphCommit, GitRef, StashEntry } from './types.ts';
6
6
  /**
7
7
  * Build a GitCommit from a `%H\x1f%h\x1f%s\x1f%an\x1f%aI`-ordered field array
8
8
  * (the body, when present, is the caller's `parts.slice(5)`). Null when the
@@ -27,6 +27,14 @@ export declare function parseRefs(decoration: string): GitRef[];
27
27
  export declare function parseBranches(stdout: string): GitBranch[];
28
28
  /** Parse `git for-each-ref` tag lines: `name\0shortHash` per line. */
29
29
  export declare function parseTags(stdout: string): GitBranch[];
30
+ /**
31
+ * Parse `git stash list -z --format=%gd%x1f%H%x1f%gs%x1f%cr` into stash entries.
32
+ * Each NUL-separated record is: selector (`stash@{N}`), the stash commit SHA, the
33
+ * reflog subject, and a relative time. The subject is either auto ("WIP on
34
+ * <branch>: <sha> <subj>") or custom ("On <branch>: <message>"); the branch is
35
+ * the text between "on " and the first ": ", and the message is after it.
36
+ */
37
+ export declare function parseStashList(stdout: string): StashEntry[];
30
38
  /**
31
39
  * Parse `git show --name-status -z` into stats with an explicit state machine:
32
40
  * read a status token, then consume exactly the paths it owns (2 for R/C, 1
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse a git remote URL into a browsable https page URL + host classification.
3
+ *
4
+ * Git remotes come in several shapes — scp-like ssh (`git@host:owner/repo.git`),
5
+ * ssh:// , https:// , git:// — all of which must collapse to a single
6
+ * `https://<host>/<path>` page link and a known-host kind for the status-bar
7
+ * icon. Credentials in the URL (`https://user:token@host/…`) are stripped so a
8
+ * token can never leak into an href the browser renders.
9
+ */
10
+ import type { GitRemote, RemoteHostKind } from './types.ts';
11
+ /** Map a hostname to a known-host kind (self-hosted / unknown → 'other'). */
12
+ export declare function classifyHost(host: string): RemoteHostKind;
13
+ /** Build a {@link GitRemote} from a remote name + its configured URL. */
14
+ export declare function parseRemote(name: string, url: string): GitRemote;