@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.
@@ -0,0 +1,499 @@
1
+ import { join, sep } from "node:path";
2
+ import { mapWorkspaceFailure, resolveBrowseRoot, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
3
+ import { isSafePath, isSafeRev } from "./validate.js";
4
+ import { commitFromFields, parseBranches, parseGraphLog, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
5
+ import { imageMimeFor, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX } from "./types.js";
6
+ const GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
7
+ const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
8
+ function isHexLike(text) {
9
+ return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
10
+ }
11
+ async function runQuery(deps, config, request) {
12
+ const q = request.query;
13
+ if (q.kind === "dir-list" || q.kind === "file-content") {
14
+ const browse = await resolveBrowseRoot(deps, request.sessionId);
15
+ if (!browse.ok) return { ok: false, error: browse.error };
16
+ try {
17
+ return q.kind === "dir-list" ? await queryDirList(deps, browse.root, q, browse.isGitRepo) : await queryFileContent(deps, config, browse.root, q);
18
+ } catch (error) {
19
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : String(error) } };
20
+ }
21
+ }
22
+ const workspace = await resolveWorkspace(deps, request.sessionId);
23
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
24
+ const root = workspace.root;
25
+ try {
26
+ switch (q.kind) {
27
+ case "history":
28
+ return await queryHistory(deps, root, q);
29
+ case "diff":
30
+ return await queryDiff(deps, root, q);
31
+ case "file-lines":
32
+ return await queryFileLines(deps, root, q);
33
+ case "image-diff":
34
+ return await queryImageDiff(deps, config, root, q);
35
+ case "show":
36
+ return await queryShow(deps, root, q.ref);
37
+ case "branches":
38
+ return await queryBranches(deps, root);
39
+ case "tags":
40
+ return await queryTags(deps, root);
41
+ case "stash-list":
42
+ return await queryStashList(deps, root);
43
+ case "authors":
44
+ return await queryAuthors(deps, root);
45
+ case "last-commit-message":
46
+ return await queryLastCommitMessage(deps, root);
47
+ case "worktree-stats":
48
+ return await queryWorktreeStats(deps, config, request.sessionId);
49
+ case "pull-preview":
50
+ return await queryPullPreview(deps, root);
51
+ case "push-preview":
52
+ return await queryPushPreview(deps, root);
53
+ }
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ return { ok: false, error: { code: "git-error", message } };
57
+ }
58
+ }
59
+ async function queryHistory(deps, root, q) {
60
+ const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
61
+ const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
62
+ const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
63
+ const search = q.search?.trim() ?? "";
64
+ const hexJump = search !== "" && isHexLike(search);
65
+ const countArgs = ["git", "rev-list", "--count"];
66
+ if (!hexJump) {
67
+ const filters = [];
68
+ if (search !== "") filters.push("-i", "-E", `--grep=${search}`);
69
+ if (q.author !== void 0 && q.author !== "") filters.push(`--author=${q.author}`);
70
+ if (q.since !== void 0 && q.since !== "") filters.push(`--since=${q.since}`);
71
+ if (q.ref !== void 0 && q.ref !== "") {
72
+ if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
73
+ filters.push("--end-of-options", q.ref);
74
+ } else {
75
+ filters.push(...VISIBLE_REFS);
76
+ }
77
+ args.push(...filters);
78
+ countArgs.push(...filters);
79
+ } else {
80
+ args.push("--end-of-options", search);
81
+ }
82
+ const [res, countRes] = await Promise.all([
83
+ runCommand(deps.run, args, root, "history", deps.signal),
84
+ hexJump ? Promise.resolve(null) : runCommand(deps.run, countArgs, root, "history-count", deps.signal)
85
+ ]);
86
+ if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
87
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
88
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
89
+ if (res.run.exitCode !== 0) {
90
+ const stderr = res.run.stderr.trim();
91
+ if (hexJump || /unknown revision|bad revision|does not have any commits|ambiguous argument/i.test(stderr)) {
92
+ return { ok: true, value: { kind: "history", commits: [], total: 0 } };
93
+ }
94
+ return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
95
+ }
96
+ const commits = parseGraphLog(res.run.stdout);
97
+ let total = -1;
98
+ if (!hexJump && countRes !== null && "run" in countRes && countRes.run.exitCode === 0) {
99
+ const n = Number(countRes.run.stdout.trim());
100
+ if (Number.isFinite(n)) total = n;
101
+ }
102
+ return { ok: true, value: { kind: "history", commits, total } };
103
+ }
104
+ async function queryDiff(deps, root, q) {
105
+ if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
106
+ const ctx = q.context !== void 0 && Number.isFinite(q.context) ? Math.max(0, Math.min(DIFF_CONTEXT_MAX, Math.floor(q.context))) : DIFF_CONTEXT_DEFAULT;
107
+ const unified = `-U${ctx}`;
108
+ let args;
109
+ if (q.base === "staged") {
110
+ args = ["git", "diff", unified, "--cached", "--", q.path];
111
+ } else if (q.base === "commit") {
112
+ if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
113
+ args = ["git", "show", unified, "--end-of-options", q.commit, "--", q.path];
114
+ } else {
115
+ args = ["git", "diff", unified, "--", q.path];
116
+ }
117
+ const res = await runCommand(deps.run, args, root, "diff", deps.signal);
118
+ if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
119
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
120
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
121
+ let truncated = res.run.stdoutLossy;
122
+ let text = res.run.stdout;
123
+ if (q.base === "worktree" && text.trim() === "") {
124
+ const tracked = await runCommand(deps.run, ["git", "ls-files", "--error-unmatch", "--", q.path], root, "diff-tracked-probe", deps.signal);
125
+ const isUntracked = !("run" in tracked) || tracked.run.exitCode !== 0;
126
+ if (isUntracked) {
127
+ const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
128
+ if ("run" in noIndex) {
129
+ truncated = truncated || noIndex.run.stdoutLossy;
130
+ text = noIndex.run.stdout;
131
+ }
132
+ }
133
+ }
134
+ return {
135
+ ok: true,
136
+ value: {
137
+ kind: "diff",
138
+ path: q.path,
139
+ text,
140
+ newLines: await countNewSideLines(deps, root, q, text),
141
+ ...truncated ? { truncated: true } : {}
142
+ }
143
+ };
144
+ }
145
+ async function countNewSideLines(deps, root, q, diffText) {
146
+ if (!diffText.includes("@@")) return 0;
147
+ const side = await readNewSide(deps, root, q, { worktreeMaxBytes: DIFF_COUNT_READ_MAX });
148
+ if (!side.ok || side.text === null) return 0;
149
+ return countLines(side.text);
150
+ }
151
+ function countLines(content) {
152
+ if (content === "") return 0;
153
+ let lines = 1;
154
+ for (let at = content.indexOf("\n"); at !== -1; at = content.indexOf("\n", at + 1)) lines++;
155
+ return content.endsWith("\n") ? lines - 1 : lines;
156
+ }
157
+ function splitLines(content) {
158
+ const all = content.split("\n");
159
+ if (all[all.length - 1] === "") all.pop();
160
+ return all;
161
+ }
162
+ async function queryFileLines(deps, root, q) {
163
+ if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
164
+ const start = Number.isFinite(q.start) ? Math.max(1, Math.floor(q.start)) : 1;
165
+ const end = Number.isFinite(q.end) ? Math.max(start, Math.floor(q.end)) : start;
166
+ const side = await readNewSide(deps, root, q);
167
+ if (!side.ok) return { ok: false, error: { code: side.code, message: side.message } };
168
+ return sliceLines(q.path, side.text ?? "", start, end);
169
+ }
170
+ const ABSENT_SIDE_RE = /does not exist in|exists on disk, but not in the index|no such path/i;
171
+ async function readNewSide(deps, root, q, opts) {
172
+ if (q.base === "worktree") return { ok: true, text: await readWorktreeSide(deps, root, q.path, opts?.worktreeMaxBytes) };
173
+ if (q.base === "commit" && !isSafeRev(q.commit)) return { ok: false, code: "invalid-name", message: `unsafe commit: ${q.commit}` };
174
+ const spec = q.base === "commit" ? `${q.commit}:${q.path}` : `:${q.path}`;
175
+ const res = await runCommand(deps.run, ["git", "show", "--end-of-options", spec], root, "new-side", deps.signal);
176
+ if (!("run" in res)) return { ok: false, code: "git-unavailable", message: "git unavailable" };
177
+ if (res.run.cancelled) return { ok: false, code: "cancelled", message: "cancelled" };
178
+ if (res.run.timedOut) return { ok: false, code: "timeout", message: "timed out" };
179
+ if (res.run.exitCode !== 0) {
180
+ return ABSENT_SIDE_RE.test(res.run.stderr) ? { ok: true, text: null } : { ok: false, code: "git-error", message: res.run.stderr.trim() || "git show failed" };
181
+ }
182
+ if (res.run.stdoutLossy) return { ok: false, code: "git-error", message: "git show output exceeded the output cap" };
183
+ return { ok: true, text: res.run.stdout };
184
+ }
185
+ async function readWorktreeSide(deps, root, path, maxBytes) {
186
+ const file = join(root, path);
187
+ const link = await deps.fs.symlinkTarget(file);
188
+ if (link !== null) return link;
189
+ if (maxBytes !== void 0) {
190
+ const size = await deps.fs.stat(file).catch(() => null);
191
+ if (size === null || size.size > maxBytes) return null;
192
+ }
193
+ const text = await readWorktreeText(deps, root, path);
194
+ return text === null ? null : text.replace(/\r\n/g, "\n");
195
+ }
196
+ async function readWorktreeText(deps, root, path) {
197
+ try {
198
+ const file = join(root, path);
199
+ const real = await deps.fs.realpath(file);
200
+ const rootReal = await deps.fs.realpath(root);
201
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return null;
202
+ const buf = await deps.fs.readFile(file);
203
+ return buf.toString("utf8");
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ function sliceLines(path, content, start, end) {
209
+ const all = splitLines(content);
210
+ const from = Math.min(start, all.length + 1);
211
+ const to = Math.min(end, all.length);
212
+ const lines = from <= to ? all.slice(from - 1, to) : [];
213
+ const eof = to >= all.length;
214
+ return { ok: true, value: { kind: "file-lines", path, start: from, lines, eof } };
215
+ }
216
+ async function queryImageDiff(deps, config, root, q) {
217
+ if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
218
+ if (q.base === "commit" && !isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
219
+ const mime = imageMimeFor(q.path);
220
+ if (mime === null) return { ok: true, value: { kind: "image-diff", path: q.path, mime } };
221
+ const oldSpec = q.base === "staged" ? `HEAD:${q.path}` : q.base === "commit" ? `${q.commit}^1:${q.path}` : `:${q.path}`;
222
+ const newSpec = q.base === "staged" ? `:${q.path}` : q.base === "commit" ? `${q.commit}:${q.path}` : null;
223
+ const [oldOid, newOid] = await Promise.all([
224
+ resolveOid(deps, root, oldSpec),
225
+ newSpec === null ? Promise.resolve(void 0) : resolveOid(deps, root, newSpec)
226
+ ]);
227
+ const cap = config.maxBytes;
228
+ const gitDir = oldOid !== void 0 || newOid !== void 0 ? await absoluteGitDir(deps, root) : "";
229
+ const [oldSide, newSide] = await Promise.all([
230
+ oldOid === void 0 ? Promise.resolve(void 0) : blobSide(deps, gitDir, oldOid, cap),
231
+ newOid !== void 0 ? blobSide(deps, gitDir, newOid, cap) : q.base === "worktree" ? worktreeSide(deps, root, q.path, cap) : Promise.resolve(void 0)
232
+ ]);
233
+ const sides = [oldSide, newSide];
234
+ if (sides.some((s) => s !== void 0 && "tooLarge" in s)) {
235
+ return { ok: true, value: { kind: "image-diff", path: q.path, mime, tooLarge: true } };
236
+ }
237
+ const old64 = sides[0] !== void 0 && !("tooLarge" in sides[0]) ? sides[0].data : void 0;
238
+ const new64 = sides[1] !== void 0 && !("tooLarge" in sides[1]) ? sides[1].data : void 0;
239
+ return {
240
+ ok: true,
241
+ value: {
242
+ kind: "image-diff",
243
+ path: q.path,
244
+ mime,
245
+ ...old64 !== void 0 ? { old: `data:${mime};base64,${old64}` } : {},
246
+ ...new64 !== void 0 ? { new: `data:${mime};base64,${new64}` } : {}
247
+ }
248
+ };
249
+ }
250
+ async function resolveOid(deps, root, spec) {
251
+ const res = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", spec], root, "image-oid", deps.signal);
252
+ if (!("run" in res) || res.run.exitCode !== 0) return void 0;
253
+ const oid = res.run.stdout.trim();
254
+ return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(oid) ? oid : void 0;
255
+ }
256
+ async function absoluteGitDir(deps, root) {
257
+ const res = await runCommand(deps.run, ["git", "rev-parse", "--absolute-git-dir"], root, "git-dir", deps.signal);
258
+ if (!("run" in res) || res.run.exitCode !== 0) throw new Error("git dir unavailable");
259
+ return res.run.stdout.trim();
260
+ }
261
+ async function blobSide(deps, gitDir, oid, cap) {
262
+ const sizeRes = await runCommand(deps.run, ["git", "cat-file", "-s", oid], gitDir, "image-size", deps.signal);
263
+ if (!("run" in sizeRes) || sizeRes.run.exitCode !== 0) return void 0;
264
+ const size = Number(sizeRes.run.stdout.trim());
265
+ if (!Number.isFinite(size)) return void 0;
266
+ if (size > cap) return { tooLarge: true };
267
+ const nameRes = await runCommand(deps.run, ["git", "unpack-file", oid], gitDir, "image-unpack", deps.signal);
268
+ if (!("run" in nameRes) || nameRes.run.exitCode !== 0) return void 0;
269
+ const name = nameRes.run.stdout.trim();
270
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return void 0;
271
+ const file = join(gitDir, name);
272
+ try {
273
+ const buf = await deps.fs.readFile(file);
274
+ if (buf.length > cap) return { tooLarge: true };
275
+ return { data: buf.toString("base64") };
276
+ } catch {
277
+ return void 0;
278
+ } finally {
279
+ await deps.fs.remove(file).catch(() => {
280
+ });
281
+ }
282
+ }
283
+ async function worktreeSide(deps, root, path, cap) {
284
+ try {
285
+ const file = join(root, path);
286
+ const real = await deps.fs.realpath(file);
287
+ const rootReal = await deps.fs.realpath(root);
288
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return void 0;
289
+ const info = await deps.fs.stat(file);
290
+ if (info.size > cap) return { tooLarge: true };
291
+ const buf = await deps.fs.readFile(file);
292
+ if (buf.length > cap) return { tooLarge: true };
293
+ return { data: buf.toString("base64") };
294
+ } catch {
295
+ return void 0;
296
+ }
297
+ }
298
+ const DIR_ENTRY_CAP = 2e3;
299
+ async function queryDirList(deps, root, q, isGitRepo) {
300
+ const rel = q.path;
301
+ if (rel !== "" && !isSafePath(rel) || hasGitSegment(rel)) return { ok: false, error: { code: "invalid-path", message: rel } };
302
+ const dir = rel === "" ? root : join(root, rel);
303
+ const inside = await isInsideRoot(deps, root, dir, true);
304
+ if (!inside) return { ok: false, error: { code: "invalid-path", message: rel } };
305
+ let raw;
306
+ try {
307
+ raw = await deps.fs.readdir(dir);
308
+ } catch (error) {
309
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "readdir failed" } };
310
+ }
311
+ const filtered = raw.filter((e) => e.name !== ".git");
312
+ filtered.sort((a, b) => a.isDirectory !== b.isDirectory ? a.isDirectory ? -1 : 1 : a.name.localeCompare(b.name));
313
+ const truncated = filtered.length > DIR_ENTRY_CAP;
314
+ const slice = truncated ? filtered.slice(0, DIR_ENTRY_CAP) : filtered;
315
+ const ignored = isGitRepo ? await ignoredEntries(deps, root, rel, slice.map((e) => e.name)) : /* @__PURE__ */ new Set();
316
+ const entries = await Promise.all(slice.map(async (e) => {
317
+ const ignoreFlag = ignored.has(e.name) ? { ignored: true } : {};
318
+ if (e.isDirectory) return { name: e.name, dir: true, ...ignoreFlag };
319
+ let size;
320
+ try {
321
+ size = (await deps.fs.stat(join(dir, e.name))).size;
322
+ } catch {
323
+ size = void 0;
324
+ }
325
+ return { name: e.name, dir: false, ...size !== void 0 ? { size } : {}, ...ignoreFlag };
326
+ }));
327
+ const path = rel === "" ? "" : rel.replace(/\/+$/, "");
328
+ return { ok: true, value: { kind: "dir-list", path, entries, truncated } };
329
+ }
330
+ async function ignoredEntries(deps, root, parent, names) {
331
+ const ignored = /* @__PURE__ */ new Set();
332
+ const paths = names.map((name) => parent === "" ? name : `${parent}/${name}`);
333
+ for (let index = 0; index < paths.length; ) {
334
+ const batch = [];
335
+ let bytes = 0;
336
+ while (index < paths.length && batch.length < 128) {
337
+ const candidate = paths[index];
338
+ const size = Buffer.byteLength(candidate) + 1;
339
+ if (batch.length > 0 && bytes + size > 32 * 1024) break;
340
+ batch.push(candidate);
341
+ bytes += size;
342
+ index++;
343
+ }
344
+ const result = await runCommand(deps.run, ["git", "check-ignore", "-z", "--stdin"], root, "dir-ignore", deps.signal, `${batch.join("\0")}\0`);
345
+ if ("failure" in result || result.run.timedOut || result.run.cancelled || result.run.stdoutLossy || ![0, 1].includes(result.run.exitCode ?? -1)) continue;
346
+ const matches = new Set(result.run.stdout.split("\0"));
347
+ for (const path of batch) if (matches.has(path)) ignored.add(parent === "" ? path : path.slice(parent.length + 1));
348
+ }
349
+ return ignored;
350
+ }
351
+ async function queryFileContent(deps, config, root, q) {
352
+ if (!isSafePath(q.path) || hasGitSegment(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
353
+ const file = join(root, q.path);
354
+ const inside = await isInsideRoot(deps, root, file, true);
355
+ if (!inside) return { ok: false, error: { code: "invalid-path", message: q.path } };
356
+ const cap = config.maxBytes;
357
+ let info;
358
+ try {
359
+ info = await deps.fs.stat(file);
360
+ } catch (error) {
361
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "stat failed" } };
362
+ }
363
+ const mime = imageMimeFor(q.path);
364
+ const tooLarge = { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true } };
365
+ if (info.size > cap) return tooLarge;
366
+ let buf;
367
+ try {
368
+ buf = await deps.fs.readFile(file);
369
+ } catch (error) {
370
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "read failed" } };
371
+ }
372
+ if (buf.length > cap) return tooLarge;
373
+ if (mime !== null) {
374
+ return { ok: true, value: { kind: "file-content", path: q.path, variant: "image", dataUrl: `data:${mime};base64,${buf.toString("base64")}` } };
375
+ }
376
+ if (isBinaryBuffer(buf)) return { ok: true, value: { kind: "file-content", path: q.path, variant: "binary" } };
377
+ const content = buf.toString("utf8");
378
+ const lines = content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
379
+ return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines } };
380
+ }
381
+ function hasGitSegment(path) {
382
+ return path.split(/[\\/]/).includes(".git");
383
+ }
384
+ async function isInsideRoot(deps, root, path, hideGit = false) {
385
+ try {
386
+ const real = await deps.fs.realpath(path);
387
+ const rootReal = await deps.fs.realpath(root);
388
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return false;
389
+ return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1));
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+ function isBinaryBuffer(buf) {
395
+ const n = Math.min(buf.length, 8192);
396
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
397
+ return false;
398
+ }
399
+ async function queryShow(deps, root, ref) {
400
+ if (!isSafeRev(ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${ref}` } };
401
+ const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
402
+ const [metaRes, statRes, numstatRes] = await Promise.all([
403
+ runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
404
+ runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
405
+ runCommand(deps.run, ["git", "show", "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
406
+ ]);
407
+ if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
408
+ if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
409
+ if (metaRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
410
+ if (metaRes.run.exitCode !== 0) {
411
+ return { ok: false, error: { code: "git-error", message: metaRes.run.stderr.trim() || "unknown ref" } };
412
+ }
413
+ const parts = metaRes.run.stdout.split("");
414
+ const commit = commitFromFields(parts);
415
+ const body = commit === null ? "" : parts.slice(5).join("").trim();
416
+ const stats = "run" in statRes && statRes.run.exitCode === 0 ? parseNameStatus(statRes.run.stdout) : [];
417
+ const { insertions, deletions } = stats.length > 0 && "run" in numstatRes && numstatRes.run.exitCode === 0 ? sumNumstat(numstatRes.run.stdout) : { insertions: 0, deletions: 0 };
418
+ return { ok: true, value: { kind: "show", ref, commit, body, stats, insertions, deletions } };
419
+ }
420
+ async function queryBranches(deps, root) {
421
+ const fmt = "--format=%(refname:short)%00%(objectname:short)%00%(upstream:track)";
422
+ const [localRes, remoteRes, currentRes, defaultRes] = await Promise.all([
423
+ runCommand(deps.run, ["git", "for-each-ref", "--sort=-committerdate", fmt, "refs/heads"], root, "branches-local", deps.signal),
424
+ runCommand(deps.run, ["git", "for-each-ref", "--sort=-committerdate", fmt, "refs/remotes"], root, "branches-remote", deps.signal),
425
+ runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch-current", deps.signal),
426
+ runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root, "branch-default", deps.signal)
427
+ ]);
428
+ const local = "run" in localRes && localRes.run.exitCode === 0 ? parseBranches(localRes.run.stdout) : [];
429
+ const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parseBranches(remoteRes.run.stdout).filter((b) => !b.name.endsWith("/HEAD")) : [];
430
+ const current = "run" in currentRes && currentRes.run.exitCode === 0 ? currentRes.run.stdout.trim() || null : null;
431
+ let defaultBranch = null;
432
+ if ("run" in defaultRes && defaultRes.run.exitCode === 0) {
433
+ const raw = defaultRes.run.stdout.trim();
434
+ defaultBranch = raw.replace(/^origin\//, "") || null;
435
+ }
436
+ return { ok: true, value: { kind: "branches", current, defaultBranch, local, remote } };
437
+ }
438
+ async function queryTags(deps, root) {
439
+ const res = await runCommand(deps.run, ["git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname:short)", "refs/tags"], root, "tags", deps.signal);
440
+ const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
441
+ return { ok: true, value: { kind: "tags", tags } };
442
+ }
443
+ async function queryStashList(deps, root) {
444
+ const res = await runCommand(deps.run, ["git", "stash", "list", "-z", "--format=%gd%x1f%H%x1f%gs%x1f%cr"], root, "stash-list", deps.signal);
445
+ const entries = "run" in res && res.run.exitCode === 0 ? parseStashList(res.run.stdout) : [];
446
+ return { ok: true, value: { kind: "stash-list", entries } };
447
+ }
448
+ async function queryAuthors(deps, root) {
449
+ const res = await runCommand(deps.run, ["git", "log", ...VISIBLE_REFS, "--format=%an", "--max-count=2000"], root, "authors", deps.signal);
450
+ const authors = "run" in res && res.run.exitCode === 0 ? [...new Set(res.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== ""))].sort((a, b) => a.localeCompare(b)) : [];
451
+ return { ok: true, value: { kind: "authors", authors } };
452
+ }
453
+ async function queryLastCommitMessage(deps, root) {
454
+ const res = await runCommand(deps.run, ["git", "log", "-1", "--format=%B"], root, "last-message", deps.signal);
455
+ const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
456
+ return { ok: true, value: { kind: "last-commit-message", message } };
457
+ }
458
+ async function queryPullPreview(deps, root) {
459
+ const empty = { kind: "pull-preview", hasUpstream: false, commits: 0, files: 0, insertions: 0, deletions: 0 };
460
+ const commitsRes = await runCommand(deps.run, ["git", "rev-list", "--count", "--end-of-options", "HEAD..@{upstream}"], root, "pull-preview-count", deps.signal);
461
+ if (!("run" in commitsRes) || commitsRes.run.exitCode !== 0) return { ok: true, value: empty };
462
+ const commits = Number(commitsRes.run.stdout.trim()) || 0;
463
+ const diffRes = await runCommand(deps.run, ["git", "diff", "--numstat", "--end-of-options", "HEAD", "@{upstream}"], root, "pull-preview-diff", deps.signal);
464
+ let files = 0;
465
+ let insertions = 0;
466
+ let deletions = 0;
467
+ if ("run" in diffRes && diffRes.run.exitCode === 0) {
468
+ files = diffRes.run.stdout.split("\n").filter((l) => l.trim() !== "").length;
469
+ const sum = sumNumstat(diffRes.run.stdout);
470
+ insertions = sum.insertions;
471
+ deletions = sum.deletions;
472
+ }
473
+ return { ok: true, value: { kind: "pull-preview", hasUpstream: true, commits, files, insertions, deletions } };
474
+ }
475
+ async function queryPushPreview(deps, root) {
476
+ const empty = { kind: "push-preview", hasUpstream: false, commits: 0, files: 0, insertions: 0, deletions: 0 };
477
+ const commitsRes = await runCommand(deps.run, ["git", "rev-list", "--count", "--end-of-options", "@{upstream}..HEAD"], root, "push-preview-count", deps.signal);
478
+ if (!("run" in commitsRes) || commitsRes.run.exitCode !== 0) return { ok: true, value: empty };
479
+ const commits = Number(commitsRes.run.stdout.trim()) || 0;
480
+ const diffRes = await runCommand(deps.run, ["git", "diff", "--numstat", "--end-of-options", "@{upstream}", "HEAD"], root, "push-preview-diff", deps.signal);
481
+ let files = 0;
482
+ let insertions = 0;
483
+ let deletions = 0;
484
+ if ("run" in diffRes && diffRes.run.exitCode === 0) {
485
+ files = diffRes.run.stdout.split("\n").filter((l) => l.trim() !== "").length;
486
+ const sum = sumNumstat(diffRes.run.stdout);
487
+ insertions = sum.insertions;
488
+ deletions = sum.deletions;
489
+ }
490
+ return { ok: true, value: { kind: "push-preview", hasUpstream: true, commits, files, insertions, deletions } };
491
+ }
492
+ async function queryWorktreeStats(deps, config, sessionId) {
493
+ const snapshot = await snapshotForSession(deps, config, sessionId);
494
+ if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
495
+ return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
496
+ }
497
+ export {
498
+ runQuery
499
+ };
@@ -7,7 +7,7 @@
7
7
  * icon. Credentials in the URL (`https://user:token@host/…`) are stripped so a
8
8
  * token can never leak into an href the browser renders.
9
9
  */
10
- import type { GitRemote, RemoteHostKind } from './types.ts';
10
+ import type { GitRemote, RemoteHostKind } from './types.js';
11
11
  /** Map a hostname to a known-host kind (self-hosted / unknown → 'other'). */
12
12
  export declare function classifyHost(host: string): RemoteHostKind;
13
13
  /** Build a {@link GitRemote} from a remote name + its configured URL. */
@@ -0,0 +1,48 @@
1
+ function classifyHost(host) {
2
+ const h = host.toLowerCase();
3
+ if (h === "github.com" || h.endsWith(".github.com")) return "github";
4
+ if (h === "gitlab.com" || h.endsWith(".gitlab.com")) return "gitlab";
5
+ if (h === "gitee.com" || h.endsWith(".gitee.com")) return "gitee";
6
+ if (h === "bitbucket.org" || h.endsWith(".bitbucket.org")) return "bitbucket";
7
+ return "other";
8
+ }
9
+ function normalizePath(path) {
10
+ return path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
11
+ }
12
+ function splitHostPath(raw) {
13
+ const url = raw.trim();
14
+ if (url === "") return null;
15
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
16
+ if (scp !== null && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
17
+ const host = scp[1];
18
+ const path = normalizePath(scp[2]);
19
+ return host !== "" && path !== "" ? { host, path } : null;
20
+ }
21
+ const scheme = url.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)\/(.+)$/i);
22
+ if (scheme !== null) {
23
+ let authority = scheme[1];
24
+ const at = authority.lastIndexOf("@");
25
+ if (at >= 0) authority = authority.slice(at + 1);
26
+ const host = authority.replace(/:\d+$/, "");
27
+ const path = normalizePath(scheme[2]);
28
+ return host !== "" && path !== "" ? { host, path } : null;
29
+ }
30
+ return null;
31
+ }
32
+ function parseRemote(name, url) {
33
+ const parsed = splitHostPath(url);
34
+ if (parsed === null) {
35
+ return { name, url, webUrl: null, host: null, hostKind: "other" };
36
+ }
37
+ return {
38
+ name,
39
+ url,
40
+ webUrl: `https://${parsed.host}/${parsed.path}`,
41
+ host: parsed.host,
42
+ hostKind: classifyHost(parsed.host)
43
+ };
44
+ }
45
+ export {
46
+ classifyHost,
47
+ parseRemote
48
+ };
@@ -1,4 +1,4 @@
1
- import type { GitPanelConfig, SnapshotDeps } from './core.ts';
2
- import type { GitSuggestRequest, GitSuggestResult } from './types.ts';
1
+ import type { GitPanelConfig, SnapshotDeps } from './core.js';
2
+ import type { GitSuggestRequest, GitSuggestResult } from './types.js';
3
3
  /** Generate a commit message for the session's uncommitted changes. */
4
4
  export declare function runSuggest(deps: SnapshotDeps, config: GitPanelConfig, request: GitSuggestRequest): Promise<GitSuggestResult>;