@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.
- package/lib/client.js +47 -13
- package/lib/host/actions.d.ts +3 -0
- package/lib/host/core.d.ts +7 -1
- package/lib/host/git.d.ts +2 -0
- package/lib/host/index.d.ts +3 -2
- package/lib/host/index.js +151 -6
- package/lib/host/remote.d.ts +14 -0
- package/lib/host/types.d.ts +45 -1
- package/lib/host/validate.d.ts +4 -0
- package/package.json +1 -1
package/lib/host/actions.d.ts
CHANGED
|
@@ -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:
|
package/lib/host/core.d.ts
CHANGED
|
@@ -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;
|
package/lib/host/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/host/types.d.ts
CHANGED
|
@@ -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;
|
package/lib/host/validate.d.ts
CHANGED
|
@@ -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.
|
|
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",
|