@xbzbing/dsh-git-panel 1.5.3 → 1.5.4

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.
@@ -58,14 +58,14 @@ Two halves communicating over the typert Remote gateway.
58
58
  ## Development
59
59
 
60
60
  ```bash
61
- node build.mjs # tsc (host d.ts) + esbuild (host bundle + client bundle + testkit)
61
+ node build.mjs # tsc (host d.ts) + esbuild (per-module host js + client bundle + testkit)
62
62
  npm run typecheck # type check
63
63
  npm run test:unit # pure-algorithm and host-endpoint unit tests (node --test)
64
64
  npm run test:e2e # isolated file:// headless-browser e2e (never touches a running instance)
65
65
  npm test # unit + e2e
66
66
  ```
67
67
 
68
- The host bundle is not minified: the typert gateway validates arguments by method parameter name, and minification would rename them and break the wire contract. Client changes are not hot-reloaded — rebuild and refresh the page. `lib/` is committed as a build artifact so `dsh plugin add github:…` installs the built tree directly; after editing `src/` you must rebuild and commit, or a GitHub install loads a stale entry. The test-time `lib/testkit.mjs` is not committed. For local development, add the plugin to a dsh web profile's `dsh.profile.bundles` with a `file:` dependency pointing at this directory; a rebuild makes it effective.
68
+ The host output is emitted one file per `src/host/` module (stack traces land in the module that raised) and is never minified: the typert gateway validates arguments by method parameter name, and minification would rename them and break the wire contract. Client changes are not hot-reloaded — rebuild and refresh the page. `lib/` is committed as a build artifact so `dsh plugin add github:…` installs the built tree directly; after editing `src/` you must rebuild and commit, or a GitHub install loads a stale entry. The test-time `lib/testkit.mjs` is not committed. For local development, add the plugin to a dsh web profile's `dsh.profile.bundles` with a `file:` dependency pointing at this directory; a rebuild makes it effective.
69
69
 
70
70
  ## Project structure
71
71
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-git-panel
2
2
 
3
- [English](README_EN.md) | 简体中文
3
+ [English](README.en.md) | 简体中文
4
4
 
5
5
  ![dsh-git-panel](docs/assets/banner-zh.png)
6
6
 
@@ -58,14 +58,14 @@ dsh plugin --profile web add github:xbzbing/dsh-git-panel
58
58
  ## 开发
59
59
 
60
60
  ```bash
61
- node build.mjs # tsc(host d.ts)+ esbuild(host bundle + client bundle + testkit)
61
+ node build.mjs # tsc(host d.ts)+ esbuild(host 逐模块 js + client bundle + testkit)
62
62
  npm run typecheck # 类型检查
63
63
  npm run test:unit # 纯算法与 host 端点单测(node --test)
64
64
  npm run test:e2e # 隔离的 file:// 无头浏览器 e2e(不碰任何运行中的实例)
65
65
  npm test # 单测 + e2e
66
66
  ```
67
67
 
68
- host bundle 不做压缩:typert 网关靠方法参数名做参数校验,压缩会改名并破坏 wire 契约。client 改动没有热重载,需重新构建并刷新页面。`lib/` 作为构建产物随 git 提交入库,改动 `src/` 后必须重新构建并提交,否则从 GitHub 安装会加载到过期入口;测试期生成的 `lib/testkit.mjs` 不入库。本地联调:把插件加入 dsh web profile 的 `dsh.profile.bundles`,用 `file:` 依赖指向本目录,构建后即生效。
68
+ host 产物按 `src/host/` 逐模块输出(一个源文件一个 js,堆栈直接落在出问题的模块),且不做压缩:typert 网关靠方法参数名做参数校验,压缩会改名并破坏 wire 契约。client 改动没有热重载,需重新构建并刷新页面。`lib/` 作为构建产物随 git 提交入库,改动 `src/` 后必须重新构建并提交,否则从 GitHub 安装会加载到过期入口;测试期生成的 `lib/testkit.mjs` 不入库。本地联调:把插件加入 dsh web profile 的 `dsh.profile.bundles`,用 `file:` 依赖指向本目录,构建后即生效。
69
69
 
70
70
  ## 项目结构
71
71
 
