@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.
@@ -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
  }
@@ -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
- /** Best-effort unlink (force) — temp-blob cleanup. */
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 (statsDetailed) {
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
- 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
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,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
+ };
@@ -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,
@@ -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
+ };