@xbzbing/dsh-git-panel 1.5.3 → 1.6.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
- 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[])[];
@@ -22,7 +22,9 @@ export declare function scrubCredentials(text: string): string;
22
22
  * rule below (git keeps the stash entry on conflict → recoverable).
23
23
  * - index.lock busy (another process holds the lock).
24
24
  * - nothing-to-commit / no changes added (a real commit no-op).
25
- * - not-found (missing ref / tag / stash).
25
+ * - not-found (missing ref / tag / stash, or a checkout target that resolves
26
+ * to neither a ref nor a path).
27
+ * - branch-in-use (the target branch is checked out in another worktree).
26
28
  * - local-changes-block (a dirty-worktree refusal; neutral wording covers both
27
29
  * `git checkout` and `git stash apply`).
28
30
  * - revert-merge (reverting a merge commit without a mainline parent — git
@@ -0,0 +1,250 @@
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
+ let checkout = ["git", "checkout", "--end-of-options", action.name];
40
+ if (action.createFrom !== void 0) {
41
+ if (!isSafeBranchName(action.createFrom)) return { error: "invalid-name", message: `unsafe tracking ref: ${action.createFrom}` };
42
+ checkout = ["git", "checkout", "-b", action.name, "--track", "--end-of-options", action.createFrom];
43
+ }
44
+ const msg = action.stashMessage?.trim() ?? "";
45
+ return { argv: msg === "" ? [checkout] : [["git", "stash", "push", "-u", "-m", msg], checkout] };
46
+ }
47
+ case "fetch":
48
+ return { argv: [["git", "fetch", "--all", "--prune"]] };
49
+ case "pull-ff":
50
+ return { argv: [["git", "pull", "--ff-only"]] };
51
+ case "push":
52
+ return { argv: [["git", "push"]] };
53
+ case "publish":
54
+ if (!isSafeRemoteName(action.remote)) return { error: "invalid-name", message: `unsafe remote name: ${action.remote}` };
55
+ return { argv: [["git", "push", "-u", action.remote, "HEAD"]] };
56
+ case "tag-create": {
57
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
58
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
59
+ const msg = action.message?.trim() ?? "";
60
+ const flags = msg === "" ? [] : ["-a", "-m", msg];
61
+ return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
62
+ }
63
+ case "tag-delete":
64
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
65
+ return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
66
+ case "stash-push": {
67
+ const msg = action.message?.trim() ?? "";
68
+ return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
69
+ }
70
+ case "stash-apply":
71
+ case "stash-pop":
72
+ case "stash-drop": {
73
+ if (!Number.isInteger(action.index) || action.index < 0) {
74
+ return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
75
+ }
76
+ const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
77
+ return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
78
+ }
79
+ case "revert": {
80
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
81
+ return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
82
+ }
83
+ case "reset": {
84
+ if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
85
+ if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
86
+ return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
87
+ }
88
+ return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
89
+ }
90
+ }
91
+ }
92
+ function isIndexBusy(outcome) {
93
+ if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
94
+ return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
95
+ }
96
+ function scrubCredentials(text) {
97
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, "$1");
98
+ }
99
+ function classifyActionFailure(stdout, stderr, exitCode) {
100
+ const combined = stderr + stdout;
101
+ const err = scrubCredentials(stderr.trim());
102
+ if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
103
+ return { code: "conflict", message: err || "merge conflict" };
104
+ }
105
+ if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
106
+ return { code: "index-busy", message: err };
107
+ }
108
+ if (/nothing to commit|no changes added/i.test(combined)) {
109
+ return { code: "git-error", message: err || "nothing to commit" };
110
+ }
111
+ 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|did not match any file\(s\) known to git/i.test(combined)) {
112
+ return { code: "not-found", message: err || "not found" };
113
+ }
114
+ if (/is already used by worktree|already checked out at/i.test(combined)) {
115
+ return { code: "branch-in-use", message: err || "branch is checked out in another worktree" };
116
+ }
117
+ if (/Authentication failed|could not read (Username|Password)|terminal prompts disabled|Permission denied \(publickey\)|Could not read from remote repository/i.test(combined)) {
118
+ return { code: "auth-failed", message: err || "authentication failed" };
119
+ }
120
+ if (/\[rejected\]|Updates were rejected|failed to push some refs|fetch first|tip of your current branch is behind/i.test(combined)) {
121
+ return { code: "push-rejected", message: err || "push rejected (remote has new commits; fetch first)" };
122
+ }
123
+ if (/Not possible to fast-forward|cannot fast-forward|not a fast-forward|Need to specify how to reconcile divergent branches/i.test(combined)) {
124
+ return { code: "not-ff", message: err || "cannot fast-forward (branches diverged)" };
125
+ }
126
+ if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
127
+ return { code: "local-changes-block", message: err };
128
+ }
129
+ if (/is a merge but no -m|mainline parent/i.test(combined)) {
130
+ return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
131
+ }
132
+ return { code: "git-error", message: err || `git exited ${exitCode}` };
133
+ }
134
+ async function runAction(deps, config, request) {
135
+ const workspace = await resolveWorkspace(deps, request.sessionId);
136
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
137
+ const root = workspace.root;
138
+ const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
139
+ const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
140
+ const act = request.action;
141
+ if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
142
+ if (!Number.isInteger(act.index) || act.index < 0) {
143
+ return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
144
+ }
145
+ const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
146
+ const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
147
+ if (sha === "" || sha !== act.sha) {
148
+ return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
149
+ }
150
+ }
151
+ if (request.action.kind === "discard") {
152
+ const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
153
+ if (removed !== null) {
154
+ if (!removed.ok) return removed.result;
155
+ if (removed.remainingTracked.length === 0) {
156
+ const snapshot2 = await snapshotForSession(deps, config, request.sessionId);
157
+ if (!snapshot2.ok) return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
158
+ return { ok: true, snapshot: snapshot2.value, output: "" };
159
+ }
160
+ request = { ...request, action: { kind: "discard", paths: removed.remainingTracked } };
161
+ }
162
+ }
163
+ const plan = planAction(request.action, unborn);
164
+ if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
165
+ const stashing = request.action.kind === "branch-checkout" && (request.action.stashMessage?.trim() ?? "") !== "";
166
+ let lastOutput = "";
167
+ for (let step = 0; step < plan.argv.length; step += 1) {
168
+ const argv = plan.argv[step];
169
+ let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
170
+ if (isIndexBusy(outcome)) {
171
+ await new Promise((resolve) => setTimeout(resolve, 150));
172
+ outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
173
+ }
174
+ const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
175
+ const stashChangedWorkTree = stashing && step > 0;
176
+ if ("failure" in outcome) {
177
+ const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
178
+ return { ok: false, error: { code: stashChangedWorkTree ? "stash-switch-failed" : "git-unavailable", message: message + where } };
179
+ }
180
+ if (outcome.run.cancelled) {
181
+ return { ok: false, error: stashChangedWorkTree ? { code: "stash-switch-failed", message: "cancelled" + where } : { code: "cancelled" } };
182
+ }
183
+ if (outcome.run.timedOut) {
184
+ return { ok: false, error: stashChangedWorkTree ? { code: "stash-switch-failed", message: "timed out" + where } : { code: "timeout" } };
185
+ }
186
+ lastOutput = outcome.run.stdout || outcome.run.stderr;
187
+ if (outcome.run.exitCode !== 0) {
188
+ const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
189
+ if (stashChangedWorkTree) {
190
+ return { ok: false, error: { code: "stash-switch-failed", message: failure.message + where } };
191
+ }
192
+ if (request.action.kind === "revert" && failure.code === "conflict") {
193
+ let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
194
+ if (isIndexBusy(abort)) {
195
+ await new Promise((resolve) => setTimeout(resolve, 150));
196
+ abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
197
+ }
198
+ const aborted = "run" in abort && abort.run.exitCode === 0;
199
+ return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
200
+ }
201
+ return { ok: false, error: { code: failure.code, message: failure.message + where } };
202
+ }
203
+ if ((request.action.kind === "stash-push" || stashing) && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
204
+ const message = (outcome.run.stdout.trim() || "No local changes to save") + where;
205
+ return { ok: false, error: { code: stashing ? "local-changes-block" : "git-error", message } };
206
+ }
207
+ }
208
+ const snapshot = await snapshotForSession(deps, config, request.sessionId);
209
+ if (!snapshot.ok) {
210
+ return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
211
+ }
212
+ return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
213
+ }
214
+ async function discardUntracked(deps, config, root, sessionId, paths) {
215
+ const snap = await snapshotForSession(deps, config, sessionId);
216
+ if (!snap.ok) return null;
217
+ const untrackedSet = new Set(snap.value.changes.filter((c) => c.status === "untracked").map((c) => c.path));
218
+ const untracked = paths.filter((p) => untrackedSet.has(p));
219
+ if (untracked.length === 0) return null;
220
+ const tracked = paths.filter((p) => !untrackedSet.has(p));
221
+ let rootReal;
222
+ try {
223
+ rootReal = await deps.fs.realpath(root);
224
+ } catch {
225
+ return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
226
+ }
227
+ for (const path of untracked) {
228
+ if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
229
+ const target = join(root, path);
230
+ let targetReal;
231
+ try {
232
+ targetReal = await deps.fs.realpath(target);
233
+ } catch {
234
+ continue;
235
+ }
236
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
237
+ return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
238
+ }
239
+ await deps.fs.remove(target).catch(() => {
240
+ });
241
+ }
242
+ return { ok: true, remainingTracked: tracked };
243
+ }
244
+ export {
245
+ classifyActionFailure,
246
+ isSafePath,
247
+ planAction,
248
+ runAction,
249
+ scrubCredentials
250
+ };
@@ -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. */
@@ -15,9 +15,11 @@ export interface GitPanelConfig {
15
15
  readonly graphStyle: GraphStyle;
16
16
  /** Whether the "AI suggest" button shows in the commit box. */
17
17
  readonly suggestEnabled: boolean;
18
- /** Max diff bytes sent to the model; beyond this the diff is truncated. */
18
+ /** Max diff bytes sent to the model; beyond this the diff is truncated. Range 16–512 KiB. */
19
19
  readonly suggestMaxBytes: number;
20
+ /** Model output token budget (reasoning tokens count against it on thinking models). Range 256–16384. */
20
21
  readonly suggestMaxOutputTokens: number;
22
+ /** Timeout for one suggest call. Range 5–120 s. */
21
23
  readonly suggestTimeoutMs: number;
22
24
  /** Optional provider/model route override; absent → the agent default. */
23
25
  readonly suggestProvider?: string;
@@ -46,6 +48,12 @@ export interface SnapshotDeps {
46
48
  }>;
47
49
  /** Raw bytes (no encoding) — image sides for the image-diff query. */
48
50
  readFile(path: string): Promise<Buffer>;
51
+ /**
52
+ * A symlink's target text, else null. git stores the LINK as the worktree
53
+ * blob (mode 120000), so the diff's new side is the target string, not the
54
+ * file it points at — following the link would describe a different file.
55
+ */
56
+ symlinkTarget(path: string): Promise<string | null>;
49
57
  /** One directory's entries (name + is-directory) — the file browser. */
50
58
  readdir(path: string): Promise<ReadonlyArray<{
51
59
  name: string;
@@ -0,0 +1,305 @@
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
+ import { SUGGEST_LIMITS } from "./types.js";
6
+ const DEFAULT_CONFIG = {
7
+ timeoutMs: 8e3,
8
+ maxBytes: 4 * 1024 * 1024,
9
+ maxChanges: 1e3,
10
+ refreshIntervalMs: 3e4,
11
+ showInputPill: true,
12
+ defaultDiffView: "unified",
13
+ graphStyle: "parallel",
14
+ suggestEnabled: true,
15
+ suggestMaxBytes: SUGGEST_LIMITS.maxBytes.default,
16
+ suggestMaxOutputTokens: SUGGEST_LIMITS.maxOutputTokens.default,
17
+ suggestTimeoutMs: SUGGEST_LIMITS.timeoutMs.default
18
+ };
19
+ function normalizeConfig(raw) {
20
+ const c = raw ?? {};
21
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
22
+ const clampLimit = (v, limit) => {
23
+ const raw2 = unwrapVolatile(v);
24
+ return typeof raw2 === "number" && Number.isFinite(raw2) ? Math.min(limit.max, Math.max(limit.min, Math.floor(raw2))) : limit.default;
25
+ };
26
+ const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
27
+ const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
28
+ const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
29
+ return {
30
+ timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
31
+ maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
32
+ maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
33
+ refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
34
+ showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
35
+ defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
36
+ graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
37
+ suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
38
+ suggestMaxBytes: clampLimit(c.suggestMaxBytes, SUGGEST_LIMITS.maxBytes),
39
+ suggestMaxOutputTokens: clampLimit(c.suggestMaxOutputTokens, SUGGEST_LIMITS.maxOutputTokens),
40
+ suggestTimeoutMs: clampLimit(c.suggestTimeoutMs, SUGGEST_LIMITS.timeoutMs),
41
+ ...route
42
+ };
43
+ }
44
+ function unwrapVolatile(value) {
45
+ return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
46
+ }
47
+ function readDiffView(value, fallback) {
48
+ const raw = unwrapVolatile(value);
49
+ return raw === "unified" || raw === "split" ? raw : fallback;
50
+ }
51
+ function readGraphStyle(value, fallback) {
52
+ const raw = unwrapVolatile(value);
53
+ return raw === "parallel" || raw === "compact" ? raw : fallback;
54
+ }
55
+ function readBool(value, fallback) {
56
+ const raw = unwrapVolatile(value);
57
+ return typeof raw === "boolean" ? raw : fallback;
58
+ }
59
+ const NEG_CACHE_MS = 15e3;
60
+ function parsePrimaryRemote(stdout) {
61
+ const fetchUrls = /* @__PURE__ */ new Map();
62
+ const order = [];
63
+ for (const line of stdout.split("\n")) {
64
+ const m = line.match(/^(\S+)\t(\S+)\s+\(fetch\)$/);
65
+ if (m === null) continue;
66
+ const name2 = m[1];
67
+ if (!fetchUrls.has(name2)) {
68
+ fetchUrls.set(name2, m[2]);
69
+ order.push(name2);
70
+ }
71
+ }
72
+ if (order.length === 0) return null;
73
+ const name = fetchUrls.has("origin") ? "origin" : order[0];
74
+ return parseRemote(name, fetchUrls.get(name));
75
+ }
76
+ const STATS_FANOUT_LIMIT = 200;
77
+ async function resolveBrowseRoot(deps, sessionId) {
78
+ const ws = await resolveWorkspace(deps, sessionId);
79
+ if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
80
+ const err = ws.failure.error;
81
+ if ((err.code === "not-a-git-repo" || err.code === "git-unavailable") && "cwd" in err && err.cwd !== void 0 && err.cwd !== "") {
82
+ try {
83
+ return { ok: true, root: await deps.fs.realpath(err.cwd), isGitRepo: false };
84
+ } catch {
85
+ return { ok: false, error: { code: "git-error" } };
86
+ }
87
+ }
88
+ return { ok: false, error: mapWorkspaceFailure(ws.failure) };
89
+ }
90
+ async function resolveWorkspace(deps, sessionId) {
91
+ let cwd = deps.sessions.liveCwd(sessionId);
92
+ if (cwd === void 0 || cwd === "") {
93
+ const meta = await deps.sessions.persistedMeta(sessionId);
94
+ cwd = meta?.cwd;
95
+ }
96
+ if (cwd === void 0 || cwd === "") {
97
+ return { ok: false, failure: { ok: false, error: { code: "cwd-unavailable", sessionId } } };
98
+ }
99
+ const cached = deps.rootCache?.get(cwd);
100
+ if (cached !== void 0) return { ok: true, root: cached };
101
+ const negAt = deps.rootNegCache?.get(cwd);
102
+ if (negAt !== void 0) {
103
+ if (Date.now() < negAt) return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
104
+ deps.rootNegCache?.delete(cwd);
105
+ }
106
+ const top = await runCommand(deps.run, ["git", "rev-parse", "--show-toplevel"], cwd, "toplevel", deps.signal);
107
+ if ("failure" in top) {
108
+ return { ok: false, failure: { ok: false, error: { ...mapRunFailure(top.failure), cwd } } };
109
+ }
110
+ if (top.run.cancelled) return { ok: false, failure: { ok: false, error: { code: "cancelled" } } };
111
+ if (top.run.timedOut) return { ok: false, failure: { ok: false, error: { code: "timeout" } } };
112
+ if (top.run.exitCode !== 0) {
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
+ const raw = top.run.stdout.trim();
117
+ if (raw === "") {
118
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
119
+ return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
120
+ }
121
+ let root = raw;
122
+ try {
123
+ root = await deps.fs.realpath(raw);
124
+ } catch {
125
+ }
126
+ deps.rootCache?.set(cwd, root);
127
+ return { ok: true, root };
128
+ }
129
+ async function runCommand(runner, argv, cwd, _label, signal, stdinData) {
130
+ try {
131
+ const run = await runner.run(argv, { cwd, ...signal ? { signal } : {}, ...stdinData !== void 0 ? { stdinData } : {} });
132
+ return { run };
133
+ } catch (error) {
134
+ return { failure: error };
135
+ }
136
+ }
137
+ function mapRunFailure(failure) {
138
+ const message = failure instanceof Error ? failure.message : String(failure);
139
+ return { code: "git-unavailable", detail: message };
140
+ }
141
+ async function detectGitRepo(deps, cwd) {
142
+ let dir;
143
+ try {
144
+ dir = await deps.fs.realpath(cwd);
145
+ } catch {
146
+ dir = cwd;
147
+ }
148
+ for (let i = 0; i < 100; i++) {
149
+ try {
150
+ await deps.fs.stat(join(dir, ".git"));
151
+ return true;
152
+ } catch {
153
+ }
154
+ const parent = dirname(dir);
155
+ if (parent === dir) break;
156
+ dir = parent;
157
+ }
158
+ return false;
159
+ }
160
+ function mapWorkspaceFailure(failure) {
161
+ const error = failure.error;
162
+ const message = "detail" in error ? error.detail : void 0;
163
+ return { code: error.code, ...message !== void 0 ? { message } : {} };
164
+ }
165
+ async function snapshotForSession(deps, config, sessionId) {
166
+ const workspace = await resolveWorkspace(deps, sessionId);
167
+ if (!workspace.ok) {
168
+ const failure = workspace.failure;
169
+ if (failure.error.code === "not-a-git-repo") {
170
+ return { ok: false, error: { ...failure.error, showInputPill: config.showInputPill } };
171
+ }
172
+ if (failure.error.code === "git-unavailable") {
173
+ const cwd = failure.error.cwd;
174
+ const isGitRepo = cwd !== void 0 && cwd !== "" ? await detectGitRepo(deps, cwd) : false;
175
+ return { ok: false, error: { ...failure.error, isGitRepo } };
176
+ }
177
+ return failure;
178
+ }
179
+ const root = workspace.root;
180
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
181
+ runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
182
+ runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
183
+ runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
184
+ runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
185
+ runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
186
+ runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
187
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
188
+ // `git remote -v` lists every remote's fetch/push URL; parsed into the
189
+ // primary remote (origin, else the first) for the left-column status bar.
190
+ runCommand(deps.run, ["git", "remote", "-v"], root, "remote", deps.signal)
191
+ ]);
192
+ const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
193
+ const unborn = "run" in headRes && headRes.run.exitCode !== 0;
194
+ const head = "run" in headRes && headRes.run.exitCode === 0 ? headRes.run.stdout.trim() || null : null;
195
+ let allChanges = [];
196
+ let statusLossy = false;
197
+ if ("run" in statusRes && !statusRes.run.timedOut && statusRes.run.exitCode === 0) {
198
+ allChanges = parseStatus(statusRes.run.stdout);
199
+ statusLossy = statusRes.run.stdoutLossy;
200
+ }
201
+ const truncated = allChanges.length > config.maxChanges || statusLossy;
202
+ const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
203
+ let staged = 0;
204
+ let modified = 0;
205
+ let untracked = 0;
206
+ for (const c of allChanges) {
207
+ if (c.status === "untracked") untracked++;
208
+ else if (c.staged) staged++;
209
+ else modified++;
210
+ }
211
+ let ahead = 0;
212
+ let behind = 0;
213
+ const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
214
+ if (hasUpstream && "run" in aheadBehindRes) {
215
+ const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
216
+ behind = Number(parts[0]) || 0;
217
+ ahead = Number(parts[1]) || 0;
218
+ }
219
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
220
+ let lastCommit = null;
221
+ if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
222
+ lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
223
+ }
224
+ const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
225
+ const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
226
+ const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
227
+ let untrackedInsertions = 0;
228
+ if (statsDetailed) {
229
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
230
+ const safeUntracked = untrackedPaths.filter(isSafePath);
231
+ if (safeUntracked.length > 0) {
232
+ const perFile = await Promise.all(
233
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
234
+ );
235
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
236
+ }
237
+ }
238
+ const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
239
+ const distinct = new Set(allChanges.map((c) => c.path));
240
+ const stats = {
241
+ fileCount: distinct.size,
242
+ staged,
243
+ modified,
244
+ untracked,
245
+ insertions: wt.insertions + stg.insertions + untrackedInsertions,
246
+ deletions: wt.deletions + stg.deletions,
247
+ lastChangeAt,
248
+ headCommittedAt: lastCommit?.dateIso ?? null,
249
+ partial: !statsDetailed
250
+ };
251
+ const snapshot = {
252
+ root,
253
+ branch,
254
+ head,
255
+ unborn,
256
+ dirty: allChanges.length > 0,
257
+ staged,
258
+ modified,
259
+ untracked,
260
+ ahead,
261
+ behind,
262
+ hasUpstream,
263
+ remote,
264
+ lastCommit,
265
+ changes,
266
+ stats,
267
+ truncated,
268
+ refreshIntervalMs: config.refreshIntervalMs,
269
+ showInputPill: config.showInputPill,
270
+ defaultDiffView: config.defaultDiffView,
271
+ graphStyle: config.graphStyle,
272
+ suggestEnabled: config.suggestEnabled,
273
+ checkedAt: Date.now()
274
+ };
275
+ return { ok: true, value: snapshot };
276
+ }
277
+ async function maxChangeMtime(deps, root, changes, cap = 200) {
278
+ let max = null;
279
+ const slice = changes.slice(0, cap);
280
+ await Promise.all(slice.map(async (c) => {
281
+ if (c.isDirectory) return;
282
+ try {
283
+ const info = await deps.fs.stat(join(root, c.path));
284
+ if (Number.isFinite(info.mtimeMs)) {
285
+ max = max === null ? info.mtimeMs : Math.max(max, info.mtimeMs);
286
+ }
287
+ } catch {
288
+ }
289
+ }));
290
+ return max;
291
+ }
292
+ export {
293
+ DEFAULT_CONFIG,
294
+ mapWorkspaceFailure,
295
+ maxChangeMtime,
296
+ normalizeConfig,
297
+ parsePrimaryRemote,
298
+ readBool,
299
+ readDiffView,
300
+ readGraphStyle,
301
+ resolveBrowseRoot,
302
+ resolveWorkspace,
303
+ runCommand,
304
+ snapshotForSession
305
+ };