@xbzbing/dsh-git-panel 1.6.2 → 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.md +1 -1
- package/lib/client.js +170 -52
- package/lib/host/actions.js +49 -5
- package/lib/host/core.d.ts +3 -1
- package/lib/host/core.js +25 -5
- 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 +1 -1
- package/lib/host/index.js +5 -18
- 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 +13 -0
- package/lib/host/suggest.js +30 -6
- package/lib/host/types.d.ts +45 -4
- package/lib/host/types.js +13 -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
|
@@ -50,9 +50,11 @@ export interface SnapshotDeps {
|
|
|
50
50
|
readonly run: GitRunner;
|
|
51
51
|
readonly fs: {
|
|
52
52
|
realpath(path: string): Promise<string>;
|
|
53
|
+
/** `isDirectory` reports the *resolved* type (symlinks are followed). */
|
|
53
54
|
stat(path: string): Promise<{
|
|
54
55
|
mtimeMs: number;
|
|
55
56
|
size: number;
|
|
57
|
+
isDirectory(): boolean;
|
|
56
58
|
}>;
|
|
57
59
|
/** Raw bytes (no encoding) — image sides for the image-diff query. */
|
|
58
60
|
readFile(path: string): Promise<Buffer>;
|
|
@@ -67,7 +69,7 @@ export interface SnapshotDeps {
|
|
|
67
69
|
name: string;
|
|
68
70
|
isDirectory: boolean;
|
|
69
71
|
}>>;
|
|
70
|
-
/**
|
|
72
|
+
/** Recursive remove (force) — untracked discards pass whole directories; temp-blob cleanup passes files. */
|
|
71
73
|
remove(path: string): Promise<void>;
|
|
72
74
|
};
|
|
73
75
|
readonly sessions: {
|
package/lib/host/core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
6
|
import { SUGGEST_LANGUAGE_DEFAULT, SUGGEST_LANGUAGES, SUGGEST_LIMITS } from "./types.js";
|
|
6
7
|
const DEFAULT_CONFIG = {
|
|
@@ -80,6 +81,7 @@ function parsePrimaryRemote(stdout) {
|
|
|
80
81
|
return parseRemote(name, fetchUrls.get(name));
|
|
81
82
|
}
|
|
82
83
|
const STATS_FANOUT_LIMIT = 200;
|
|
84
|
+
const UNTRACKED_NUMSTAT_LIMIT = 32;
|
|
83
85
|
async function resolveBrowseRoot(deps, sessionId) {
|
|
84
86
|
const ws = await resolveWorkspace(deps, sessionId);
|
|
85
87
|
if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
|
|
@@ -186,7 +188,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
186
188
|
const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
|
|
187
189
|
runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
|
|
188
190
|
runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
|
|
189
|
-
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),
|
|
190
192
|
runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
|
|
191
193
|
runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
|
|
192
194
|
runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
|
|
@@ -204,7 +206,20 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
204
206
|
allChanges = parseStatus(statusRes.run.stdout);
|
|
205
207
|
statusLossy = statusRes.run.stdoutLossy;
|
|
206
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
|
+
}
|
|
207
221
|
const truncated = allChanges.length > config.maxChanges || statusLossy;
|
|
222
|
+
const truncatedReason = statusLossy ? "lossy" : allChanges.length > config.maxChanges ? "cap" : void 0;
|
|
208
223
|
const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
|
|
209
224
|
let staged = 0;
|
|
210
225
|
let modified = 0;
|
|
@@ -230,9 +245,10 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
230
245
|
const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
231
246
|
const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
232
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;
|
|
233
250
|
let untrackedInsertions = 0;
|
|
234
|
-
if (
|
|
235
|
-
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
251
|
+
if (untrackedCountable) {
|
|
236
252
|
const safeUntracked = untrackedPaths.filter(isSafePath);
|
|
237
253
|
if (safeUntracked.length > 0) {
|
|
238
254
|
const perFile = await Promise.all(
|
|
@@ -252,7 +268,9 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
252
268
|
deletions: wt.deletions + stg.deletions,
|
|
253
269
|
lastChangeAt,
|
|
254
270
|
headCommittedAt: lastCommit?.dateIso ?? null,
|
|
255
|
-
|
|
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
|
|
256
274
|
};
|
|
257
275
|
const snapshot = {
|
|
258
276
|
root,
|
|
@@ -271,6 +289,8 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
271
289
|
changes,
|
|
272
290
|
stats,
|
|
273
291
|
truncated,
|
|
292
|
+
...truncatedReason !== void 0 ? { truncatedReason } : {},
|
|
293
|
+
...untrackedCollapsed ? { untrackedCollapsed: true } : {},
|
|
274
294
|
refreshIntervalMs: config.refreshIntervalMs,
|
|
275
295
|
showInputPill: config.showInputPill,
|
|
276
296
|
defaultDiffView: config.defaultDiffView,
|
|
@@ -278,7 +298,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
278
298
|
suggestEnabled: config.suggestEnabled,
|
|
279
299
|
checkedAt: Date.now()
|
|
280
300
|
};
|
|
281
|
-
return { ok: true, value: snapshot };
|
|
301
|
+
return { ok: true, value: { ...snapshot, stateFingerprint: snapshotFingerprint(snapshot) } };
|
|
282
302
|
}
|
|
283
303
|
async function maxChangeMtime(deps, root, changes, cap = 200) {
|
|
284
304
|
let max = null;
|
|
@@ -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
|
@@ -17,7 +17,7 @@ export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashL
|
|
|
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, resolveSuggestLanguage, parseStyleHistory, isCjkText } 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[];
|
package/lib/host/index.js
CHANGED
|
@@ -49,8 +49,8 @@ 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";
|
|
@@ -62,7 +62,7 @@ import { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashL
|
|
|
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, resolveSuggestLanguage, parseStyleHistory, isCjkText } 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");
|
|
@@ -195,9 +180,11 @@ var index_default = GitPanelService;
|
|
|
195
180
|
export {
|
|
196
181
|
DEFAULT_CONFIG2 as DEFAULT_CONFIG,
|
|
197
182
|
GitPanelService,
|
|
183
|
+
LONG_LINE_MAX_CHARS,
|
|
198
184
|
checkLatestVersion2 as checkLatestVersion,
|
|
199
185
|
classifyActionFailure,
|
|
200
186
|
classifyHost,
|
|
187
|
+
compactLongLines,
|
|
201
188
|
compareVersions,
|
|
202
189
|
createGitRunner2 as createGitRunner,
|
|
203
190
|
index_default as default,
|
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
|
+
};
|
package/lib/host/suggest.d.ts
CHANGED
|
@@ -19,5 +19,18 @@ export declare function parseStyleHistory(stdout: string, ownEmail: string | und
|
|
|
19
19
|
* neutral fallback.
|
|
20
20
|
*/
|
|
21
21
|
export declare function resolveSuggestLanguage(setting: SuggestLanguage, samples: readonly string[]): ResolvedLanguage;
|
|
22
|
+
/** A diff line longer than this is replaced by a one-line marker before the
|
|
23
|
+
* model-input budget is applied. Build output is minified into single lines (a
|
|
24
|
+
* bundled `lib/client.js` line can be hundreds of KB), so shipping it verbatim
|
|
25
|
+
* spends the whole `suggestMaxBytes` budget on one line and cuts off the hunks
|
|
26
|
+
* that actually describe the change. Line count and the diff prefix are kept,
|
|
27
|
+
* so `@@` headers still describe the same regions. */
|
|
28
|
+
export declare const LONG_LINE_MAX_CHARS = 400;
|
|
29
|
+
/** Fold over-long lines into `<prefix>… [long line: N chars omitted]`.
|
|
30
|
+
* `N` counts UTF-16 code units (what `String.length` reports), not bytes. */
|
|
31
|
+
export declare function compactLongLines(text: string, maxChars?: number): {
|
|
32
|
+
text: string;
|
|
33
|
+
folded: number;
|
|
34
|
+
};
|
|
22
35
|
/** Generate a commit message for the session's uncommitted changes. */
|
|
23
36
|
export declare function runSuggest(deps: SnapshotDeps, config: GitPanelConfig, request: GitSuggestRequest): Promise<GitSuggestResult>;
|
package/lib/host/suggest.js
CHANGED
|
@@ -59,6 +59,7 @@ function systemPrompt(language) {
|
|
|
59
59
|
].join("\n");
|
|
60
60
|
}
|
|
61
61
|
const UNTRACKED_FILE_CAP = 16;
|
|
62
|
+
const PROMPT_FILE_CAP = 200;
|
|
62
63
|
function resolveRoute(config, agentDefaultModel) {
|
|
63
64
|
if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
|
|
64
65
|
return { provider: config.suggestProvider, model: config.suggestModel };
|
|
@@ -100,9 +101,23 @@ function truncateUtf8(text, maxBytes) {
|
|
|
100
101
|
}
|
|
101
102
|
return text.slice(0, lo);
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
+
const LONG_LINE_MAX_CHARS = 400;
|
|
105
|
+
const STRUCTURAL_LINE = /^(?:@@|diff --git |index |--- (?:a\/|\/dev\/null)|\+\+\+ (?:b\/|\/dev\/null)|new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to |Binary files |GIT binary patch|literal |delta |\\ )/;
|
|
106
|
+
function compactLongLines(text, maxChars = LONG_LINE_MAX_CHARS) {
|
|
107
|
+
if (maxChars <= 0) return { text, folded: 0 };
|
|
108
|
+
let folded = 0;
|
|
109
|
+
const lines = text.split("\n").map((line) => {
|
|
110
|
+
if (line.length <= maxChars || STRUCTURAL_LINE.test(line)) return line;
|
|
111
|
+
folded += 1;
|
|
112
|
+
const prefix = line[0] === "+" || line[0] === "-" || line[0] === " " || line[0] === "\\" ? line[0] : "";
|
|
113
|
+
return `${prefix}\u2026 [long line: ${line.length} chars omitted]`;
|
|
114
|
+
});
|
|
115
|
+
return { text: lines.join("\n"), folded };
|
|
116
|
+
}
|
|
117
|
+
function frameChanges(files, filesOmitted, diff, styleSamples) {
|
|
104
118
|
return JSON.stringify({
|
|
105
119
|
files,
|
|
120
|
+
...filesOmitted > 0 ? { filesOmitted } : {},
|
|
106
121
|
diff,
|
|
107
122
|
commitStyleSamples: styleSamples.length === 0 ? null : styleSamples
|
|
108
123
|
});
|
|
@@ -160,7 +175,7 @@ async function appendUntracked(deps, root, files, diff, budget) {
|
|
|
160
175
|
}
|
|
161
176
|
const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
|
|
162
177
|
if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
|
|
163
|
-
const chunk = res.run.stdout;
|
|
178
|
+
const chunk = compactLongLines(res.run.stdout).text;
|
|
164
179
|
if (chunk === "") continue;
|
|
165
180
|
const lossy = res.run.stdoutLossy;
|
|
166
181
|
if (lossy || Buffer.byteLength(chunk, "utf8") > remaining) {
|
|
@@ -186,7 +201,7 @@ async function runSuggest(deps, config, request) {
|
|
|
186
201
|
const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
|
|
187
202
|
const pathSet = new Set(paths);
|
|
188
203
|
const [statusRes, headRes, logRes, emailRes] = await Promise.all([
|
|
189
|
-
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
|
|
204
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "suggest-status", deps.signal),
|
|
190
205
|
runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
|
|
191
206
|
runCommand(deps.run, ["git", "log", `-n${HISTORY_SCAN}`, "-z", "--format=%ae%s"], root, "suggest-log", deps.signal),
|
|
192
207
|
runCommand(deps.run, ["git", "config", "user.email"], root, "suggest-email", deps.signal)
|
|
@@ -201,13 +216,19 @@ async function runSuggest(deps, config, request) {
|
|
|
201
216
|
const styleSamples = "run" in logRes && logRes.run.exitCode === 0 ? parseStyleHistory(logRes.run.stdout, "run" in emailRes && emailRes.run.exitCode === 0 ? emailRes.run.stdout.trim() : void 0) : [];
|
|
202
217
|
const language = resolveSuggestLanguage(config.suggestLanguage, styleSamples);
|
|
203
218
|
const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
|
|
204
|
-
const files = pathSet.size > 0 ? all.filter((c) =>
|
|
219
|
+
const files = pathSet.size > 0 ? all.filter((c) => {
|
|
220
|
+
if (pathSet.has(c.path)) return true;
|
|
221
|
+
for (let slash = c.path.indexOf("/"); slash !== -1; slash = c.path.indexOf("/", slash + 1)) {
|
|
222
|
+
if (pathSet.has(c.path.slice(0, slash))) return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}) : all;
|
|
205
226
|
const collected = await collectDiff(deps, root, hasHead, pathArgs);
|
|
206
227
|
if (!("diff" in collected)) return collected;
|
|
207
228
|
if (files.length === 0 && collected.diff.trim() === "") {
|
|
208
229
|
return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
|
|
209
230
|
}
|
|
210
|
-
let diff = collected.diff;
|
|
231
|
+
let diff = compactLongLines(collected.diff).text;
|
|
211
232
|
let truncated = collected.lossy;
|
|
212
233
|
if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
|
|
213
234
|
diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
|
|
@@ -222,7 +243,8 @@ async function runSuggest(deps, config, request) {
|
|
|
222
243
|
return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
|
|
223
244
|
}
|
|
224
245
|
const frame = frameChanges(
|
|
225
|
-
files.map((c) => ({ path: c.path, status: c.status })),
|
|
246
|
+
files.slice(0, PROMPT_FILE_CAP).map((c) => ({ path: c.path, status: c.status })),
|
|
247
|
+
Math.max(0, files.length - PROMPT_FILE_CAP),
|
|
226
248
|
diff,
|
|
227
249
|
styleSamples
|
|
228
250
|
);
|
|
@@ -275,6 +297,8 @@ async function runSuggest(deps, config, request) {
|
|
|
275
297
|
};
|
|
276
298
|
}
|
|
277
299
|
export {
|
|
300
|
+
LONG_LINE_MAX_CHARS,
|
|
301
|
+
compactLongLines,
|
|
278
302
|
isCjkText,
|
|
279
303
|
parseStyleHistory,
|
|
280
304
|
resolveSuggestLanguage,
|