@xbzbing/dsh-git-panel 1.4.0 → 1.5.1

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,6 +11,9 @@ 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
+ /** Strip an embedded credential (`scheme://user:token@host`) from a git
15
+ * message so a token in a remote URL never reaches the RPC reply or logs. */
16
+ export declare function scrubCredentials(text: string): string;
14
17
  /**
15
18
  * Classify a non-zero git exit into a wire error code + message. Order is
16
19
  * semantic and must not be reshuffled:
@@ -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) {
@@ -328,6 +387,9 @@ function isSafeRev(input) {
328
387
  function isSafeBranchName(name) {
329
388
  return isSafeRev(name) && !name.startsWith("/");
330
389
  }
390
+ function isSafeRemoteName(name) {
391
+ return /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(name) && name.length <= 128;
392
+ }
331
393
 
332
394
  // src/host/core.ts
333
395
  var DEFAULT_CONFIG = {
@@ -380,6 +442,22 @@ function readBool(value, fallback) {
380
442
  return typeof raw === "boolean" ? raw : fallback;
381
443
  }
382
444
  var NEG_CACHE_MS = 15e3;
445
+ function parsePrimaryRemote(stdout) {
446
+ const fetchUrls = /* @__PURE__ */ new Map();
447
+ const order = [];
448
+ for (const line of stdout.split("\n")) {
449
+ const m = line.match(/^(\S+)\t(\S+)\s+\(fetch\)$/);
450
+ if (m === null) continue;
451
+ const name2 = m[1];
452
+ if (!fetchUrls.has(name2)) {
453
+ fetchUrls.set(name2, m[2]);
454
+ order.push(name2);
455
+ }
456
+ }
457
+ if (order.length === 0) return null;
458
+ const name = fetchUrls.has("origin") ? "origin" : order[0];
459
+ return parseRemote(name, fetchUrls.get(name));
460
+ }
383
461
  var STATS_FANOUT_LIMIT = 200;
384
462
  async function resolveBrowseRoot(deps, sessionId) {
385
463
  const ws = await resolveWorkspace(deps, sessionId);
@@ -484,14 +562,17 @@ async function snapshotForSession(deps, config, sessionId) {
484
562
  return failure;
485
563
  }
486
564
  const root = workspace.root;
487
- const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes] = await Promise.all([
565
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
488
566
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
489
567
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
490
568
  runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
491
569
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
492
570
  runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
493
571
  runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
494
- runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal)
572
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
573
+ // `git remote -v` lists every remote's fetch/push URL; parsed into the
574
+ // primary remote (origin, else the first) for the left-column status bar.
575
+ runCommand(deps.run, ["git", "remote", "-v"], root, "remote", deps.signal)
495
576
  ]);
496
577
  const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
497
578
  const unborn = "run" in headRes && headRes.run.exitCode !== 0;
@@ -514,11 +595,13 @@ async function snapshotForSession(deps, config, sessionId) {
514
595
  }
515
596
  let ahead = 0;
516
597
  let behind = 0;
