@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.
@@ -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", "--end-of-options", action.name];
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", "--end-of-options", action.createFrom];
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}`, "--end-of-options", action.commit]] };
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.fs.remove(target).catch(() => {
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
  }
@@ -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
- /** Best-effort unlink (force) — temp-blob cleanup. */
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 (statsDetailed) {
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
- partial: !statsDetailed
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,2 @@
1
+ import type { SnapshotDeps } from './core.js';
2
+ export declare function createFsDeps(): SnapshotDeps['fs'];
@@ -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
+ };
@@ -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,
@@ -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 = search !== "" && isHexLike(search);
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 (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}`);
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
- return { ok: true, value: { kind: "history", commits, total } };
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,3 @@
1
+ import type { GitSnapshot } from './types.js';
2
+ /** Hash the final wire state; sampling time and the digest itself are not state. */
3
+ export declare function snapshotFingerprint(snapshot: GitSnapshot): string;
@@ -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
+ };
@@ -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>;
@@ -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
- function frameChanges(files, diff, styleSamples) {
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) => pathSet.has(c.path)) : all;
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,