@xbzbing/dsh-git-panel 1.3.0 → 1.4.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, 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
@@ -10,11 +10,11 @@ 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';
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, } from './types.ts';
14
14
  export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace } 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 { isSafePath, planAction, runAction, classifyActionFailure } from './actions.ts';
18
18
  export { runQuery } from './queries.ts';
19
19
  export { runSuggest } from './suggest.ts';
20
20
  export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.ts';
@@ -29,10 +29,12 @@ export declare class GitPanelService extends TypertRemoteService {
29
29
  static Config: Schema<Schemastery.ObjectS<NoInfer<{
30
30
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
31
31
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
32
+ graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
32
33
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
33
34
  }>>, Schemastery.ObjectT<NoInfer<{
34
35
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
35
36
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
37
+ graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
36
38
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
37
39
  }>>, "plain">;
38
40
  private readonly deps;
package/lib/host/index.js CHANGED
@@ -250,6 +250,25 @@ function parseTags(stdout) {
250
250
  }
251
251
  return out;
252
252
  }
253
+ function parseStashList(stdout) {
254
+ const out = [];
255
+ for (const record of stdout.split("\0")) {
256
+ if (record.trim() === "") continue;
257
+ const [selector = "", sha = "", subject = "", relTime = ""] = record.split("");
258
+ const idxMatch = /stash@\{(\d+)\}/.exec(selector);
259
+ if (idxMatch === null) continue;
260
+ const index = Number(idxMatch[1]);
261
+ let branch = null;
262
+ let message = subject;
263
+ const m = /^(?:WIP on|On) (.+?): (.*)$/s.exec(subject);
264
+ if (m !== null) {
265
+ branch = m[1] === "(no branch)" ? null : m[1] ?? null;
266
+ message = m[2] ?? "";
267
+ }
268
+ out.push({ index, sha: sha.trim(), message: message.trim(), branch, relTime: relTime.trim() });
269
+ }
270
+ return out;
271
+ }
253
272
  function parseNameStatus(stdout) {
254
273
  const out = [];
255
274
  const tokens = stdout.split("\0");
@@ -318,6 +337,7 @@ var DEFAULT_CONFIG = {
318
337
  refreshIntervalMs: 3e4,
319
338
  showInputPill: true,
320
339
  defaultDiffView: "unified",
340
+ graphStyle: "parallel",
321
341
  suggestEnabled: true,
322
342
  suggestMaxBytes: 64 * 1024,
323
343
  suggestMaxOutputTokens: 200,
@@ -336,6 +356,7 @@ function normalizeConfig(raw) {
336
356
  refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
337
357
  showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
338
358
  defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
359
+ graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
339
360
  suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
340
361
  suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
341
362
  suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
@@ -350,11 +371,16 @@ function readDiffView(value, fallback) {
350
371
  const raw = unwrapVolatile(value);
351
372
  return raw === "unified" || raw === "split" ? raw : fallback;
352
373
  }
374
+ function readGraphStyle(value, fallback) {
375
+ const raw = unwrapVolatile(value);
376
+ return raw === "parallel" || raw === "compact" ? raw : fallback;
377
+ }
353
378
  function readBool(value, fallback) {
354
379
  const raw = unwrapVolatile(value);
355
380
  return typeof raw === "boolean" ? raw : fallback;
356
381
  }
357
382
  var NEG_CACHE_MS = 15e3;
383
+ var STATS_FANOUT_LIMIT = 200;
358
384
  async function resolveBrowseRoot(deps, sessionId) {
359
385
  const ws = await resolveWorkspace(deps, sessionId);
360
386
  if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
@@ -499,16 +525,19 @@ async function snapshotForSession(deps, config, sessionId) {
499
525
  }
500
526
  const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
501
527
  const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
528
+ const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
502
529
  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);
530
+ if (statsDetailed) {
531
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
532
+ const safeUntracked = untrackedPaths.filter(isSafePath);
533
+ if (safeUntracked.length > 0) {
534
+ const perFile = await Promise.all(
535
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
536
+ );
537
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
538
+ }
539
+ }
540
+ const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
512
541
  const distinct = new Set(allChanges.map((c) => c.path));
513
542
  const stats = {
514
543
  fileCount: distinct.size,
@@ -518,7 +547,8 @@ async function snapshotForSession(deps, config, sessionId) {
518
547
  insertions: wt.insertions + stg.insertions + untrackedInsertions,
519
548
  deletions: wt.deletions + stg.deletions,
520
549
  lastChangeAt,
521
- headCommittedAt: lastCommit?.dateIso ?? null
550
+ headCommittedAt: lastCommit?.dateIso ?? null,
551
+ partial: !statsDetailed
522
552
  };
523
553
  const snapshot = {
524
554
  root,
@@ -538,6 +568,7 @@ async function snapshotForSession(deps, config, sessionId) {
538
568
  refreshIntervalMs: config.refreshIntervalMs,
539
569
  showInputPill: config.showInputPill,
540
570
  defaultDiffView: config.defaultDiffView,
571
+ graphStyle: config.graphStyle,
541
572
  suggestEnabled: config.suggestEnabled,
542
573
  checkedAt: Date.now()
543
574
  };
@@ -599,14 +630,86 @@ function planAction(action, unborn) {
599
630
  return { argv: [["git", "checkout", "--end-of-options", action.name]] };
600
631
  case "fetch":
601
632
  return { argv: [["git", "fetch", "--all", "--prune"]] };
633
+ case "tag-create": {
634
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
635
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
636
+ const msg = action.message?.trim() ?? "";
637
+ const flags = msg === "" ? [] : ["-a", "-m", msg];
638
+ return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
639
+ }
640
+ case "tag-delete":
641
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
642
+ return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
643
+ case "stash-push": {
644
+ const msg = action.message?.trim() ?? "";
645
+ return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
646
+ }
647
+ case "stash-apply":
648
+ case "stash-pop":
649
+ case "stash-drop": {
650
+ if (!Number.isInteger(action.index) || action.index < 0) {
651
+ return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
652
+ }
653
+ const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
654
+ return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
655
+ }
656
+ case "revert": {
657
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
658
+ return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
659
+ }
660
+ case "reset": {
661
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
662
+ if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
663
+ return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
664
+ }
665
+ return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
666
+ }
602
667
  }
603
668
  }
669
+ function isIndexBusy(outcome) {
670
+ if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
671
+ return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
672
+ }
673
+ function classifyActionFailure(stdout, stderr, exitCode) {
674
+ const combined = stderr + stdout;
675
+ const err = stderr.trim();
676
+ if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
677
+ return { code: "conflict", message: err || "merge conflict" };
678
+ }
679
+ if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
680
+ return { code: "index-busy", message: err };
681
+ }
682
+ if (/nothing to commit|no changes added/i.test(combined)) {
683
+ return { code: "git-error", message: err || "nothing to commit" };
684
+ }
685
+ 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)) {
686
+ return { code: "not-found", message: err || "not found" };
687
+ }
688
+ if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
689
+ return { code: "local-changes-block", message: err };
690
+ }
691
+ if (/is a merge but no -m|mainline parent/i.test(combined)) {
692
+ return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
693
+ }
694
+ return { code: "git-error", message: err || `git exited ${exitCode}` };
695
+ }
604
696
  async function runAction(deps, config, request) {
605
697
  const workspace = await resolveWorkspace(deps, request.sessionId);
606
698
  if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
607
699
  const root = workspace.root;
608
700
  const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
609
701
  const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
702
+ const act = request.action;
703
+ if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
704
+ if (!Number.isInteger(act.index) || act.index < 0) {
705
+ return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
706
+ }
707
+ const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
708
+ const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
709
+ if (sha === "" || sha !== act.sha) {
710
+ return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
711
+ }
712
+ }
610
713
  if (request.action.kind === "discard") {
611
714
  const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
612
715
  if (removed !== null) {
@@ -624,7 +727,11 @@ async function runAction(deps, config, request) {
624
727
  let lastOutput = "";
625
728
  for (let step = 0; step < plan.argv.length; step += 1) {
626
729
  const argv = plan.argv[step];
627
- const outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
730
+ let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
731
+ if (isIndexBusy(outcome)) {
732
+ await new Promise((resolve) => setTimeout(resolve, 150));
733
+ outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
734
+ }
628
735
  const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
629
736
  if ("failure" in outcome) {
630
737
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
@@ -634,14 +741,20 @@ async function runAction(deps, config, request) {
634
741
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
635
742
  lastOutput = outcome.run.stdout || outcome.run.stderr;
636
743
  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 } };
744
+ const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
745
+ if (request.action.kind === "revert" && failure.code === "conflict") {
746
+ let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
747
+ if (isIndexBusy(abort)) {
748
+ await new Promise((resolve) => setTimeout(resolve, 150));
749
+ abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
750
+ }
751
+ const aborted = "run" in abort && abort.run.exitCode === 0;
752
+ return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
643
753
  }
644
- return { ok: false, error: { code: "git-error", message: (stderr.trim() || `git exited ${outcome.run.exitCode}`) + where } };
754
+ return { ok: false, error: { code: failure.code, message: failure.message + where } };
755
+ }
756
+ if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
757
+ return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
645
758
  }
646
759
  }
647
760
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -740,6 +853,8 @@ async function runQuery(deps, config, request) {
740
853
  return await queryBranches(deps, root);
741
854
  case "tags":
742
855
  return await queryTags(deps, root);
856
+ case "stash-list":
857
+ return await queryStashList(deps, root);
743
858
  case "authors":
744
859
  return await queryAuthors(deps, root);
745
860
  case "last-commit-message":
@@ -1096,6 +1211,11 @@ async function queryTags(deps, root) {
1096
1211
  const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
1097
1212
  return { ok: true, value: { kind: "tags", tags } };
1098
1213
  }
1214
+ async function queryStashList(deps, root) {
1215
+ const res = await runCommand(deps.run, ["git", "stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs%x1f%cr"], root, "stash-list", deps.signal);
1216
+ const entries = "run" in res && res.run.exitCode === 0 ? parseStashList(res.run.stdout) : [];
1217
+ return { ok: true, value: { kind: "stash-list", entries } };
1218
+ }
1099
1219
  async function queryAuthors(deps, root) {
1100
1220
  const res = await runCommand(deps.run, ["git", "log", ...VISIBLE_REFS, "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
1101
1221
  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)) : [];
@@ -1557,6 +1677,10 @@ __publicField(GitPanelService, "Config", Schema.object({
1557
1677
  Schema.const("unified").description("\u7EDF\u4E00\u89C6\u56FE\uFF08\u5355\u680F\u884C\u5185\u5BF9\u6BD4\uFF09"),
1558
1678
  Schema.const("split").description("\u5E76\u6392\u89C6\u56FE\uFF08\u5DE6\u53F3\u5206\u680F\u5BF9\u6BD4\uFF09")
1559
1679
  ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE"),
1680
+ graphStyle: Schema.union([
1681
+ Schema.const("parallel").description("\u5E73\u884C\u7EBF\uFF08VSCode \u98CE\u683C\uFF0C\u5206\u652F\u5404\u5360\u4E00\u5217\uFF09"),
1682
+ Schema.const("compact").description("\u7D27\u51D1\uFF08git log \u98CE\u683C\uFF0C\u5171\u4EAB\u7956\u5148\u5408\u5E76\u4E3A\u4E00\u5217\uFF09")
1683
+ ]).default("parallel").volatile().description("\u63D0\u4EA4\u5386\u53F2\u56FE\u7EBF\u6761\u6837\u5F0F"),
1560
1684
  suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE")
1561
1685
  }));
1562
1686
  var index_default = GitPanelService;
@@ -1564,6 +1688,7 @@ export {
1564
1688
  DEFAULT_CONFIG,
1565
1689
  GitPanelService,
1566
1690
  checkLatestVersion,
1691
+ classifyActionFailure,
1567
1692
  compareVersions,
1568
1693
  createGitRunner,
1569
1694
  index_default as default,
@@ -1573,7 +1698,9 @@ export {
1573
1698
  parseGraphLog,
1574
1699
  parseNameStatus,
1575
1700
  parseRepository,
1701
+ parseStashList,
1576
1702
  parseStatus,
1703
+ parseTags,
1577
1704
  planAction,
1578
1705
  readVersionInfo,
1579
1706
  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
@@ -5,6 +5,10 @@
5
5
  */
6
6
  /** Diff layout: `unified` = single inline column, `split` = side-by-side. */
7
7
  export type DiffViewMode = 'unified' | 'split';
8
+ /** Commit-graph line style: `compact` = converge a shared ancestor into one
9
+ * lane (git log --graph); `parallel` = keep each merge's first parent in its
10
+ * own lane, converging only at the ancestor (VSCode / GUI tools). */
11
+ export type GraphStyle = 'compact' | 'parallel';
8
12
  /** Wire request: the browser sends only session identity, never a path. */
9
13
  export interface GitSnapshotRequest {
10
14
  readonly sessionId: string;
@@ -62,6 +66,8 @@ export interface GitSnapshot {
62
66
  readonly showInputPill: boolean;
63
67
  /** Default diff layout the views open with (user preference). */
64
68
  readonly defaultDiffView: DiffViewMode;
69
+ /** Commit-graph line style (user preference). */
70
+ readonly graphStyle: GraphStyle;
65
71
  /** Whether the commit box shows the AI-suggest button (user preference). */
66
72
  readonly suggestEnabled: boolean;
67
73
  /** Epoch millis of the snapshot. */
@@ -122,8 +128,41 @@ export type GitAction = {
122
128
  readonly name: string;
123
129
  } | {
124
130
  readonly kind: 'fetch';
131
+ } | {
132
+ readonly kind: 'tag-create';
133
+ readonly name: string;
134
+ readonly commit: string;
135
+ readonly message?: string;
136
+ } | {
137
+ readonly kind: 'tag-delete';
138
+ readonly name: string;
139
+ } | {
140
+ readonly kind: 'stash-push';
141
+ readonly message?: string;
142
+ } | {
143
+ readonly kind: 'stash-apply';
144
+ readonly index: number;
145
+ readonly sha: string;
146
+ } | {
147
+ readonly kind: 'stash-pop';
148
+ readonly index: number;
149
+ readonly sha: string;
150
+ } | {
151
+ readonly kind: 'stash-drop';
152
+ readonly index: number;
153
+ readonly sha: string;
154
+ } | {
155
+ readonly kind: 'revert';
156
+ readonly commit: string;
157
+ } | {
158
+ readonly kind: 'reset';
159
+ readonly commit: string;
160
+ readonly mode: ResetMode;
125
161
  };
126
- export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
162
+ /** `git reset` modes, from least to most destructive. Single source so the
163
+ * client dialog and the command builder cannot drift. */
164
+ export type ResetMode = 'soft' | 'mixed' | 'hard';
165
+ export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
127
166
  export type GitActionResult = {
128
167
  readonly ok: true;
129
168
  readonly snapshot: GitSnapshot;
@@ -197,6 +236,8 @@ export type GitQuery = {
197
236
  readonly kind: 'branches';
198
237
  } | {
199
238
  readonly kind: 'tags';
239
+ } | {
240
+ readonly kind: 'stash-list';
200
241
  } | {
201
242
  readonly kind: 'authors';
202
243
  } | {
@@ -224,6 +265,19 @@ export interface GitBranch {
224
265
  readonly ahead?: number;
225
266
  readonly behind?: number;
226
267
  }
268
+ /** One entry in the stash stack (`git stash list`). */
269
+ export interface StashEntry {
270
+ /** Stack position (0 = most recent). */
271
+ readonly index: number;
272
+ /** The stash commit SHA — a stable id the index position is not (see run). */
273
+ readonly sha: string;
274
+ /** The stash message (custom `-m` text, or the auto "WIP on …" subject). */
275
+ readonly message: string;
276
+ /** Branch the stash was taken on; null when it could not be parsed. */
277
+ readonly branch: string | null;
278
+ /** Human relative time (e.g. "2 hours ago"). */
279
+ readonly relTime: string;
280
+ }
227
281
  /** Working-tree statistics for the changes page header. */
228
282
  export interface WorktreeStats {
229
283
  /** Distinct changed-file paths (staged ∪ modified ∪ untracked). */
@@ -239,6 +293,13 @@ export interface WorktreeStats {
239
293
  readonly lastChangeAt: number | null;
240
294
  /** HEAD commit time ISO; null when unborn. */
241
295
  readonly headCommittedAt: string | null;
296
+ /**
297
+ * True when the per-file fan-outs were skipped because the change set was too
298
+ * large (issue #16): `insertions` then omits untracked lines and
299
+ * `lastChangeAt` is null. Counts (fileCount/staged/modified/untracked) and
300
+ * tracked insertions/deletions stay exact.
301
+ */
302
+ readonly partial: boolean;
242
303
  }
243
304
  export type GitQueryResult = {
244
305
  readonly kind: 'history';
@@ -286,6 +347,9 @@ export type GitQueryResult = {
286
347
  } | {
287
348
  readonly kind: 'tags';
288
349
  readonly tags: readonly GitBranch[];
350
+ } | {
351
+ readonly kind: 'stash-list';
352
+ readonly entries: readonly StashEntry[];
289
353
  } | {
290
354
  readonly kind: 'authors';
291
355
  readonly authors: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "DeepSeek Harness (dsh) plugin: an IDE-style Git panel in the Web GUI — branch/history overview, uncommitted changes with commit & amend, diff stats, and a zsh-style branch marker in the input bar.",
5
5
  "type": "module",
6
6
  "license": "MIT",