517
- if ("run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0) {
598
+ const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
599
+ if (hasUpstream && "run" in aheadBehindRes) {
518
600
  const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
519
601
  behind = Number(parts[0]) || 0;
520
602
  ahead = Number(parts[1]) || 0;
521
603
  }
604
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
522
605
  let lastCommit = null;
523
606
  if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
524
607
  lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
@@ -561,6 +644,8 @@ async function snapshotForSession(deps, config, sessionId) {
561
644
  untracked,
562
645
  ahead,
563
646
  behind,
647
+ hasUpstream,
648
+ remote,
564
649
  lastCommit,
565
650
  changes,
566
651
  stats,
@@ -630,6 +715,13 @@ function planAction(action, unborn) {
630
715
  return { argv: [["git", "checkout", "--end-of-options", action.name]] };
631
716
  case "fetch":
632
717
  return { argv: [["git", "fetch", "--all", "--prune"]] };
718
+ case "pull-ff":
719
+ return { argv: [["git", "pull", "--ff-only"]] };
720
+ case "push":
721
+ return { argv: [["git", "push"]] };
722
+ case "publish":
723
+ if (!isSafeRemoteName(action.remote)) return { error: "invalid-name", message: `unsafe remote name: ${action.remote}` };
724
+ return { argv: [["git", "push", "-u", action.remote, "HEAD"]] };
633
725
  case "tag-create": {
634
726
  if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
635
727
  if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
@@ -670,9 +762,12 @@ function isIndexBusy(outcome) {
670
762
  if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
671
763
  return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
672
764
  }
765
+ function scrubCredentials(text) {
766
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, "$1");
767
+ }
673
768
  function classifyActionFailure(stdout, stderr, exitCode) {
674
769
  const combined = stderr + stdout;
675
- const err = stderr.trim();
770
+ const err = scrubCredentials(stderr.trim());
676
771
  if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
677
772
  return { code: "conflict", message: err || "merge conflict" };
678
773
  }
@@ -685,6 +780,15 @@ function classifyActionFailure(stdout, stderr, exitCode) {
685
780
  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
781
  return { code: "not-found", message: err || "not found" };
687
782
  }
783
+ if (/Authentication failed|could not read (Username|Password)|terminal prompts disabled|Permission denied \(publickey\)|Could not read from remote repository/i.test(combined)) {
784
+ return { code: "auth-failed", message: err || "authentication failed" };
785
+ }
786
+ if (/\[rejected\]|Updates were rejected|failed to push some refs|fetch first|tip of your current branch is behind/i.test(combined)) {
787
+ return { code: "push-rejected", message: err || "push rejected (remote has new commits; fetch first)" };
788
+ }
789
+ if (/Not possible to fast-forward|cannot fast-forward|not a fast-forward|Need to specify how to reconcile divergent branches/i.test(combined)) {
790
+ return { code: "not-ff", message: err || "cannot fast-forward (branches diverged)" };
791
+ }
688
792
  if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
689
793
  return { code: "local-changes-block", message: err };
690
794
  }
@@ -861,6 +965,10 @@ async function runQuery(deps, config, request) {
861
965
  return await queryLastCommitMessage(deps, root);
862
966
  case "worktree-stats":
863
967
  return await queryWorktreeStats(deps, config, request.sessionId);
968
+ case "pull-preview":
969
+ return await queryPullPreview(deps, root);
970
+ case "push-preview":
971
+ return await queryPushPreview(deps, root);
864
972
  }
865
973
  } catch (error) {
866
974
  const message = error instanceof Error ? error.message : String(error);
@@ -870,7 +978,7 @@ async function runQuery(deps, config, request) {
870
978
  async function queryHistory(deps, root, q) {
871
979
  const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
872
980
  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}`];
981
+ const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
874
982
  const search = q.search?.trim() ?? "";
875
983
  const hexJump = search !== "" && isHexLike(search);
876
984
  const countArgs = ["git", "rev-list", "--count"];
@@ -1226,6 +1334,40 @@ async function queryLastCommitMessage(deps, root) {
1226
1334
  const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
1227
1335
  return { ok: true, value: { kind: "last-commit-message", message } };
1228
1336
  }
1337
+ async function queryPullPreview(deps, root) {
1338
+ const empty = { kind: "pull-preview", hasUpstream: false, commits: 0, files: 0, insertions: 0, deletions: 0 };
1339
+ const commitsRes = await runCommand(deps.run, ["git", "rev-list", "--count", "--end-of-options", "HEAD..@{upstream}"], root, "pull-preview-count", deps.signal);
1340
+ if (!("run" in commitsRes) || commitsRes.run.exitCode !== 0) return { ok: true, value: empty };
1341
+ const commits = Number(commitsRes.run.stdout.trim()) || 0;
1342
+ const diffRes = await runCommand(deps.run, ["git", "diff", "--numstat", "--end-of-options", "HEAD", "@{upstream}"], root, "pull-preview-diff", deps.signal);
1343
+ let files = 0;
1344
+ let insertions = 0;
1345
+ let deletions = 0;
1346
+ if ("run" in diffRes && diffRes.run.exitCode === 0) {
1347
+ files = diffRes.run.stdout.split("\n").filter((l) => l.trim() !== "").length;
1348
+ const sum = sumNumstat(diffRes.run.stdout);
1349
+ insertions = sum.insertions;
1350
+ deletions = sum.deletions;
1351
+ }
1352
+ return { ok: true, value: { kind: "pull-preview", hasUpstream: true, commits, files, insertions, deletions } };
1353
+ }
1354
+ async function queryPushPreview(deps, root) {
1355
+ const empty = { kind: "push-preview", hasUpstream: false, commits: 0, files: 0, insertions: 0, deletions: 0 };
1356
+ const commitsRes = await runCommand(deps.run, ["git", "rev-list", "--count", "--end-of-options", "@{upstream}..HEAD"], root, "push-preview-count", deps.signal);
1357
+ if (!("run" in commitsRes) || commitsRes.run.exitCode !== 0) return { ok: true, value: empty };
1358
+ const commits = Number(commitsRes.run.stdout.trim()) || 0;
1359
+ const diffRes = await runCommand(deps.run, ["git", "diff", "--numstat", "--end-of-options", "@{upstream}", "HEAD"], root, "push-preview-diff", deps.signal);
1360
+ let files = 0;
1361
+ let insertions = 0;
1362
+ let deletions = 0;
1363
+ if ("run" in diffRes && diffRes.run.exitCode === 0) {
1364
+ files = diffRes.run.stdout.split("\n").filter((l) => l.trim() !== "").length;
1365
+ const sum = sumNumstat(diffRes.run.stdout);
1366
+ insertions = sum.insertions;
1367
+ deletions = sum.deletions;
1368
+ }
1369
+ return { ok: true, value: { kind: "push-preview", hasUpstream: true, commits, files, insertions, deletions } };
1370
+ }
1229
1371
  async function queryWorktreeStats(deps, config, sessionId) {
1230
1372
  const snapshot = await snapshotForSession(deps, config, sessionId);
1231
1373
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
@@ -1689,6 +1831,7 @@ export {
1689
1831
  GitPanelService,
1690
1832
  checkLatestVersion,
1691
1833
  classifyActionFailure,
1834
+ classifyHost,
1692
1835
  compareVersions,
1693
1836
  createGitRunner,
1694
1837
  index_default as default,
@@ -1697,6 +1840,8 @@ export {
1697
1840
  parseBranches,
1698
1841
  parseGraphLog,
1699
1842
  parseNameStatus,
1843
+ parsePrimaryRemote,
1844
+ parseRemote,
1700
1845
  parseRepository,
1701
1846
  parseStashList,
1702
1847
  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,13 @@ export type GitAction = {
128
147
  readonly name: string;
129
148
  } | {
130
149
  readonly kind: 'fetch';
150
+ } | {
151
+ readonly kind: 'pull-ff';
152
+ } | {
153
+ readonly kind: 'push';
154
+ } | {
155
+ readonly kind: 'publish';
156
+ readonly remote: string;
131
157
  } | {
132
158
  readonly kind: 'tag-create';
133
159
  readonly name: string;
@@ -162,7 +188,7 @@ export type GitAction = {
162
188
  /** `git reset` modes, from least to most destructive. Single source so the
163
189
  * client dialog and the command builder cannot drift. */
164
190
  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';
191
+ 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' | 'push-rejected' | 'auth-failed' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
166
192
  export type GitActionResult = {
167
193
  readonly ok: true;
168
194
  readonly snapshot: GitSnapshot;
@@ -244,6 +270,10 @@ export type GitQuery = {
244
270
  readonly kind: 'last-commit-message';
245
271
  } | {
246
272
  readonly kind: 'worktree-stats';
273
+ } | {
274
+ readonly kind: 'pull-preview';
275
+ } | {
276
+ readonly kind: 'push-preview';
247
277
  };
248
278
  /** One entry in a `dir-list` result. */
249
279
  export interface DirEntry {
@@ -359,6 +389,20 @@ export type GitQueryResult = {
359
389
  } | {
360
390
  readonly kind: 'worktree-stats';
361
391
  readonly stats: WorktreeStats;
392
+ } | {
393
+ readonly kind: 'pull-preview';
394
+ readonly hasUpstream: boolean;
395
+ readonly commits: number;
396
+ readonly files: number;
397
+ readonly insertions: number;
398
+ readonly deletions: number;
399
+ } | {
400
+ readonly kind: 'push-preview';
401
+ readonly hasUpstream: boolean;
402
+ readonly commits: number;
403
+ readonly files: number;
404
+ readonly insertions: number;
405
+ readonly deletions: number;
362
406
  } | {
363
407
  readonly kind: 'dir-list';
364
408
  readonly path: string;
@@ -8,3 +8,7 @@ export declare function isSafePath(path: string): boolean;
8
8
  export declare function isSafeRev(input: string): boolean;
9
9
  /** A branch name for `git checkout`: the ref rules plus no leading slash. */
10
10
  export declare function isSafeBranchName(name: string): boolean;
11
+ /** A git remote name for `git push -u <remote> HEAD`. Allows the characters git
12
+ * permits in a remote name (alphanumerics, `._/-`) but never a leading dash, so
13
+ * a configured remote can never slip into the option position. */
14
+ export declare function isSafeRemoteName(name: string): boolean;
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.1",
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",