@@ -1,6 +1,6 @@
1
- import type { SnapshotDeps, GitPanelConfig } from './core.ts';
2
- import { isSafePath } from './validate.ts';
3
- import type { GitAction, GitActionRequest, GitActionResult, GitErrorCode } from './types.ts';
1
+ import type { SnapshotDeps, GitPanelConfig } from './core.js';
2
+ import { isSafePath } from './validate.js';
3
+ import type { GitAction, GitActionRequest, GitActionResult, GitErrorCode } from './types.js';
4
4
  export { isSafePath };
5
5
  interface CommandPlan {
6
6
  readonly argv: readonly (readonly string[])[];
@@ -0,0 +1,230 @@
1
+ import { join, sep } from "node:path";
2
+ import { mapWorkspaceFailure, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
3
+ import { isSafeBranchName, isSafePath, isSafeRemoteName, isSafeRev } from "./validate.js";
4
+ function withPaths(prefixes, paths) {
5
+ if (paths.length === 0) return { error: "invalid-path", message: "no paths given" };
6
+ for (const path of paths) {
7
+ if (!isSafePath(path)) return { error: "invalid-path", message: `unsafe path: ${path}` };
8
+ }
9
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) };
10
+ }
11
+ function planAction(action, unborn) {
12
+ switch (action.kind) {
13
+ case "stage":
14
+ return withPaths([["git", "add", "--"]], action.paths);
15
+ case "stage-all":
16
+ return { argv: [["git", "add", "-A"]] };
17
+ case "unstage":
18
+ return unborn ? withPaths([["git", "rm", "--cached", "-r", "--"]], action.paths) : withPaths([["git", "restore", "--staged", "--"]], action.paths);
19
+ case "unstage-all":
20
+ return unborn ? { argv: [["git", "rm", "--cached", "-r", "--", "."]] } : { argv: [["git", "restore", "--staged", "--", "."]] };
21
+ case "discard":
22
+ return withPaths([["git", "restore", "--"]], action.paths);
23
+ case "commit": {
24
+ const message = action.message.trim();
25
+ const amend = action.amend === true;
26
+ if (message === "" && !amend) return { error: "empty-message" };
27
+ const amendFlag = amend ? ["--amend"] : [];
28
+ const msgArgs = message === "" ? ["--no-edit"] : ["-m", message];
29
+ if (action.paths === void 0 || action.paths.length === 0) {
30
+ return { argv: [["git", "commit", ...amendFlag, ...msgArgs]] };
31
+ }
32
+ const staged = withPaths([["git", "add", "--"]], action.paths);
33
+ if ("error" in staged) return staged;
34
+ const commitCmd = ["git", "commit", ...amendFlag, ...msgArgs, "--", ...action.paths];
35
+ return { argv: [...staged.argv, commitCmd] };
36
+ }
37
+ case "branch-checkout":
38
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
39
+ return { argv: [["git", "checkout", "--end-of-options", action.name]] };
40
+ case "fetch":
41
+ return { argv: [["git", "fetch", "--all", "--prune"]] };
42
+ case "pull-ff":
43
+ return { argv: [["git", "pull", "--ff-only"]] };
44
+ case "push":
45
+ return { argv: [["git", "push"]] };
46
+ case "publish":
47
+ if (!isSafeRemoteName(action.remote)) return { error: "invalid-name", message: `unsafe remote name: ${action.remote}` };
48
+ return { argv: [["git", "push", "-u", action.remote, "HEAD"]] };
49
+ case "tag-create": {
50
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
51
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
52
+ const msg = action.message?.trim() ?? "";
53
+ const flags = msg === "" ? [] : ["-a", "-m", msg];
54
+ return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
55
+ }
56
+ case "tag-delete":
57
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
58
+ return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
59
+ case "stash-push": {
60
+ const msg = action.message?.trim() ?? "";
61
+ return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
62
+ }
63
+ case "stash-apply":
64
+ case "stash-pop":
65
+ case "stash-drop": {
66
+ if (!Number.isInteger(action.index) || action.index < 0) {
67
+ return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
68
+ }
69
+ const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
70
+ return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
71
+ }
72
+ case "revert": {
73
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
74
+ return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
75
+ }
76
+ case "reset": {
77
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
78
+ if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
79
+ return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
80
+ }
81
+ return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
82
+ }
83
+ }
84
+ }
85
+ function isIndexBusy(outcome) {
86
+ if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
87
+ return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
88
+ }
89
+ function scrubCredentials(text) {
90
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, "$1");
91
+ }
92
+ function classifyActionFailure(stdout, stderr, exitCode) {
93
+ const combined = stderr + stdout;
94
+ const err = scrubCredentials(stderr.trim());
95
+ if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
96
+ return { code: "conflict", message: err || "merge conflict" };
97
+ }
98
+ if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
99
+ return { code: "index-busy", message: err };
100
+ }
101
+ if (/nothing to commit|no changes added/i.test(combined)) {
102
+ return { code: "git-error", message: err || "nothing to commit" };
103
+ }
104
+ 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)) {
105
+ return { code: "not-found", message: err || "not found" };
106
+ }
107
+ if (/Authentication failed|could not read (Username|Password)|terminal prompts disabled|Permission denied \(publickey\)|Could not read from remote repository/i.test(combined)) {
108
+ return { code: "auth-failed", message: err || "authentication failed" };
109
+ }
110
+ if (/\[rejected\]|Updates were rejected|failed to push some refs|fetch first|tip of your current branch is behind/i.test(combined)) {
111
+ return { code: "push-rejected", message: err || "push rejected (remote has new commits; fetch first)" };
112
+ }
113
+ if (/Not possible to fast-forward|cannot fast-forward|not a fast-forward|Need to specify how to reconcile divergent branches/i.test(combined)) {
114
+ return { code: "not-ff", message: err || "cannot fast-forward (branches diverged)" };
115
+ }
116
+ if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
117
+ return { code: "local-changes-block", message: err };
118
+ }
119
+ if (/is a merge but no -m|mainline parent/i.test(combined)) {
120
+ return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
121
+ }
122
+ return { code: "git-error", message: err || `git exited ${exitCode}` };
123
+ }
124
+ async function runAction(deps, config, request) {
125
+ const workspace = await resolveWorkspace(deps, request.sessionId);
126
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
127
+ const root = workspace.root;
128
+ const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
129
+ const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
130
+ const act = request.action;
131
+ if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
132
+ if (!Number.isInteger(act.index) || act.index < 0) {
133
+ return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
134
+ }
135
+ const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
136
+ const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
137
+ if (sha === "" || sha !== act.sha) {
138
+ return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
139
+ }
140
+ }
141
+ if (request.action.kind === "discard") {
142
+ const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
143
+ if (removed !== null) {
144
+ if (!removed.ok) return removed.result;
145
+ if (removed.remainingTracked.length === 0) {
146
+ const snapshot2 = await snapshotForSession(deps, config, request.sessionId);
147
+ if (!snapshot2.ok) return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
148
+ return { ok: true, snapshot: snapshot2.value, output: "" };
149
+ }
150
+ request = { ...request, action: { kind: "discard", paths: removed.remainingTracked } };
151
+ }
152
+ }
153
+ const plan = planAction(request.action, unborn);
154
+ if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
155
+ let lastOutput = "";
156
+ for (let step = 0; step < plan.argv.length; step += 1) {
157
+ const argv = plan.argv[step];
158
+ let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
159
+ if (isIndexBusy(outcome)) {
160
+ await new Promise((resolve) => setTimeout(resolve, 150));
161
+ outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
162
+ }
163
+ const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
164
+ if ("failure" in outcome) {
165
+ const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
166
+ return { ok: false, error: { code: "git-unavailable", message: message + where } };
167
+ }
168
+ if (outcome.run.cancelled) return { ok: false, error: { code: "cancelled" } };
169
+ if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
170
+ lastOutput = outcome.run.stdout || outcome.run.stderr;
171
+ if (outcome.run.exitCode !== 0) {
172
+ const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
173
+ if (request.action.kind === "revert" && failure.code === "conflict") {
174
+ let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
175
+ if (isIndexBusy(abort)) {
176
+ await new Promise((resolve) => setTimeout(resolve, 150));
177
+ abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
178
+ }
179
+ const aborted = "run" in abort && abort.run.exitCode === 0;
180
+ return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
181
+ }
182
+ return { ok: false, error: { code: failure.code, message: failure.message + where } };
183
+ }
184
+ if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
185
+ return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
186
+ }
187
+ }
188
+ const snapshot = await snapshotForSession(deps, config, request.sessionId);
189
+ if (!snapshot.ok) {
190
+ return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
191
+ }
192
+ return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
193
+ }
194
+ async function discardUntracked(deps, config, root, sessionId, paths) {
195
+ const snap = await snapshotForSession(deps, config, sessionId);
196
+ if (!snap.ok) return null;
197
+ const untrackedSet = new Set(snap.value.changes.filter((c) => c.status === "untracked").map((c) => c.path));
198
+ const untracked = paths.filter((p) => untrackedSet.has(p));
199
+ if (untracked.length === 0) return null;
200
+ const tracked = paths.filter((p) => !untrackedSet.has(p));
201
+ let rootReal;
202
+ try {
203
+ rootReal = await deps.fs.realpath(root);
204
+ } catch {
205
+ return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
206
+ }
207
+ for (const path of untracked) {
208
+ if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
209
+ const target = join(root, path);
210
+ let targetReal;
211
+ try {
212
+ targetReal = await deps.fs.realpath(target);
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
217
+ return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
218
+ }
219
+ await deps.fs.remove(target).catch(() => {
220
+ });
221
+ }
222
+ return { ok: true, remainingTracked: tracked };
223
+ }
224
+ export {
225
+ classifyActionFailure,
226
+ isSafePath,
227
+ planAction,
228
+ runAction,
229
+ scrubCredentials
230
+ };
@@ -1,6 +1,6 @@
1
- import type { GitRunner } from './git.ts';
2
- import type { AgentDefaultModelFace, LlmFace } from './llm-face.ts';
3
- import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.ts';
1
+ import type { GitRunner } from './git.js';
2
+ import type { AgentDefaultModelFace, LlmFace } from './llm-face.js';
3
+ import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.js';
4
4
  export interface GitPanelConfig {
5
5
  readonly timeoutMs: number;
6
6
  /** Per-command stdout cap; also the per-side image-diff payload cap. */
@@ -0,0 +1,300 @@
1
+ import { dirname, join } from "node:path";
2
+ import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
3
+ import { parseRemote } from "./remote.js";
4
+ import { isSafePath } from "./validate.js";
5
+ const DEFAULT_CONFIG = {
6
+ timeoutMs: 8e3,
7
+ maxBytes: 4 * 1024 * 1024,
8
+ maxChanges: 1e3,
9
+ refreshIntervalMs: 3e4,
10
+ showInputPill: true,
11
+ defaultDiffView: "unified",
12
+ graphStyle: "parallel",
13
+ suggestEnabled: true,
14
+ suggestMaxBytes: 64 * 1024,
15
+ suggestMaxOutputTokens: 200,
16
+ suggestTimeoutMs: 15e3
17
+ };
18
+ function normalizeConfig(raw) {
19
+ const c = raw ?? {};
20
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
21
+ const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
22
+ const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
23
+ const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
24
+ return {
25
+ timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
26
+ maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
27
+ maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
28
+ refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
29
+ showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
30
+ defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
31
+ graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
32
+ suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
33
+ suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
34
+ suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
35
+ suggestTimeoutMs: num(c.suggestTimeoutMs, DEFAULT_CONFIG.suggestTimeoutMs),
36
+ ...route
37
+ };
38
+ }
39
+ function unwrapVolatile(value) {
40
+ return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
41
+ }
42
+ function readDiffView(value, fallback) {
43
+ const raw = unwrapVolatile(value);
44
+ return raw === "unified" || raw === "split" ? raw : fallback;
45
+ }
46
+ function readGraphStyle(value, fallback) {
47
+ const raw = unwrapVolatile(value);
48
+ return raw === "parallel" || raw === "compact" ? raw : fallback;
49
+ }
50
+ function readBool(value, fallback) {
51
+ const raw = unwrapVolatile(value);
52
+ return typeof raw === "boolean" ? raw : fallback;
53
+ }
54
+ const NEG_CACHE_MS = 15e3;
55
+ function parsePrimaryRemote(stdout) {
56
+ const fetchUrls = /* @__PURE__ */ new Map();
57
+ const order = [];
58
+ for (const line of stdout.split("\n")) {
59
+ const m = line.match(/^(\S+)\t(\S+)\s+\(fetch\)$/);
60
+ if (m === null) continue;
61
+ const name2 = m[1];
62
+ if (!fetchUrls.has(name2)) {
63
+ fetchUrls.set(name2, m[2]);
64
+ order.push(name2);
65
+ }
66
+ }
67
+ if (order.length === 0) return null;
68
+ const name = fetchUrls.has("origin") ? "origin" : order[0];
69
+ return parseRemote(name, fetchUrls.get(name));
70
+ }
71
+ const STATS_FANOUT_LIMIT = 200;
72
+ async function resolveBrowseRoot(deps, sessionId) {
73
+ const ws = await resolveWorkspace(deps, sessionId);
74
+ if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
75
+ const err = ws.failure.error;
76
+ if ((err.code === "not-a-git-repo" || err.code === "git-unavailable") && "cwd" in err && err.cwd !== void 0 && err.cwd !== "") {
77
+ try {
78
+ return { ok: true, root: await deps.fs.realpath(err.cwd), isGitRepo: false };
79
+ } catch {
80
+ return { ok: false, error: { code: "git-error" } };
81
+ }
82
+ }
83
+ return { ok: false, error: mapWorkspaceFailure(ws.failure) };
84
+ }
85
+ async function resolveWorkspace(deps, sessionId) {
86
+ let cwd = deps.sessions.liveCwd(sessionId);
87
+ if (cwd === void 0 || cwd === "") {
88
+ const meta = await deps.sessions.persistedMeta(sessionId);
89
+ cwd = meta?.cwd;
90
+ }
91
+ if (cwd === void 0 || cwd === "") {
92
+ return { ok: false, failure: { ok: false, error: { code: "cwd-unavailable", sessionId } } };
93
+ }
94
+ const cached = deps.rootCache?.get(cwd);
95
+ if (cached !== void 0) return { ok: true, root: cached };
96
+ const negAt = deps.rootNegCache?.get(cwd);
97
+ if (negAt !== void 0) {
98
+ if (Date.now() < negAt) return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
99
+ deps.rootNegCache?.delete(cwd);
100
+ }
101
+ const top = await runCommand(deps.run, ["git", "rev-parse", "--show-toplevel"], cwd, "toplevel", deps.signal);
102
+ if ("failure" in top) {
103
+ return { ok: false, failure: { ok: false, error: { ...mapRunFailure(top.failure), cwd } } };
104
+ }
105
+ if (top.run.cancelled) return { ok: false, failure: { ok: false, error: { code: "cancelled" } } };
106
+ if (top.run.timedOut) return { ok: false, failure: { ok: false, error: { code: "timeout" } } };
107
+ if (top.run.exitCode !== 0) {
108
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
109
+ return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
110
+ }
111
+ const raw = top.run.stdout.trim();
112
+ if (raw === "") {
113
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
114
+ return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
115
+ }
116
+ let root = raw;
117
+ try {
118
+ root = await deps.fs.realpath(raw);
119
+ } catch {
120
+ }
121
+ deps.rootCache?.set(cwd, root);
122
+ return { ok: true, root };
123
+ }
124
+ async function runCommand(runner, argv, cwd, _label, signal, stdinData) {
125
+ try {
126
+ const run = await runner.run(argv, { cwd, ...signal ? { signal } : {}, ...stdinData !== void 0 ? { stdinData } : {} });
127
+ return { run };
128
+ } catch (error) {
129
+ return { failure: error };
130
+ }
131
+ }
132
+ function mapRunFailure(failure) {
133
+ const message = failure instanceof Error ? failure.message : String(failure);
134
+ return { code: "git-unavailable", detail: message };
135
+ }
136
+ async function detectGitRepo(deps, cwd) {
137
+ let dir;
138
+ try {
139
+ dir = await deps.fs.realpath(cwd);
140
+ } catch {
141
+ dir = cwd;
142
+ }
143
+ for (let i = 0; i < 100; i++) {
144
+ try {
145
+ await deps.fs.stat(join(dir, ".git"));
146
+ return true;
147
+ } catch {
148
+ }
149
+ const parent = dirname(dir);
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+ return false;
154
+ }
155
+ function mapWorkspaceFailure(failure) {
156
+ const error = failure.error;
157
+ const message = "detail" in error ? error.detail : void 0;
158
+ return { code: error.code, ...message !== void 0 ? { message } : {} };
159
+ }
160
+ async function snapshotForSession(deps, config, sessionId) {
161
+ const workspace = await resolveWorkspace(deps, sessionId);
162
+ if (!workspace.ok) {
163
+ const failure = workspace.failure;
164
+ if (failure.error.code === "not-a-git-repo") {
165
+ return { ok: false, error: { ...failure.error, showInputPill: config.showInputPill } };
166
+ }
167
+ if (failure.error.code === "git-unavailable") {
168
+ const cwd = failure.error.cwd;
169
+ const isGitRepo = cwd !== void 0 && cwd !== "" ? await detectGitRepo(deps, cwd) : false;
170
+ return { ok: false, error: { ...failure.error, isGitRepo } };
171
+ }
172
+ return failure;
173
+ }
174
+ const root = workspace.root;
175
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
176
+ runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
177
+ runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
178
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
179
+ runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
180
+ runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
181
+ runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
182
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
183
+ // `git remote -v` lists every remote's fetch/push URL; parsed into the
184
+ // primary remote (origin, else the first) for the left-column status bar.
185
+ runCommand(deps.run, ["git", "remote", "-v"], root, "remote", deps.signal)
186
+ ]);
187
+ const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
188
+ const unborn = "run" in headRes && headRes.run.exitCode !== 0;
189
+ const head = "run" in headRes && headRes.run.exitCode === 0 ? headRes.run.stdout.trim() || null : null;
190
+ let allChanges = [];
191
+ let statusLossy = false;
192
+ if ("run" in statusRes && !statusRes.run.timedOut && statusRes.run.exitCode === 0) {
193
+ allChanges = parseStatus(statusRes.run.stdout);
194
+ statusLossy = statusRes.run.stdoutLossy;
195
+ }
196
+ const truncated = allChanges.length > config.maxChanges || statusLossy;
197
+ const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
198
+ let staged = 0;
199
+ let modified = 0;
200
+ let untracked = 0;
201
+ for (const c of allChanges) {
202
+ if (c.status === "untracked") untracked++;
203
+ else if (c.staged) staged++;
204
+ else modified++;
205
+ }
206
+ let ahead = 0;
207
+ let behind = 0;
208
+ const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
209
+ if (hasUpstream && "run" in aheadBehindRes) {
210
+ const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
211
+ behind = Number(parts[0]) || 0;
212
+ ahead = Number(parts[1]) || 0;
213
+ }
214
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
215
+ let lastCommit = null;
216
+ if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
217
+ lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
218
+ }
219
+ const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
220
+ const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
221
+ const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
222
+ let untrackedInsertions = 0;
223
+ if (statsDetailed) {
224
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
225
+ const safeUntracked = untrackedPaths.filter(isSafePath);
226
+ if (safeUntracked.length > 0) {
227
+ const perFile = await Promise.all(
228
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
229
+ );
230
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
231
+ }
232
+ }
233
+ const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
234
+ const distinct = new Set(allChanges.map((c) => c.path));
235
+ const stats = {
236
+ fileCount: distinct.size,
237
+ staged,
238
+ modified,
239
+ untracked,
240
+ insertions: wt.insertions + stg.insertions + untrackedInsertions,
241
+ deletions: wt.deletions + stg.deletions,
242
+ lastChangeAt,
243
+ headCommittedAt: lastCommit?.dateIso ?? null,
244
+ partial: !statsDetailed
245
+ };
246
+ const snapshot = {
247
+ root,
248
+ branch,
249
+ head,
250
+ unborn,
251
+ dirty: allChanges.length > 0,
252
+ staged,
253
+ modified,
254
+ untracked,
255
+ ahead,
256
+ behind,
257
+ hasUpstream,
258
+ remote,
259
+ lastCommit,
260
+ changes,
261
+ stats,
262
+ truncated,
263
+ refreshIntervalMs: config.refreshIntervalMs,
264
+ showInputPill: config.showInputPill,
265
+ defaultDiffView: config.defaultDiffView,
266
+ graphStyle: config.graphStyle,
267
+ suggestEnabled: config.suggestEnabled,
268
+ checkedAt: Date.now()
269
+ };
270
+ return { ok: true, value: snapshot };
271
+ }
272
+ async function maxChangeMtime(deps, root, changes, cap = 200) {
273
+ let max = null;
274
+ const slice = changes.slice(0, cap);
275
+ await Promise.all(slice.map(async (c) => {
276
+ if (c.isDirectory) return;
277
+ try {
278
+ const info = await deps.fs.stat(join(root, c.path));
279
+ if (Number.isFinite(info.mtimeMs)) {
280
+ max = max === null ? info.mtimeMs : Math.max(max, info.mtimeMs);
281
+ }
282
+ } catch {
283
+ }
284
+ }));
285
+ return max;
286
+ }
287
+ export {
288
+ DEFAULT_CONFIG,
289
+ mapWorkspaceFailure,
290
+ maxChangeMtime,
291
+ normalizeConfig,
292
+ parsePrimaryRemote,
293
+ readBool,
294
+ readDiffView,
295
+ readGraphStyle,
296
+ resolveBrowseRoot,
297
+ resolveWorkspace,
298
+ runCommand,
299
+ snapshotForSession
300
+ };
@@ -0,0 +1,65 @@
1
+ import { readFile } from "node:fs/promises";
2
+ function createGitRunner(subprocess, timeoutMs, maxBytes) {
3
+ const spillMaxBytes = maxBytes * 16;
4
+ return {
5
+ async run(argv, opts) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8
+ try {
9
+ const signal = opts.signal === void 0 ? controller.signal : AbortSignal.any([controller.signal, opts.signal]);
10
+ const handle = subprocess.spawn({
11
+ argv,
12
+ cwd: opts.cwd,
13
+ // Never let a network command (fetch/pull) block on an interactive
14
+ // credential or host-key prompt: with stdin ignored git would hang
15
+ // until the timeout. GIT_TERMINAL_PROMPT=0 makes it fail fast instead,
16
+ // and is harmless for the non-network commands that share this runner.
17
+ env: { GIT_TERMINAL_PROMPT: "0" },
18
+ stdio: {
19
+ stdin: opts.stdinData === void 0 ? "ignore" : { data: opts.stdinData },
20
+ stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
21
+ stderr: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } }
22
+ },
23
+ graceMs: 200,
24
+ signal
25
+ });
26
+ let outcome;
27
+ try {
28
+ outcome = await handle.done;
29
+ } catch (error) {
30
+ const cancelled2 = opts.signal?.aborted === true && !controller.signal.aborted;
31
+ if (controller.signal.aborted || opts.signal?.aborted === true) {
32
+ return { exitCode: null, stdout: "", stderr: "", timedOut: !cancelled2, cancelled: cancelled2, stdoutLossy: false };
33
+ }
34
+ throw error;
35
+ }
36
+ const stdout = handle.collected.stdout?.readFrom(0);
37
+ const stderr = handle.collected.stderr?.readFrom(0);
38
+ const resolved = await resolveStdout(stdout);
39
+ const cancelled = opts.signal?.aborted === true && !controller.signal.aborted;
40
+ return {
41
+ exitCode: outcome.exitCode,
42
+ stdout: resolved.text,
43
+ stderr: stderr?.text ?? "",
44
+ timedOut: controller.signal.aborted,
45
+ cancelled,
46
+ stdoutLossy: resolved.lossy
47
+ };
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+ };
53
+ }
54
+ async function resolveStdout(read) {
55
+ if (read === void 0) return { text: "", lossy: false };
56
+ if (!read.lossy || read.spillPath === void 0) return { text: read.text, lossy: read.lossy };
57
+ try {
58
+ return { text: await readFile(read.spillPath, "utf8"), lossy: false };
59
+ } catch {
60
+ return { text: read.text, lossy: true };
61
+ }
62
+ }
63
+ export {
64
+ createGitRunner
65
+ };