@xbzbing/dsh-git-panel 1.6.1 → 1.7.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.
- package/README.en.md +3 -3
- package/README.md +4 -4
- package/lib/client.js +175 -52
- package/lib/host/actions.js +49 -5
- package/lib/host/core.d.ts +12 -2
- package/lib/host/core.js +33 -6
- package/lib/host/fs-deps.d.ts +2 -0
- package/lib/host/fs-deps.js +25 -0
- package/lib/host/history-topology.d.ts +25 -0
- package/lib/host/history-topology.js +55 -0
- package/lib/host/index.d.ts +5 -3
- package/lib/host/index.js +15 -19
- package/lib/host/queries.js +20 -14
- package/lib/host/snapshot-fingerprint.d.ts +3 -0
- package/lib/host/snapshot-fingerprint.js +12 -0
- package/lib/host/suggest.d.ts +33 -1
- package/lib/host/suggest.js +107 -21
- package/lib/host/types.d.ts +64 -8
- package/lib/host/types.js +17 -2
- package/package.json +2 -2
package/lib/host/actions.js
CHANGED
|
@@ -36,10 +36,10 @@ function planAction(action, unborn) {
|
|
|
36
36
|
}
|
|
37
37
|
case "branch-checkout": {
|
|
38
38
|
if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
|
|
39
|
-
let checkout = ["git", "checkout",
|
|
39
|
+
let checkout = ["git", "checkout", action.name];
|
|
40
40
|
if (action.createFrom !== void 0) {
|
|
41
41
|
if (!isSafeBranchName(action.createFrom)) return { error: "invalid-name", message: `unsafe tracking ref: ${action.createFrom}` };
|
|
42
|
-
checkout = ["git", "checkout", "-b", action.name, "--track",
|
|
42
|
+
checkout = ["git", "checkout", "-b", action.name, "--track", action.createFrom];
|
|
43
43
|
}
|
|
44
44
|
const msg = action.stashMessage?.trim() ?? "";
|
|
45
45
|
return { argv: msg === "" ? [checkout] : [["git", "stash", "push", "-u", "-m", msg], checkout] };
|
|
@@ -85,7 +85,7 @@ function planAction(action, unborn) {
|
|
|
85
85
|
if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
|
|
86
86
|
return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
|
|
87
87
|
}
|
|
88
|
-
return { argv: [["git", "reset", `--${action.mode}`,
|
|
88
|
+
return { argv: [["git", "reset", `--${action.mode}`, action.commit]] };
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -211,6 +211,26 @@ async function runAction(deps, config, request) {
|
|
|
211
211
|
}
|
|
212
212
|
return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
|
|
213
213
|
}
|
|
214
|
+
async function looksLikeRepository(deps, target) {
|
|
215
|
+
try {
|
|
216
|
+
await deps.fs.stat(join(target, ".git"));
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
const head = await pathKind(deps, join(target, "HEAD"));
|
|
221
|
+
const refs = await pathKind(deps, join(target, "refs"));
|
|
222
|
+
const objects = await pathKind(deps, join(target, "objects"));
|
|
223
|
+
if (!head.present || head.directory === true) return false;
|
|
224
|
+
const directoryLike = (kind) => kind.present && kind.directory !== false;
|
|
225
|
+
return directoryLike(refs) && directoryLike(objects);
|
|
226
|
+
}
|
|
227
|
+
async function pathKind(deps, path) {
|
|
228
|
+
try {
|
|
229
|
+
return { present: true, directory: (await deps.fs.stat(path)).isDirectory() };
|
|
230
|
+
} catch {
|
|
231
|
+
return { present: await deps.fs.symlinkTarget(path) !== null, directory: null };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
214
234
|
async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
215
235
|
const snap = await snapshotForSession(deps, config, sessionId);
|
|
216
236
|
if (!snap.ok) return null;
|
|
@@ -218,12 +238,20 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
|
218
238
|
const untracked = paths.filter((p) => untrackedSet.has(p));
|
|
219
239
|
if (untracked.length === 0) return null;
|
|
220
240
|
const tracked = paths.filter((p) => !untrackedSet.has(p));
|
|
241
|
+
const directoryEntries = new Set(snap.value.changes.filter((c) => c.isDirectory === true).map((c) => c.path));
|
|
242
|
+
for (const path of untracked) {
|
|
243
|
+
if (!directoryEntries.has(path)) continue;
|
|
244
|
+
if (!await looksLikeRepository(deps, join(root, path))) continue;
|
|
245
|
+
return { ok: false, result: { ok: false, error: { code: "nested-repo", message: `repository directory: ${path}` } } };
|
|
246
|
+
}
|
|
221
247
|
let rootReal;
|
|
222
248
|
try {
|
|
223
249
|
rootReal = await deps.fs.realpath(root);
|
|
224
250
|
} catch {
|
|
225
251
|
return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
|
|
226
252
|
}
|
|
253
|
+
const failures = [];
|
|
254
|
+
let removedCount = 0;
|
|
227
255
|
for (const path of untracked) {
|
|
228
256
|
if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
|
|
229
257
|
const target = join(root, path);
|
|
@@ -236,8 +264,24 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
|
236
264
|
if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
|
|
237
265
|
return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
|
|
238
266
|
}
|
|
239
|
-
await deps
|
|
240
|
-
|
|
267
|
+
if (await looksLikeRepository(deps, target)) {
|
|
268
|
+
const code = removedCount === 0 ? "nested-repo" : "nested-repo-partial";
|
|
269
|
+
const detail = removedCount === 0 ? "" : `; ${removedCount} path(s) already removed`;
|
|
270
|
+
return { ok: false, result: { ok: false, error: { code, message: `repository directory: ${path}${detail}` } } };
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await deps.fs.remove(target);
|
|
274
|
+
removedCount += 1;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
failures.push(`${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (failures.length > 0) {
|
|
280
|
+
const skipped = tracked.length > 0 ? `; ${tracked.length} tracked path(s) not restored` : "";
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
result: { ok: false, error: { code: "git-error", message: `could not discard ${failures.length} path(s): ${failures.join("; ")}${skipped}` } }
|
|
284
|
+
};
|
|
241
285
|
}
|
|
242
286
|
return { ok: true, remainingTracked: tracked };
|
|
243
287
|
}
|
package/lib/host/core.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GitRunner } from './git.js';
|
|
2
2
|
import type { AgentDefaultModelFace, LlmFace } from './llm-face.js';
|
|
3
|
-
import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.js';
|
|
3
|
+
import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult, SuggestLanguage } 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,6 +15,8 @@ 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
|
+
/** Language the generated message is written in; `auto` follows repo history. */
|
|
19
|
+
readonly suggestLanguage: SuggestLanguage;
|
|
18
20
|
/** Max diff bytes sent to the model; beyond this the diff is truncated. Range 16–512 KiB. */
|
|
19
21
|
readonly suggestMaxBytes: number;
|
|
20
22
|
/** Model output token budget (reasoning tokens count against it on thinking models). Range 256–16384. */
|
|
@@ -31,6 +33,12 @@ export declare function normalizeConfig(raw: unknown): GitPanelConfig;
|
|
|
31
33
|
export declare function readDiffView(value: unknown, fallback: DiffViewMode): DiffViewMode;
|
|
32
34
|
/** Read the commit-graph style field, unwrapping a schemastery volatile ref. */
|
|
33
35
|
export declare function readGraphStyle(value: unknown, fallback: GraphStyle): GraphStyle;
|
|
36
|
+
/**
|
|
37
|
+
* Read the commit-message language field, unwrapping a schemastery volatile
|
|
38
|
+
* ref. An unknown string (a hand-edited profile, or a value from a newer
|
|
39
|
+
* version) falls back to the default rather than reaching the prompt.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readSuggestLanguage(value: unknown, fallback: SuggestLanguage): SuggestLanguage;
|
|
34
42
|
/**
|
|
35
43
|
* Read a boolean config field, unwrapping a schemastery volatile reference
|
|
36
44
|
* (`{ get() }`) so a live-editable toggle reflects the latest value. Absent or
|
|
@@ -42,9 +50,11 @@ export interface SnapshotDeps {
|
|
|
42
50
|
readonly run: GitRunner;
|
|
43
51
|
readonly fs: {
|
|
44
52
|
realpath(path: string): Promise<string>;
|
|
53
|
+
/** `isDirectory` reports the *resolved* type (symlinks are followed). */
|
|
45
54
|
stat(path: string): Promise<{
|
|
46
55
|
mtimeMs: number;
|
|
47
56
|
size: number;
|
|
57
|
+
isDirectory(): boolean;
|
|
48
58
|
}>;
|
|
49
59
|
/** Raw bytes (no encoding) — image sides for the image-diff query. */
|
|
50
60
|
readFile(path: string): Promise<Buffer>;
|
|
@@ -59,7 +69,7 @@ export interface SnapshotDeps {
|
|
|
59
69
|
name: string;
|
|
60
70
|
isDirectory: boolean;
|
|
61
71
|
}>>;
|
|
62
|
-
/**
|
|
72
|
+
/** Recursive remove (force) — untracked discards pass whole directories; temp-blob cleanup passes files. */
|
|
63
73
|
remove(path: string): Promise<void>;
|
|
64
74
|
};
|
|
65
75
|
readonly sessions: {
|
package/lib/host/core.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
|
|
3
3
|
import { parseRemote } from "./remote.js";
|
|
4
|
+
import { snapshotFingerprint } from "./snapshot-fingerprint.js";
|
|
4
5
|
import { isSafePath } from "./validate.js";
|
|
5
|
-
import { SUGGEST_LIMITS } from "./types.js";
|
|
6
|
+
import { SUGGEST_LANGUAGE_DEFAULT, SUGGEST_LANGUAGES, SUGGEST_LIMITS } from "./types.js";
|
|
6
7
|
const DEFAULT_CONFIG = {
|
|
7
8
|
timeoutMs: 8e3,
|
|
8
9
|
maxBytes: 4 * 1024 * 1024,
|
|
@@ -12,6 +13,7 @@ const DEFAULT_CONFIG = {
|
|
|
12
13
|
defaultDiffView: "unified",
|
|
13
14
|
graphStyle: "parallel",
|
|
14
15
|
suggestEnabled: true,
|
|
16
|
+
suggestLanguage: SUGGEST_LANGUAGE_DEFAULT,
|
|
15
17
|
suggestMaxBytes: SUGGEST_LIMITS.maxBytes.default,
|
|
16
18
|
suggestMaxOutputTokens: SUGGEST_LIMITS.maxOutputTokens.default,
|
|
17
19
|
suggestTimeoutMs: SUGGEST_LIMITS.timeoutMs.default
|
|
@@ -35,6 +37,7 @@ function normalizeConfig(raw) {
|
|
|
35
37
|
defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
|
|
36
38
|
graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
|
|
37
39
|
suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
|
|
40
|
+
suggestLanguage: readSuggestLanguage(c.suggestLanguage, DEFAULT_CONFIG.suggestLanguage),
|
|
38
41
|
suggestMaxBytes: clampLimit(c.suggestMaxBytes, SUGGEST_LIMITS.maxBytes),
|
|
39
42
|
suggestMaxOutputTokens: clampLimit(c.suggestMaxOutputTokens, SUGGEST_LIMITS.maxOutputTokens),
|
|
40
43
|
suggestTimeoutMs: clampLimit(c.suggestTimeoutMs, SUGGEST_LIMITS.timeoutMs),
|
|
@@ -52,6 +55,10 @@ function readGraphStyle(value, fallback) {
|
|
|
52
55
|
const raw = unwrapVolatile(value);
|
|
53
56
|
return raw === "parallel" || raw === "compact" ? raw : fallback;
|
|
54
57
|
}
|
|
58
|
+
function readSuggestLanguage(value, fallback) {
|
|
59
|
+
const raw = unwrapVolatile(value);
|
|
60
|
+
return typeof raw === "string" && SUGGEST_LANGUAGES.includes(raw) ? raw : fallback;
|
|
61
|
+
}
|
|
55
62
|
function readBool(value, fallback) {
|
|
56
63
|
const raw = unwrapVolatile(value);
|
|
57
64
|
return typeof raw === "boolean" ? raw : fallback;
|
|
@@ -74,6 +81,7 @@ function parsePrimaryRemote(stdout) {
|
|
|
74
81
|
return parseRemote(name, fetchUrls.get(name));
|
|
75
82
|
}
|
|
76
83
|
const STATS_FANOUT_LIMIT = 200;
|
|
84
|
+
const UNTRACKED_NUMSTAT_LIMIT = 32;
|
|
77
85
|
async function resolveBrowseRoot(deps, sessionId) {
|
|
78
86
|
const ws = await resolveWorkspace(deps, sessionId);
|
|
79
87
|
if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
|
|
@@ -180,7 +188,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
180
188
|
const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
|
|
181
189
|
runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
|
|
182
190
|
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),
|
|
191
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "status", deps.signal),
|
|
184
192
|
runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
|
|
185
193
|
runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
|
|
186
194
|
runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
|
|
@@ -198,7 +206,20 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
198
206
|
allChanges = parseStatus(statusRes.run.stdout);
|
|
199
207
|
statusLossy = statusRes.run.stdoutLossy;
|
|
200
208
|
}
|
|
209
|
+
let untrackedCollapsed = false;
|
|
210
|
+
const untrackedEntries = allChanges.filter((c) => c.status === "untracked");
|
|
211
|
+
const collapseCandidate = untrackedEntries.some((c) => c.path.includes("/"));
|
|
212
|
+
if (statusLossy || untrackedEntries.length > config.maxChanges && collapseCandidate) {
|
|
213
|
+
const collapsed = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=normal"], root, "status-collapsed", deps.signal);
|
|
214
|
+
if ("run" in collapsed && !collapsed.run.timedOut && collapsed.run.exitCode === 0) {
|
|
215
|
+
const list = parseStatus(collapsed.run.stdout);
|
|
216
|
+
untrackedCollapsed = list.some((c) => c.status === "untracked" && c.isDirectory);
|
|
217
|
+
allChanges = list;
|
|
218
|
+
statusLossy = collapsed.run.stdoutLossy;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
201
221
|
const truncated = allChanges.length > config.maxChanges || statusLossy;
|
|
222
|
+
const truncatedReason = statusLossy ? "lossy" : allChanges.length > config.maxChanges ? "cap" : void 0;
|
|
202
223
|
const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
|
|
203
224
|
let staged = 0;
|
|
204
225
|
let modified = 0;
|
|
@@ -224,9 +245,10 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
224
245
|
const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
225
246
|
const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
226
247
|
const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
|
|
248
|
+
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
249
|
+
const untrackedCountable = untrackedPaths.length <= UNTRACKED_NUMSTAT_LIMIT;
|
|
227
250
|
let untrackedInsertions = 0;
|
|
228
|
-
if (
|
|
229
|
-
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
251
|
+
if (untrackedCountable) {
|
|
230
252
|
const safeUntracked = untrackedPaths.filter(isSafePath);
|
|
231
253
|
if (safeUntracked.length > 0) {
|
|
232
254
|
const perFile = await Promise.all(
|
|
@@ -246,7 +268,9 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
246
268
|
deletions: wt.deletions + stg.deletions,
|
|
247
269
|
lastChangeAt,
|
|
248
270
|
headCommittedAt: lastCommit?.dateIso ?? null,
|
|
249
|
-
|
|
271
|
+
// Collapsed untracked directories have no per-file line counts and count as
|
|
272
|
+
// one entry each, so the stats are an explicit lower bound there too.
|
|
273
|
+
partial: !statsDetailed || !untrackedCountable || untrackedCollapsed
|
|
250
274
|
};
|
|
251
275
|
const snapshot = {
|
|
252
276
|
root,
|
|
@@ -265,6 +289,8 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
265
289
|
changes,
|
|
266
290
|
stats,
|
|
267
291
|
truncated,
|
|
292
|
+
...truncatedReason !== void 0 ? { truncatedReason } : {},
|
|
293
|
+
...untrackedCollapsed ? { untrackedCollapsed: true } : {},
|
|
268
294
|
refreshIntervalMs: config.refreshIntervalMs,
|
|
269
295
|
showInputPill: config.showInputPill,
|
|
270
296
|
defaultDiffView: config.defaultDiffView,
|
|
@@ -272,7 +298,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
272
298
|
suggestEnabled: config.suggestEnabled,
|
|
273
299
|
checkedAt: Date.now()
|
|
274
300
|
};
|
|
275
|
-
return { ok: true, value: snapshot };
|
|
301
|
+
return { ok: true, value: { ...snapshot, stateFingerprint: snapshotFingerprint(snapshot) } };
|
|
276
302
|
}
|
|
277
303
|
async function maxChangeMtime(deps, root, changes, cap = 200) {
|
|
278
304
|
let max = null;
|
|
@@ -298,6 +324,7 @@ export {
|
|
|
298
324
|
readBool,
|
|
299
325
|
readDiffView,
|
|
300
326
|
readGraphStyle,
|
|
327
|
+
readSuggestLanguage,
|
|
301
328
|
resolveBrowseRoot,
|
|
302
329
|
resolveWorkspace,
|
|
303
330
|
runCommand,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
function createFsDeps() {
|
|
3
|
+
return {
|
|
4
|
+
realpath,
|
|
5
|
+
stat: async (p) => stat(p),
|
|
6
|
+
readFile,
|
|
7
|
+
symlinkTarget: async (p) => {
|
|
8
|
+
try {
|
|
9
|
+
return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
|
|
15
|
+
// Untracked entries include whole directories (porcelain collapses `?? dir/`
|
|
16
|
+
// into one entry) and a plain `rm` fails EISDIR on them; the caller
|
|
17
|
+
// realpath-contains every target inside the repo root before removing.
|
|
18
|
+
remove: async (p) => {
|
|
19
|
+
await rm(p, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
createFsDeps
|
|
25
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GitRunner } from './git.js';
|
|
2
|
+
import type { GitGraphNode } from './types.js';
|
|
3
|
+
/** Hard cap on the auxiliary walk: deep enough for a filtered page, bounded
|
|
4
|
+
* against a pathological DAG. */
|
|
5
|
+
export declare const HISTORY_TOPOLOGY_LIMIT = 10000;
|
|
6
|
+
/** Hard cap on the walk's stdout. The node cap alone does not bound a line: an
|
|
7
|
+
* octopus merge can list thousands of parents on one commit. Over budget the
|
|
8
|
+
* result is conservatively `[]` (an incomplete graph), never a partial parse. */
|
|
9
|
+
export declare const HISTORY_TOPOLOGY_MAX_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* Parse `git rev-list --parents` output: one `<hash> [<parent>…]` per line.
|
|
12
|
+
* Returns null whenever the capture cannot be trusted as a complete ancestry —
|
|
13
|
+
* a missing trailing newline (git always terminates each line, so a cut read can
|
|
14
|
+
* land exactly on a 40-char hash and otherwise read as a parentless root), any
|
|
15
|
+
* malformed line, or output past the node/byte budget. `[]` is a genuinely empty
|
|
16
|
+
* walk, never a degraded one.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseGraphNodes(stdout: string): GitGraphNode[] | null;
|
|
19
|
+
/**
|
|
20
|
+
* Walk the page's raw ancestry. Seeds must already be full hashes; anything
|
|
21
|
+
* else is dropped rather than passed as argv. Resolves `[]` on every failure
|
|
22
|
+
* path (spawn throw, cancel, timeout, non-zero exit, lossy output, malformed
|
|
23
|
+
* output) so a filtered history page is never lost to this auxiliary command.
|
|
24
|
+
*/
|
|
25
|
+
export declare function walkHistoryTopology(runner: GitRunner, root: string, seeds: readonly string[], signal?: AbortSignal): Promise<GitGraphNode[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runCommand } from "./core.js";
|
|
2
|
+
const HISTORY_TOPOLOGY_LIMIT = 1e4;
|
|
3
|
+
const HISTORY_TOPOLOGY_MAX_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
const FULL_HASH = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;
|
|
5
|
+
function parseGraphNodes(stdout) {
|
|
6
|
+
if (stdout === "") return [];
|
|
7
|
+
if (Buffer.byteLength(stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return null;
|
|
8
|
+
if (!stdout.endsWith("\n")) return null;
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const raw of stdout.split("\n")) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (line !== "") lines.push(line);
|
|
13
|
+
}
|
|
14
|
+
if (lines.length > HISTORY_TOPOLOGY_LIMIT) return null;
|
|
15
|
+
const nodes = [];
|
|
16
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const fields = line.split(" ");
|
|
19
|
+
const hash = fields[0];
|
|
20
|
+
if (hash === void 0 || !FULL_HASH.test(hash)) return null;
|
|
21
|
+
const parents = [];
|
|
22
|
+
for (const parent of fields.slice(1)) {
|
|
23
|
+
if (!FULL_HASH.test(parent)) return null;
|
|
24
|
+
parents.push(parent);
|
|
25
|
+
}
|
|
26
|
+
if (seen.has(hash)) continue;
|
|
27
|
+
seen.add(hash);
|
|
28
|
+
nodes.push({ hash, parents });
|
|
29
|
+
}
|
|
30
|
+
return nodes;
|
|
31
|
+
}
|
|
32
|
+
async function walkHistoryTopology(runner, root, seeds, signal) {
|
|
33
|
+
const safeSeeds = seeds.filter((seed) => FULL_HASH.test(seed));
|
|
34
|
+
if (safeSeeds.length === 0) return [];
|
|
35
|
+
const argv = [
|
|
36
|
+
"git",
|
|
37
|
+
"rev-list",
|
|
38
|
+
"--parents",
|
|
39
|
+
`--max-count=${HISTORY_TOPOLOGY_LIMIT}`,
|
|
40
|
+
"--end-of-options",
|
|
41
|
+
...safeSeeds
|
|
42
|
+
];
|
|
43
|
+
const res = await runCommand(runner, argv, root, "history-topology", signal);
|
|
44
|
+
if (!("run" in res)) return [];
|
|
45
|
+
const { run } = res;
|
|
46
|
+
if (run.cancelled || run.timedOut || run.exitCode !== 0 || run.stdoutLossy) return [];
|
|
47
|
+
if (Buffer.byteLength(run.stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return [];
|
|
48
|
+
return parseGraphNodes(run.stdout) ?? [];
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
HISTORY_TOPOLOGY_LIMIT,
|
|
52
|
+
HISTORY_TOPOLOGY_MAX_BYTES,
|
|
53
|
+
parseGraphNodes,
|
|
54
|
+
walkHistoryTopology
|
|
55
|
+
};
|
package/lib/host/index.d.ts
CHANGED
|
@@ -10,14 +10,14 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import Schema from '@deepseek-ai/schemastery';
|
|
12
12
|
import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.js';
|
|
13
|
-
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, } from './types.js';
|
|
14
|
-
export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote } from './core.js';
|
|
13
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, SuggestLanguage, ResolvedLanguage, } from './types.js';
|
|
14
|
+
export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote, readSuggestLanguage } from './core.js';
|
|
15
15
|
export { createGitRunner } from './git.js';
|
|
16
16
|
export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.js';
|
|
17
17
|
export { parseRemote, classifyHost } from './remote.js';
|
|
18
18
|
export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.js';
|
|
19
19
|
export { runQuery } from './queries.js';
|
|
20
|
-
export { runSuggest } from './suggest.js';
|
|
20
|
+
export { runSuggest, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from './suggest.js';
|
|
21
21
|
export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.js';
|
|
22
22
|
export declare class GitPanelService extends TypertRemoteService {
|
|
23
23
|
static inject: string[];
|
|
@@ -32,6 +32,7 @@ export declare class GitPanelService extends TypertRemoteService {
|
|
|
32
32
|
defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
|
|
33
33
|
graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
|
|
34
34
|
suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
|
|
35
|
+
suggestLanguage: Schema<"auto" | "zh" | "en", "auto" | "zh" | "en", "volatile-defined">;
|
|
35
36
|
suggestMaxBytes: Schema<number, number, "volatile-defined">;
|
|
36
37
|
suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
|
|
37
38
|
suggestTimeoutMs: Schema<number, number, "volatile-defined">;
|
|
@@ -40,6 +41,7 @@ export declare class GitPanelService extends TypertRemoteService {
|
|
|
40
41
|
defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
|
|
41
42
|
graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
|
|
42
43
|
suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
|
|
44
|
+
suggestLanguage: Schema<"auto" | "zh" | "en", "auto" | "zh" | "en", "volatile-defined">;
|
|
43
45
|
suggestMaxBytes: Schema<number, number, "volatile-defined">;
|
|
44
46
|
suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
|
|
45
47
|
suggestTimeoutMs: Schema<number, number, "volatile-defined">;
|
package/lib/host/index.js
CHANGED
|
@@ -49,20 +49,20 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
|
|
|
49
49
|
var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
|
|
50
50
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
51
51
|
import Schema from "@deepseek-ai/schemastery";
|
|
52
|
-
import { lstat, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
53
52
|
import { createGitRunner } from "./git.js";
|
|
53
|
+
import { createFsDeps } from "./fs-deps.js";
|
|
54
54
|
import { normalizeConfig, snapshotForSession, DEFAULT_CONFIG } from "./core.js";
|
|
55
55
|
import { runAction } from "./actions.js";
|
|
56
56
|
import { runQuery } from "./queries.js";
|
|
57
57
|
import { runSuggest } from "./suggest.js";
|
|
58
58
|
import { checkLatestVersion, readVersionInfo } from "./version.js";
|
|
59
|
-
import { normalizeConfig as normalizeConfig2, DEFAULT_CONFIG as DEFAULT_CONFIG2, snapshotForSession as snapshotForSession2, resolveWorkspace, parsePrimaryRemote } from "./core.js";
|
|
59
|
+
import { normalizeConfig as normalizeConfig2, DEFAULT_CONFIG as DEFAULT_CONFIG2, snapshotForSession as snapshotForSession2, resolveWorkspace, parsePrimaryRemote, readSuggestLanguage } from "./core.js";
|
|
60
60
|
import { createGitRunner as createGitRunner2 } from "./git.js";
|
|
61
61
|
import { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
|
|
62
62
|
import { parseRemote, classifyHost } from "./remote.js";
|
|
63
63
|
import { isSafePath, planAction, runAction as runAction2, classifyActionFailure } from "./actions.js";
|
|
64
64
|
import { runQuery as runQuery2 } from "./queries.js";
|
|
65
|
-
import { runSuggest as runSuggest2 } from "./suggest.js";
|
|
65
|
+
import { runSuggest as runSuggest2, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from "./suggest.js";
|
|
66
66
|
import { readVersionInfo as readVersionInfo2, checkLatestVersion as checkLatestVersion2, compareVersions, parseRepository } from "./version.js";
|
|
67
67
|
class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _suggest_dec = [Remote("suggest")], _version_dec = [Remote("version")], _a) {
|
|
68
68
|
constructor(ctx, config) {
|
|
@@ -79,22 +79,7 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
|
|
|
79
79
|
const rootCache = /* @__PURE__ */ new Map();
|
|
80
80
|
const rootNegCache = /* @__PURE__ */ new Map();
|
|
81
81
|
const get = (key) => ctx.get(key);
|
|
82
|
-
const fs =
|
|
83
|
-
realpath,
|
|
84
|
-
stat: async (p) => stat(p),
|
|
85
|
-
readFile,
|
|
86
|
-
symlinkTarget: async (p) => {
|
|
87
|
-
try {
|
|
88
|
-
return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
|
|
94
|
-
remove: async (p) => {
|
|
95
|
-
await rm(p, { force: true });
|
|
96
|
-
}
|
|
97
|
-
};
|
|
82
|
+
const fs = createFsDeps();
|
|
98
83
|
const subprocess = get("subprocess");
|
|
99
84
|
const run = subprocess === void 0 ? { run: async () => {
|
|
100
85
|
throw new Error("subprocess service unavailable");
|
|
@@ -180,6 +165,11 @@ __publicField(GitPanelService, "Config", Schema.object({
|
|
|
180
165
|
Schema.const("compact").description("\u7D27\u51D1\uFF08git log \u98CE\u683C\uFF0C\u5171\u4EAB\u7956\u5148\u5408\u5E76\u4E3A\u4E00\u5217\uFF09")
|
|
181
166
|
]).default("parallel").volatile().description("\u63D0\u4EA4\u5386\u53F2\u56FE\u7EBF\u6761\u6837\u5F0F"),
|
|
182
167
|
suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE"),
|
|
168
|
+
suggestLanguage: Schema.union([
|
|
169
|
+
Schema.const("auto").description("\u81EA\u52A8\u8DDF\u968F\u4ED3\u5E93\u5386\u53F2\u63D0\u4EA4\u7684\u8BED\u8A00"),
|
|
170
|
+
Schema.const("zh").description("\u4E2D\u6587"),
|
|
171
|
+
Schema.const("en").description("English")
|
|
172
|
+
]).default(DEFAULT_CONFIG.suggestLanguage).volatile().description("AI \u751F\u6210\u63D0\u4EA4\u4FE1\u606F\u7684\u8BED\u8A00"),
|
|
183
173
|
// Defaults come from DEFAULT_CONFIG (which itself reads SUGGEST_LIMITS) —
|
|
184
174
|
// a literal here could silently drift from what normalizeConfig enforces.
|
|
185
175
|
suggestMaxBytes: Schema.number().default(DEFAULT_CONFIG.suggestMaxBytes).volatile().description("AI \u751F\u6210\u8F93\u5165\u9884\u7B97\uFF08diff \u5B57\u8282\u4E0A\u9650\uFF0C16\u2013512 KiB\uFF09"),
|
|
@@ -190,12 +180,15 @@ var index_default = GitPanelService;
|
|
|
190
180
|
export {
|
|
191
181
|
DEFAULT_CONFIG2 as DEFAULT_CONFIG,
|
|
192
182
|
GitPanelService,
|
|
183
|
+
LONG_LINE_MAX_CHARS,
|
|
193
184
|
checkLatestVersion2 as checkLatestVersion,
|
|
194
185
|
classifyActionFailure,
|
|
195
186
|
classifyHost,
|
|
187
|
+
compactLongLines,
|
|
196
188
|
compareVersions,
|
|
197
189
|
createGitRunner2 as createGitRunner,
|
|
198
190
|
index_default as default,
|
|
191
|
+
isCjkText,
|
|
199
192
|
isSafePath,
|
|
200
193
|
normalizeConfig2 as normalizeConfig,
|
|
201
194
|
parseBranches,
|
|
@@ -206,9 +199,12 @@ export {
|
|
|
206
199
|
parseRepository,
|
|
207
200
|
parseStashList,
|
|
208
201
|
parseStatus,
|
|
202
|
+
parseStyleHistory,
|
|
209
203
|
parseTags,
|
|
210
204
|
planAction,
|
|
205
|
+
readSuggestLanguage,
|
|
211
206
|
readVersionInfo2 as readVersionInfo,
|
|
207
|
+
resolveSuggestLanguage,
|
|
212
208
|
resolveWorkspace,
|
|
213
209
|
runAction2 as runAction,
|
|
214
210
|
runQuery2 as runQuery,
|
package/lib/host/queries.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { join, sep } from "node:path";
|
|
2
2
|
import { mapWorkspaceFailure, resolveBrowseRoot, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
|
|
3
3
|
import { isSafePath, isSafeRev } from "./validate.js";
|
|
4
|
+
import { walkHistoryTopology } from "./history-topology.js";
|
|
4
5
|
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
|
+
import { imageMimeFor, isHistoryHashSearch, isHistoryFiltered, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX, FILE_COPY_MAX_BYTES } from "./types.js";
|
|
6
7
|
const GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
|
|
8
|
+
const FIRST_PARENT_DIFF = "--diff-merges=first-parent";
|
|
7
9
|
const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
|
|
8
|
-
function isHexLike(text) {
|
|
9
|
-
return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
|
|
10
|
-
}
|
|
11
10
|
async function runQuery(deps, config, request) {
|
|
12
11
|
const q = request.query;
|
|
13
12
|
if (q.kind === "dir-list" || q.kind === "file-content") {
|
|
@@ -61,13 +60,19 @@ async function queryHistory(deps, root, q) {
|
|
|
61
60
|
const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
|
|
62
61
|
const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
|
|
63
62
|
const search = q.search?.trim() ?? "";
|
|
64
|
-
const hexJump =
|
|
63
|
+
const hexJump = isHistoryHashSearch(search);
|
|
64
|
+
const filtered = isHistoryFiltered(q);
|
|
65
|
+
const author = q.author ?? "";
|
|
66
|
+
const since = q.since ?? "";
|
|
67
|
+
const searchFilter = search !== "";
|
|
68
|
+
const authorFilter = author !== "";
|
|
69
|
+
const sinceFilter = since !== "";
|
|
65
70
|
const countArgs = ["git", "rev-list", "--count"];
|
|
66
71
|
if (!hexJump) {
|
|
67
72
|
const filters = [];
|
|
68
|
-
if (
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
73
|
+
if (searchFilter) filters.push("-i", "-E", `--grep=${search}`);
|
|
74
|
+
if (authorFilter) filters.push(`--author=${author}`);
|
|
75
|
+
if (sinceFilter) filters.push(`--since=${since}`);
|
|
71
76
|
if (q.ref !== void 0 && q.ref !== "") {
|
|
72
77
|
if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
|
|
73
78
|
filters.push("--end-of-options", q.ref);
|
|
@@ -89,7 +94,7 @@ async function queryHistory(deps, root, q) {
|
|
|
89
94
|
if (res.run.exitCode !== 0) {
|
|
90
95
|
const stderr = res.run.stderr.trim();
|
|
91
96
|
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 } };
|
|
97
|
+
return { ok: true, value: { kind: "history", commits: [], total: 0, ...filtered ? { topology: [] } : {} } };
|
|
93
98
|
}
|
|
94
99
|
return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
|
|
95
100
|
}
|
|
@@ -99,7 +104,8 @@ async function queryHistory(deps, root, q) {
|
|
|
99
104
|
const n = Number(countRes.run.stdout.trim());
|
|
100
105
|
if (Number.isFinite(n)) total = n;
|
|
101
106
|
}
|
|
102
|
-
|
|
107
|
+
const topology = filtered ? commits.length === 0 ? [] : await walkHistoryTopology(deps.run, root, commits.map((c) => c.hash), deps.signal) : void 0;
|
|
108
|
+
return { ok: true, value: { kind: "history", commits, total, ...topology !== void 0 ? { topology } : {} } };
|
|
103
109
|
}
|
|
104
110
|
async function queryDiff(deps, root, q) {
|
|
105
111
|
if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
|
|
@@ -110,7 +116,7 @@ async function queryDiff(deps, root, q) {
|
|
|
110
116
|
args = ["git", "diff", unified, "--cached", "--", q.path];
|
|
111
117
|
} else if (q.base === "commit") {
|
|
112
118
|
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];
|
|
119
|
+
args = ["git", "show", unified, FIRST_PARENT_DIFF, "--end-of-options", q.commit, "--", q.path];
|
|
114
120
|
} else {
|
|
115
121
|
args = ["git", "diff", unified, "--", q.path];
|
|
116
122
|
}
|
|
@@ -353,7 +359,6 @@ async function queryFileContent(deps, config, root, q) {
|
|
|
353
359
|
const file = join(root, q.path);
|
|
354
360
|
const inside = await isInsideRoot(deps, root, file, true);
|
|
355
361
|
if (!inside) return { ok: false, error: { code: "invalid-path", message: q.path } };
|
|
356
|
-
const cap = config.maxBytes;
|
|
357
362
|
let info;
|
|
358
363
|
try {
|
|
359
364
|
info = await deps.fs.stat(file);
|
|
@@ -361,6 +366,7 @@ async function queryFileContent(deps, config, root, q) {
|
|
|
361
366
|
return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "stat failed" } };
|
|
362
367
|
}
|
|
363
368
|
const mime = imageMimeFor(q.path);
|
|
369
|
+
const cap = mime !== null ? config.maxBytes : Math.max(config.maxBytes, FILE_COPY_MAX_BYTES);
|
|
364
370
|
const statFields = { size: info.size, mtimeMs: Math.round(info.mtimeMs) };
|
|
365
371
|
const tooLarge = { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true, ...statFields } };
|
|
366
372
|
if (info.size > cap) return tooLarge;
|
|
@@ -402,8 +408,8 @@ async function queryShow(deps, root, ref) {
|
|
|
402
408
|
const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
|
|
403
409
|
const [metaRes, statRes, numstatRes] = await Promise.all([
|
|
404
410
|
runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
|
|
405
|
-
runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
|
|
406
|
-
runCommand(deps.run, ["git", "show", "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
|
|
411
|
+
runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
|
|
412
|
+
runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
|
|
407
413
|
]);
|
|
408
414
|
if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
|
|
409
415
|
if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
function snapshotFingerprint(snapshot) {
|
|
3
|
+
const { checkedAt: _checkedAt, stateFingerprint: _stateFingerprint, ...state } = snapshot;
|
|
4
|
+
const encoded = JSON.stringify(state, (_key, value) => {
|
|
5
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
6
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
7
|
+
});
|
|
8
|
+
return createHash("sha256").update(encoded).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
snapshotFingerprint
|
|
12
|
+
};
|