@xbzbing/dsh-git-panel 1.4.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.
@@ -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, GraphStyle, 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. */
@@ -85,6 +85,12 @@ export interface SnapshotDeps {
85
85
  readonly getLlm?: () => LlmFace | undefined;
86
86
  readonly getAgentDefaultModel?: () => AgentDefaultModelFace | undefined;
87
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;
88
94
  export type WorkspaceResolution = {
89
95
  readonly ok: true;
90
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,10 +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, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, 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
16
  export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.ts';
17
+ export { parseRemote, classifyHost } from './remote.ts';
17
18
  export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.ts';
18
19
  export { runQuery } from './queries.ts';
19
20
  export { runSuggest } from './suggest.ts';
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 {
@@ -310,6 +323,52 @@ function sumNumstat(stdout) {
310
323
  return { insertions, deletions };
311
324
  }
312
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
+
313
372
  // src/host/validate.ts
314
373
  import { isAbsolute, normalize } from "node:path";
315
374
  function isSafePath(path) {
@@ -380,6 +439,22 @@ function readBool(value, fallback) {
380
439
  return typeof raw === "boolean" ? raw : fallback;
381
440
  }
382
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
+ }
383
458
  var STATS_FANOUT_LIMIT = 200;
384
459
  async function resolveBrowseRoot(deps, sessionId) {
385
460
  const ws = await resolveWorkspace(deps, sessionId);
@@ -484,14 +559,17 @@ async function snapshotForSession(deps, config, sessionId) {
484
559
  return failure;
485
560
  }
486
561
  const root = workspace.root;
487
- const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes] = await Promise.all([
562
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
488
563
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
489
564
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
490
565
  runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
491
566
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
492
567
  runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
493
568
  runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
494
- 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)
495
573
  ]);
496
574
  const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
497
575
  const unborn = "run" in headRes && headRes.run.exitCode !== 0;
@@ -514,11 +592,13 @@ async function snapshotForSession(deps, config, sessionId) {
514
592
  }
515
593
  let ahead = 0;
516
594
  let behind = 0;
517
- if ("run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0) {
595
+ const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
596
+ if (hasUpstream && "run" in aheadBehindRes) {
518
597
  const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
519
598
  behind = Number(parts[0]) || 0;
520
599
  ahead = Number(parts[1]) || 0;
521
600
  }
601
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
522
602
  let lastCommit = null;
523
603
  if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
524
604
  lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
@@ -561,6 +641,8 @@ async function snapshotForSession(deps, config, sessionId) {
561
641
  untracked,
562
642
  ahead,
563
643
  behind,
644
+ hasUpstream,
645
+ remote,
564
646
  lastCommit,
565
647
  changes,
566
648
  stats,
@@ -630,6 +712,8 @@ function planAction(action, unborn) {
630
712
  return { argv: [["git", "checkout", "--end-of-options", action.name]] };
631
713
  case "fetch":
632
714
  return { argv: [["git", "fetch", "--all", "--prune"]] };
715
+ case "pull-ff":
716
+ return { argv: [["git", "pull", "--ff-only"]] };
633
717
  case "tag-create": {
634
718
  if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
635
719
  if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
@@ -685,6 +769,9 @@ function classifyActionFailure(stdout, stderr, exitCode) {
685
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)) {
686
770
  return { code: "not-found", message: err || "not found" };
687
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
+ }
688
775
  if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
689
776
  return { code: "local-changes-block", message: err };
690
777
  }
@@ -861,6 +948,8 @@ async function runQuery(deps, config, request) {
861
948
  return await queryLastCommitMessage(deps, root);
862
949
  case "worktree-stats":
863
950
  return await queryWorktreeStats(deps, config, request.sessionId);
951
+ case "pull-preview":
952
+ return await queryPullPreview(deps, root);
864
953
  }
865
954
  } catch (error) {
866
955
  const message = error instanceof Error ? error.message : String(error);
@@ -870,7 +959,7 @@ async function runQuery(deps, config, request) {
870
959
  async function queryHistory(deps, root, q) {
871
960
  const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
872
961
  const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
873
- 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}`];
874
963
  const search = q.search?.trim() ?? "";
875
964
  const hexJump = search !== "" && isHexLike(search);
876
965
  const countArgs = ["git", "rev-list", "--count"];
@@ -1226,6 +1315,23 @@ async function queryLastCommitMessage(deps, root) {
1226
1315
  const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
1227
1316
  return { ok: true, value: { kind: "last-commit-message", message } };
1228
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
+ }
1229
1335
  async function queryWorktreeStats(deps, config, sessionId) {
1230
1336
  const snapshot = await snapshotForSession(deps, config, sessionId);
1231
1337
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
@@ -1689,6 +1795,7 @@ export {
1689
1795
  GitPanelService,
1690
1796
  checkLatestVersion,
1691
1797
  classifyActionFailure,
1798
+ classifyHost,
1692
1799
  compareVersions,
1693
1800
  createGitRunner,
1694
1801
  index_default as default,
@@ -1697,6 +1804,8 @@ export {
1697
1804
  parseBranches,
1698
1805
  parseGraphLog,
1699
1806
  parseNameStatus,
1807
+ parsePrimaryRemote,
1808
+ parseRemote,
1700
1809
  parseRepository,
1701
1810
  parseStashList,
1702
1811
  parseStatus,
@@ -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;
@@ -37,6 +37,21 @@ export type GitFailure = {
37
37
  } | {
38
38
  readonly code: 'cancelled';
39
39
  };
40
+ /** Host classification for a remote URL (drives the status-bar icon). */
41
+ export type RemoteHostKind = 'github' | 'gitlab' | 'gitee' | 'bitbucket' | 'other';
42
+ /** The repository's primary remote (origin, else the first remote). */
43
+ export interface GitRemote {
44
+ /** Remote name (`origin`, or the first configured remote). */
45
+ readonly name: string;
46
+ /** The configured fetch URL, verbatim (ssh/scp/https/git form). */
47
+ readonly url: string;
48
+ /** A browsable https URL for the repo page; null when unparseable. */
49
+ readonly webUrl: string | null;
50
+ /** The remote hostname (e.g. `github.com`); null when unparseable. */
51
+ readonly host: string | null;
52
+ /** Known-host classification, for the status-bar icon. */
53
+ readonly hostKind: RemoteHostKind;
54
+ }
40
55
  /** Immutable snapshot of one repository's status at `checkedAt`. */
41
56
  export interface GitSnapshot {
42
57
  /** Realpath of the repository root (work tree top). */
@@ -54,6 +69,10 @@ export interface GitSnapshot {
54
69
  readonly untracked: number;
55
70
  readonly ahead: number;
56
71
  readonly behind: number;
72
+ /** True when the current branch tracks an upstream (@{upstream} resolves). */
73
+ readonly hasUpstream: boolean;
74
+ /** The repository's primary remote; null when none is configured. */
75
+ readonly remote: GitRemote | null;
57
76
  readonly lastCommit: GitCommit | null;
58
77
  readonly changes: readonly GitChange[];
59
78
  /** Working-tree statistics for the changes-page header (single source). */
@@ -128,6 +147,8 @@ export type GitAction = {
128
147
  readonly name: string;
129
148
  } | {
130
149
  readonly kind: 'fetch';
150
+ } | {
151
+ readonly kind: 'pull-ff';
131
152
  } | {
132
153
  readonly kind: 'tag-create';
133
154
  readonly name: string;
@@ -162,7 +183,7 @@ export type GitAction = {
162
183
  /** `git reset` modes, from least to most destructive. Single source so the
163
184
  * client dialog and the command builder cannot drift. */
164
185
  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';
186
+ 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' | 'not-ff' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
166
187
  export type GitActionResult = {
167
188
  readonly ok: true;
168
189
  readonly snapshot: GitSnapshot;
@@ -244,6 +265,8 @@ export type GitQuery = {
244
265
  readonly kind: 'last-commit-message';
245
266
  } | {
246
267
  readonly kind: 'worktree-stats';
268
+ } | {
269
+ readonly kind: 'pull-preview';
247
270
  };
248
271
  /** One entry in a `dir-list` result. */
249
272
  export interface DirEntry {
@@ -359,6 +382,13 @@ export type GitQueryResult = {
359
382
  } | {
360
383
  readonly kind: 'worktree-stats';
361
384
  readonly stats: WorktreeStats;
385
+ } | {
386
+ readonly kind: 'pull-preview';
387
+ readonly hasUpstream: boolean;
388
+ readonly commits: number;
389
+ readonly files: number;
390
+ readonly insertions: number;
391
+ readonly deletions: number;
362
392
  } | {
363
393
  readonly kind: 'dir-list';
364
394
  readonly path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",