@openspecui/server 2.1.7 → 2.3.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.
Files changed (2) hide show
  1. package/dist/index.mjs +890 -96
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer as createServer$1 } from "node:net";
2
2
  import { serve } from "@hono/node-server";
3
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
3
+ import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
4
4
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
5
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
6
  import { Hono } from "hono";
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { WebSocketServer } from "ws";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { execFile } from "node:child_process";
14
- import { mkdir, rm, stat, writeFile } from "node:fs/promises";
14
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
15
15
  import { promisify } from "node:util";
16
16
  import * as pty from "@lydell/node-pty";
17
17
  import { EventEmitter as EventEmitter$1 } from "events";
@@ -153,7 +153,7 @@ function selectRecentDashboardItems(items, limit = DASHBOARD_RECENT_LIST_LIMIT)
153
153
  }
154
154
 
155
155
  //#endregion
156
- //#region src/dashboard-git-snapshot.ts
156
+ //#region src/git-shared.ts
157
157
  const execFileAsync$1 = promisify(execFile);
158
158
  const EMPTY_DIFF = {
159
159
  files: 0,
@@ -186,6 +186,14 @@ async function defaultReadPathTimestampMs(absolutePath) {
186
186
  return null;
187
187
  }
188
188
  }
189
+ async function pathExists(absolutePath) {
190
+ try {
191
+ await stat(absolutePath);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
189
197
  function parseShortStat(output) {
190
198
  const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
191
199
  const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
@@ -196,33 +204,48 @@ function parseShortStat(output) {
196
204
  deletions: Number.isFinite(deletions) ? deletions : 0
197
205
  };
198
206
  }
199
- function parseNumStat(output) {
200
- let files = 0;
201
- let insertions = 0;
202
- let deletions = 0;
203
- for (const line of output.split("\n")) {
204
- const trimmed = line.trim();
205
- if (!trimmed) continue;
206
- const [addRaw, deleteRaw] = trimmed.split(" ");
207
- if (!addRaw || !deleteRaw) continue;
208
- files += 1;
209
- if (addRaw !== "-") insertions += Number(addRaw) || 0;
210
- if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
211
- }
212
- return {
213
- files,
214
- insertions,
215
- deletions
216
- };
217
- }
218
207
  function normalizeGitPath(path) {
219
208
  return path.replace(/\\/g, "/").replace(/^\.\//, "");
220
209
  }
210
+ function extractGitPathVariants(rawPath) {
211
+ const trimmed = rawPath.trim();
212
+ if (!trimmed) return [];
213
+ const normalizedRaw = normalizeGitPath(trimmed);
214
+ const braceRenameMatch = /^(.*?)\{(.*?) => (.*?)\}(.*)$/.exec(trimmed);
215
+ if (braceRenameMatch) {
216
+ const [, prefix = "", left = "", right = "", suffix = ""] = braceRenameMatch;
217
+ const variants = /* @__PURE__ */ new Set();
218
+ variants.add(normalizeGitPath(`${prefix}${left}${suffix}`));
219
+ variants.add(normalizeGitPath(`${prefix}${right}${suffix}`));
220
+ return [...variants];
221
+ }
222
+ const renameParts = trimmed.split(" => ");
223
+ if (renameParts.length === 2) {
224
+ const [left = "", right = ""] = renameParts;
225
+ const variants = /* @__PURE__ */ new Set();
226
+ variants.add(normalizeGitPath(left));
227
+ variants.add(normalizeGitPath(right));
228
+ return [...variants];
229
+ }
230
+ return [normalizedRaw];
231
+ }
221
232
  function relativePath(fromDir, target) {
222
233
  const rel = relative(fromDir, target);
223
234
  if (!rel || rel.length === 0) return ".";
224
235
  return rel;
225
236
  }
237
+ async function canonicalGitPath(path) {
238
+ const resolved = resolve(path);
239
+ try {
240
+ return await realpath(resolved);
241
+ } catch {
242
+ return resolved;
243
+ }
244
+ }
245
+ async function sameGitPath(left, right) {
246
+ const [canonicalLeft, canonicalRight] = await Promise.all([canonicalGitPath(left), canonicalGitPath(right)]);
247
+ return canonicalLeft === canonicalRight;
248
+ }
226
249
  function parseBranchName(branchRef, detached) {
227
250
  if (detached) return "(detached)";
228
251
  if (!branchRef) return "(unknown)";
@@ -251,10 +274,7 @@ function parseWorktreeList(porcelain) {
251
274
  current.branchRef = line.slice(7).trim();
252
275
  continue;
253
276
  }
254
- if (line === "detached") {
255
- current.detached = true;
256
- continue;
257
- }
277
+ if (line === "detached") current.detached = true;
258
278
  }
259
279
  flush();
260
280
  return entries;
@@ -263,16 +283,17 @@ function parseRelatedChanges(paths) {
263
283
  const related = /* @__PURE__ */ new Set();
264
284
  for (const path of paths) {
265
285
  const normalized = normalizeGitPath(path);
286
+ if (normalized.includes("{") || normalized.includes("=>")) continue;
287
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
288
+ if (archiveMatch?.[1]) {
289
+ related.add(archiveMatch[1].replace(/^\d{4}-\d{2}-\d{2}-/, ""));
290
+ continue;
291
+ }
266
292
  const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
267
293
  if (activeMatch?.[1]) {
268
294
  related.add(activeMatch[1]);
269
295
  continue;
270
296
  }
271
- const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
272
- if (archiveMatch?.[1]) {
273
- const fullName = archiveMatch[1];
274
- related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
275
- }
276
297
  }
277
298
  return [...related].sort((a, b) => a.localeCompare(b));
278
299
  }
@@ -294,41 +315,112 @@ async function resolveDefaultBranch(projectDir, runGit) {
294
315
  if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
295
316
  return "main";
296
317
  }
297
- async function collectCommitEntries(options) {
298
- const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
299
- const commitEntries = [];
318
+ async function listGitWorktrees(projectDir, runGit) {
319
+ const resolvedProjectDir = resolve(projectDir);
320
+ const worktreeResult = await runGit(resolvedProjectDir, [
321
+ "worktree",
322
+ "list",
323
+ "--porcelain"
324
+ ]);
325
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
326
+ if (parsed.length > 0) return parsed;
327
+ return [{
328
+ path: resolvedProjectDir,
329
+ branchRef: null,
330
+ detached: false
331
+ }];
332
+ }
333
+
334
+ //#endregion
335
+ //#region src/git-entry-summary.ts
336
+ function createEmptyCommitRecord(hash, committedAt, title) {
337
+ return {
338
+ hash,
339
+ committedAt,
340
+ title,
341
+ diff: { ...EMPTY_DIFF },
342
+ changedPaths: []
343
+ };
344
+ }
345
+ function parseGitLogNumstatRecords(stdout) {
346
+ const records = [];
347
+ for (const block of stdout.split("")) {
348
+ const trimmedBlock = block.trim();
349
+ if (!trimmedBlock) continue;
350
+ const lines = trimmedBlock.split("\n");
351
+ const header = lines.shift()?.trim();
352
+ if (!header) continue;
353
+ const [hash, committedAtRaw = "0", title = ""] = header.split("");
354
+ if (!hash) continue;
355
+ const committedAtSeconds = Number(committedAtRaw);
356
+ const record = createEmptyCommitRecord(hash, Number.isFinite(committedAtSeconds) && committedAtSeconds > 0 ? committedAtSeconds * 1e3 : 0, title.trim() || hash.slice(0, 7));
357
+ for (const line of lines) {
358
+ const trimmedLine = line.trim();
359
+ if (!trimmedLine) continue;
360
+ const parts = trimmedLine.split(" ");
361
+ if (parts.length < 3) continue;
362
+ const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
363
+ const rawPath = pathParts.join(" ").trim();
364
+ if (!rawPath) continue;
365
+ record.diff.files += 1;
366
+ if (insertionsRaw !== "-") record.diff.insertions += Number(insertionsRaw) || 0;
367
+ if (deletionsRaw !== "-") record.diff.deletions += Number(deletionsRaw) || 0;
368
+ record.changedPaths.push(...extractGitPathVariants(rawPath));
369
+ }
370
+ records.push(record);
371
+ }
372
+ return records;
373
+ }
374
+ async function listGitCommitEntriesPage(options) {
375
+ const { worktreePath, defaultBranch, offset, limit, runGit } = options;
300
376
  const commits = await runGit(worktreePath, [
301
377
  "log",
302
- "--format=%H%x1f%ct%x1f%s",
303
- `-n${maxCommitEntries}`,
378
+ "--format=%x1e%H%x1f%ct%x1f%s",
379
+ "--numstat",
380
+ `--skip=${offset}`,
381
+ `-n${limit + 1}`,
304
382
  `${defaultBranch}..HEAD`
305
383
  ]);
306
- if (commits.ok) for (const line of commits.stdout.split("\n")) {
307
- if (!line.trim()) continue;
308
- const [hash, committedAtRaw = "0", title = ""] = line.split("");
309
- if (!hash) continue;
310
- const diffResult = await runGit(worktreePath, [
311
- "show",
312
- "--numstat",
313
- "--format=",
314
- hash
315
- ]);
316
- const changedFiles = (await runGit(worktreePath, [
317
- "show",
318
- "--name-only",
319
- "--format=",
320
- hash
321
- ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
322
- const committedAt = Number(committedAtRaw) * 1e3;
323
- commitEntries.push({
384
+ if (!commits.ok) return {
385
+ items: [],
386
+ nextCursor: null
387
+ };
388
+ const records = parseGitLogNumstatRecords(commits.stdout);
389
+ return {
390
+ items: records.slice(0, limit).map((record) => ({
324
391
  type: "commit",
325
- hash,
326
- title: title.trim() || hash.slice(0, 7),
327
- committedAt: Number.isFinite(committedAt) && committedAt > 0 ? committedAt : 0,
328
- relatedChanges: parseRelatedChanges(changedFiles),
329
- diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
330
- });
331
- }
392
+ hash: record.hash,
393
+ title: record.title,
394
+ committedAt: record.committedAt,
395
+ relatedChanges: parseRelatedChanges(record.changedPaths),
396
+ diff: record.diff
397
+ })),
398
+ nextCursor: records.length > limit ? String(offset + limit) : null
399
+ };
400
+ }
401
+ async function readGitCommitEntryByHash(options) {
402
+ const { worktreePath, hash, runGit } = options;
403
+ const result = await runGit(worktreePath, [
404
+ "show",
405
+ "--numstat",
406
+ "--format=%x1e%H%x1f%ct%x1f%s",
407
+ hash
408
+ ]);
409
+ if (!result.ok) return null;
410
+ const record = parseGitLogNumstatRecords(result.stdout)[0];
411
+ if (!record) return null;
412
+ return {
413
+ type: "commit",
414
+ hash: record.hash,
415
+ title: record.title,
416
+ committedAt: record.committedAt,
417
+ relatedChanges: parseRelatedChanges(record.changedPaths),
418
+ diff: record.diff
419
+ };
420
+ }
421
+ async function collectUncommittedEntrySummary(options) {
422
+ const { worktreePath, runGit } = options;
423
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
332
424
  const trackedResult = await runGit(worktreePath, [
333
425
  "diff",
334
426
  "--numstat",
@@ -346,32 +438,57 @@ async function collectCommitEntries(options) {
346
438
  ]);
347
439
  const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
348
440
  const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
441
+ const trackedDiff = parseGitLogNumstatRecords(`\u001ehead\u001f0\u001fUncommitted\n${trackedResult.stdout}`)[0]?.diff ?? EMPTY_DIFF;
349
442
  const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
350
- const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
351
- const updatedAt = (await Promise.all([...allUncommittedFiles].map((path) => readPathTimestampMs(resolve(worktreePath, path))))).reduce((latest, current) => {
352
- if (!current || !Number.isFinite(current) || current <= 0) return latest;
353
- return latest === null || current > latest ? current : latest;
354
- }, null) ?? null;
355
- commitEntries.sort((left, right) => {
356
- if (left.type !== "commit" || right.type !== "commit") return 0;
357
- return right.committedAt - left.committedAt;
358
- });
359
- return [{
443
+ return {
360
444
  type: "uncommitted",
361
445
  title: "Uncommitted",
362
- updatedAt,
446
+ updatedAt: (await Promise.all([...allUncommittedFiles].map((path) => readPathTimestampMs(resolve(worktreePath, path))))).reduce((latest, current) => {
447
+ if (!current || !Number.isFinite(current) || current <= 0) return latest;
448
+ return latest === null || current > latest ? current : latest;
449
+ }, null) ?? null,
363
450
  relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
364
451
  diff: {
365
452
  files: allUncommittedFiles.size,
366
453
  insertions: trackedDiff.insertions,
367
454
  deletions: trackedDiff.deletions
368
455
  }
369
- }, ...commitEntries];
456
+ };
457
+ }
458
+ async function listRecentGitEntries(options) {
459
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
460
+ const [uncommitted, commitsPage] = await Promise.all([collectUncommittedEntrySummary({
461
+ worktreePath,
462
+ runGit,
463
+ readPathTimestampMs
464
+ }), listGitCommitEntriesPage({
465
+ worktreePath,
466
+ defaultBranch,
467
+ offset: 0,
468
+ limit: maxCommitEntries,
469
+ runGit
470
+ })]);
471
+ return [uncommitted, ...commitsPage.items];
472
+ }
473
+
474
+ //#endregion
475
+ //#region src/dashboard-git-snapshot.ts
476
+ async function collectCommitEntries(options) {
477
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
478
+ return listRecentGitEntries({
479
+ worktreePath,
480
+ defaultBranch,
481
+ maxCommitEntries,
482
+ runGit,
483
+ readPathTimestampMs
484
+ });
370
485
  }
371
486
  async function collectWorktree(options) {
372
487
  const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries, readPathTimestampMs } = options;
373
488
  const worktreePath = resolve(worktree.path);
374
489
  const resolvedProjectDir = resolve(projectDir);
490
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
491
+ const pathAvailable = await pathExists(worktreePath);
375
492
  const aheadBehindResult = await runGit(worktreePath, [
376
493
  "rev-list",
377
494
  "--left-right",
@@ -401,9 +518,10 @@ async function collectWorktree(options) {
401
518
  return {
402
519
  path: worktreePath,
403
520
  relativePath: relativePath(resolvedProjectDir, worktreePath),
521
+ pathAvailable,
404
522
  branchName: parseBranchName(worktree.branchRef, worktree.detached),
405
523
  detached: worktree.detached,
406
- isCurrent: resolvedProjectDir === worktreePath,
524
+ isCurrent,
407
525
  ahead,
408
526
  behind,
409
527
  diff,
@@ -414,14 +532,13 @@ async function removeDetachedDashboardGitWorktree(options) {
414
532
  const runGit = options.runGit ?? defaultRunGit;
415
533
  const resolvedProjectDir = resolve(options.projectDir);
416
534
  const resolvedTargetPath = resolve(options.targetPath);
417
- if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
418
- const worktreeResult = await runGit(resolvedProjectDir, [
419
- "worktree",
420
- "list",
421
- "--porcelain"
422
- ]);
423
- if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
424
- const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve(worktree.path) === resolvedTargetPath);
535
+ if (await sameGitPath(resolvedTargetPath, resolvedProjectDir)) throw new Error("Cannot remove the current worktree.");
536
+ const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
537
+ let matched;
538
+ for (const worktree of worktrees) if (await sameGitPath(worktree.path, resolvedTargetPath)) {
539
+ matched = worktree;
540
+ break;
541
+ }
425
542
  if (!matched) throw new Error("Worktree not found.");
426
543
  if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
427
544
  if (!(await runGit(resolvedProjectDir, [
@@ -437,17 +554,7 @@ async function buildDashboardGitSnapshot(options) {
437
554
  const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
438
555
  const resolvedProjectDir = resolve(options.projectDir);
439
556
  const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
440
- const worktreeResult = await runGit(resolvedProjectDir, [
441
- "worktree",
442
- "list",
443
- "--porcelain"
444
- ]);
445
- const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
446
- const baseWorktrees = parsed.length > 0 ? parsed : [{
447
- path: resolvedProjectDir,
448
- branchRef: null,
449
- detached: false
450
- }];
557
+ const baseWorktrees = await listGitWorktrees(resolvedProjectDir, runGit);
451
558
  const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
452
559
  projectDir: resolvedProjectDir,
453
560
  worktree,
@@ -1247,6 +1354,631 @@ function createCliStreamObservable(startStream) {
1247
1354
  });
1248
1355
  }
1249
1356
 
1357
+ //#endregion
1358
+ //#region src/git-panel-cache.ts
1359
+ const gitPanelCaches = {
1360
+ overview: /* @__PURE__ */ new Map(),
1361
+ entries: /* @__PURE__ */ new Map(),
1362
+ meta: /* @__PURE__ */ new Map(),
1363
+ shell: /* @__PURE__ */ new Map(),
1364
+ files: /* @__PURE__ */ new Map(),
1365
+ snapshot: /* @__PURE__ */ new Map(),
1366
+ patch: /* @__PURE__ */ new Map()
1367
+ };
1368
+ const gitPanelPendingCaches = {
1369
+ overview: /* @__PURE__ */ new Map(),
1370
+ entries: /* @__PURE__ */ new Map(),
1371
+ meta: /* @__PURE__ */ new Map(),
1372
+ shell: /* @__PURE__ */ new Map(),
1373
+ files: /* @__PURE__ */ new Map(),
1374
+ snapshot: /* @__PURE__ */ new Map(),
1375
+ patch: /* @__PURE__ */ new Map()
1376
+ };
1377
+ function buildCacheKey(projectDir, key) {
1378
+ return `${resolve(projectDir)}::${key}`;
1379
+ }
1380
+ function isImmutableCommitDetailCache(scope, key) {
1381
+ return (scope === "meta" || scope === "shell" || scope === "files" || scope === "snapshot" || scope === "patch") && key.startsWith("commit:");
1382
+ }
1383
+ function getCacheVersion(scope, key) {
1384
+ if (isImmutableCommitDetailCache(scope, key)) return "commit-detail:immutable";
1385
+ return `refresh:${getDashboardGitTaskStatus().lastFinishedAt ?? 0}`;
1386
+ }
1387
+ async function getCachedGitPanelValue(scope, projectDir, key, load) {
1388
+ const cache = gitPanelCaches[scope];
1389
+ const pendingCache = gitPanelPendingCaches[scope];
1390
+ const cacheKey = buildCacheKey(projectDir, key);
1391
+ const version = getCacheVersion(scope, key);
1392
+ const hit = cache.get(cacheKey);
1393
+ if (hit && hit.version === version) return hit.value;
1394
+ const pending = pendingCache.get(cacheKey);
1395
+ if (pending && pending.version === version) return pending.promise;
1396
+ const promise = load().then((value) => {
1397
+ cache.set(cacheKey, {
1398
+ version,
1399
+ value
1400
+ });
1401
+ return value;
1402
+ }).finally(() => {
1403
+ if (pendingCache.get(cacheKey)?.promise === promise) pendingCache.delete(cacheKey);
1404
+ });
1405
+ pendingCache.set(cacheKey, {
1406
+ version,
1407
+ promise
1408
+ });
1409
+ return promise;
1410
+ }
1411
+
1412
+ //#endregion
1413
+ //#region src/git-panel-data.ts
1414
+ const DEFAULT_ENTRY_PAGE_SIZE = 50;
1415
+ const MAX_ENTRY_PAGE_SIZE = 100;
1416
+ const MAX_PATCH_BYTES = 2e5;
1417
+ const MAX_SYNTHETIC_TEXT_BYTES = 2e5;
1418
+ function clampEntryLimit(limit) {
1419
+ if (!Number.isFinite(limit)) return DEFAULT_ENTRY_PAGE_SIZE;
1420
+ return Math.max(1, Math.min(MAX_ENTRY_PAGE_SIZE, Math.trunc(limit ?? DEFAULT_ENTRY_PAGE_SIZE)));
1421
+ }
1422
+ function parseCursor(cursor) {
1423
+ const value = Number(cursor);
1424
+ if (!Number.isFinite(value) || value < 0) return 0;
1425
+ return Math.trunc(value);
1426
+ }
1427
+ function createGitFileId(path, previousPath) {
1428
+ return JSON.stringify([previousPath ?? null, path]);
1429
+ }
1430
+ function parseGitNameStatus(stdout) {
1431
+ const entries = [];
1432
+ for (const line of stdout.split("\n")) {
1433
+ const trimmed = line.trim();
1434
+ if (!trimmed) continue;
1435
+ const parts = trimmed.split(" ");
1436
+ const normalized = (parts[0] ?? "")[0] ?? "";
1437
+ if (!normalized) continue;
1438
+ if ((normalized === "R" || normalized === "C") && parts.length >= 3) {
1439
+ entries.push({
1440
+ previousPath: parts[1] ?? null,
1441
+ path: parts[2] ?? "",
1442
+ changeType: normalized === "R" ? "renamed" : "copied"
1443
+ });
1444
+ continue;
1445
+ }
1446
+ if (parts.length < 2) continue;
1447
+ entries.push({
1448
+ previousPath: null,
1449
+ path: parts[1] ?? "",
1450
+ changeType: normalized === "A" ? "added" : normalized === "M" ? "modified" : normalized === "D" ? "deleted" : normalized === "T" ? "typechanged" : normalized === "U" ? "unmerged" : "unknown"
1451
+ });
1452
+ }
1453
+ return entries;
1454
+ }
1455
+ function parseNumStatMap(stdout) {
1456
+ const diffByPath = /* @__PURE__ */ new Map();
1457
+ for (const line of stdout.split("\n")) {
1458
+ const trimmed = line.trim();
1459
+ if (!trimmed) continue;
1460
+ const parts = trimmed.split(" ");
1461
+ if (parts.length < 3) continue;
1462
+ const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
1463
+ const rawPath = pathParts.join(" ").trim();
1464
+ const diff = {
1465
+ files: 1,
1466
+ insertions: insertionsRaw === "-" ? 0 : Number(insertionsRaw) || 0,
1467
+ deletions: deletionsRaw === "-" ? 0 : Number(deletionsRaw) || 0
1468
+ };
1469
+ for (const path of extractGitPathVariants(rawPath)) diffByPath.set(path, diff);
1470
+ }
1471
+ return diffByPath;
1472
+ }
1473
+ function resolveTrackedDiff(diffByPath, status) {
1474
+ return diffByPath.get(status.path) ?? (status.previousPath ? diffByPath.get(status.previousPath) : void 0) ?? {
1475
+ files: 1,
1476
+ insertions: 0,
1477
+ deletions: 0
1478
+ };
1479
+ }
1480
+ function readyFileDiff(diff) {
1481
+ return {
1482
+ state: "ready",
1483
+ ...diff
1484
+ };
1485
+ }
1486
+ function loadingFileDiff(files = 1) {
1487
+ return {
1488
+ state: "loading",
1489
+ files
1490
+ };
1491
+ }
1492
+ function unavailableFileDiff(files = 1) {
1493
+ return {
1494
+ state: "unavailable",
1495
+ files
1496
+ };
1497
+ }
1498
+ function buildTrackedFileSummaries(statuses, numStatOutput) {
1499
+ const diffByPath = parseNumStatMap(numStatOutput);
1500
+ return statuses.map((status) => ({
1501
+ fileId: createGitFileId(status.path, status.previousPath),
1502
+ source: "tracked",
1503
+ path: status.path,
1504
+ displayPath: status.previousPath ? `${status.previousPath} -> ${status.path}` : status.path,
1505
+ previousPath: status.previousPath,
1506
+ changeType: status.changeType,
1507
+ diff: readyFileDiff(resolveTrackedDiff(diffByPath, status))
1508
+ })).sort((left, right) => left.path.localeCompare(right.path));
1509
+ }
1510
+ function buildUntrackedFileSummary(path) {
1511
+ return {
1512
+ fileId: createGitFileId(path, null),
1513
+ source: "untracked",
1514
+ path,
1515
+ displayPath: path,
1516
+ previousPath: null,
1517
+ changeType: "added",
1518
+ diff: loadingFileDiff()
1519
+ };
1520
+ }
1521
+ async function collectWorktreeSummary(options) {
1522
+ const { projectDir, worktree, defaultBranch, runGit } = options;
1523
+ const worktreePath = resolve(worktree.path);
1524
+ const resolvedProjectDir = resolve(projectDir);
1525
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
1526
+ const pathAvailable = await pathExists(worktreePath);
1527
+ const aheadBehindResult = await runGit(worktreePath, [
1528
+ "rev-list",
1529
+ "--left-right",
1530
+ "--count",
1531
+ `${defaultBranch}...HEAD`
1532
+ ]);
1533
+ let ahead = 0;
1534
+ let behind = 0;
1535
+ if (aheadBehindResult.ok) {
1536
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
1537
+ ahead = Number(aheadRaw) || 0;
1538
+ behind = Number(behindRaw) || 0;
1539
+ }
1540
+ const diffResult = await runGit(worktreePath, [
1541
+ "diff",
1542
+ "--shortstat",
1543
+ `${defaultBranch}...HEAD`
1544
+ ]);
1545
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
1546
+ return {
1547
+ path: worktreePath,
1548
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
1549
+ pathAvailable,
1550
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
1551
+ detached: worktree.detached,
1552
+ isCurrent,
1553
+ ahead,
1554
+ behind,
1555
+ diff
1556
+ };
1557
+ }
1558
+ function normalizePatchState(patch) {
1559
+ const trimmed = patch.trimEnd();
1560
+ if (!trimmed) return {
1561
+ state: "unavailable",
1562
+ patch: null
1563
+ };
1564
+ if (/^GIT binary patch$/m.test(trimmed) || /^Binary files .* differ$/m.test(trimmed)) return {
1565
+ state: "binary",
1566
+ patch: null
1567
+ };
1568
+ if (Buffer.byteLength(trimmed, "utf8") > MAX_PATCH_BYTES) return {
1569
+ state: "too-large",
1570
+ patch: null
1571
+ };
1572
+ return {
1573
+ state: "available",
1574
+ patch: trimmed
1575
+ };
1576
+ }
1577
+ function splitPatchLines(text) {
1578
+ if (!text) return [];
1579
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
1580
+ if (lines.at(-1) === "") lines.pop();
1581
+ return lines;
1582
+ }
1583
+ async function buildUntrackedPatchFile(worktreePath, file) {
1584
+ try {
1585
+ const buffer = await readFile(resolve(worktreePath, file.path));
1586
+ if (buffer.byteLength > MAX_SYNTHETIC_TEXT_BYTES) return {
1587
+ ...file,
1588
+ diff: unavailableFileDiff(),
1589
+ patch: null,
1590
+ state: "too-large"
1591
+ };
1592
+ if (buffer.includes(0)) return {
1593
+ ...file,
1594
+ diff: unavailableFileDiff(),
1595
+ patch: null,
1596
+ state: "binary"
1597
+ };
1598
+ const lines = splitPatchLines(buffer.toString("utf8"));
1599
+ const hunkHeader = lines.length > 0 ? `@@ -0,0 +1,${lines.length} @@` : null;
1600
+ const body = lines.map((line) => `+${line}`);
1601
+ const patch = [
1602
+ `diff --git a/${file.path} b/${file.path}`,
1603
+ "new file mode 100644",
1604
+ "--- /dev/null",
1605
+ `+++ b/${file.path}`,
1606
+ ...hunkHeader ? [hunkHeader] : [],
1607
+ ...body
1608
+ ].join("\n");
1609
+ return {
1610
+ ...file,
1611
+ diff: readyFileDiff({
1612
+ files: 1,
1613
+ insertions: lines.length,
1614
+ deletions: 0
1615
+ }),
1616
+ patch: patch.trimEnd(),
1617
+ state: "available"
1618
+ };
1619
+ } catch {
1620
+ return {
1621
+ ...file,
1622
+ diff: unavailableFileDiff(),
1623
+ patch: null,
1624
+ state: "unavailable"
1625
+ };
1626
+ }
1627
+ }
1628
+ async function buildCommitShell(options) {
1629
+ const { worktreePath, hash, runGit } = options;
1630
+ const [entry, nameStatusResult, numStatResult] = await Promise.all([
1631
+ readGitCommitEntryByHash({
1632
+ worktreePath,
1633
+ hash,
1634
+ runGit
1635
+ }),
1636
+ runGit(worktreePath, [
1637
+ "show",
1638
+ "--name-status",
1639
+ "--find-renames",
1640
+ "--format=",
1641
+ hash
1642
+ ]),
1643
+ runGit(worktreePath, [
1644
+ "show",
1645
+ "--numstat",
1646
+ "--format=",
1647
+ hash
1648
+ ])
1649
+ ]);
1650
+ if (!entry) return {
1651
+ entry: null,
1652
+ files: []
1653
+ };
1654
+ return {
1655
+ entry,
1656
+ files: buildTrackedFileSummaries(nameStatusResult.ok ? parseGitNameStatus(nameStatusResult.stdout) : [], numStatResult.stdout)
1657
+ };
1658
+ }
1659
+ async function buildUncommittedShell(options) {
1660
+ const { worktreePath, runGit, readPathTimestampMs } = options;
1661
+ const [entry, trackedStatusResult, trackedNumStatResult, untrackedResult] = await Promise.all([
1662
+ collectUncommittedEntrySummary({
1663
+ worktreePath,
1664
+ runGit,
1665
+ readPathTimestampMs
1666
+ }),
1667
+ runGit(worktreePath, [
1668
+ "diff",
1669
+ "--name-status",
1670
+ "--find-renames",
1671
+ "HEAD"
1672
+ ]),
1673
+ runGit(worktreePath, [
1674
+ "diff",
1675
+ "--numstat",
1676
+ "HEAD"
1677
+ ]),
1678
+ runGit(worktreePath, [
1679
+ "ls-files",
1680
+ "--others",
1681
+ "--exclude-standard"
1682
+ ])
1683
+ ]);
1684
+ const trackedFiles = buildTrackedFileSummaries(trackedStatusResult.ok ? parseGitNameStatus(trackedStatusResult.stdout) : [], trackedNumStatResult.stdout);
1685
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0).map((path) => buildUntrackedFileSummary(path));
1686
+ return {
1687
+ entry,
1688
+ files: [...trackedFiles, ...untrackedFiles].sort((left, right) => left.path.localeCompare(right.path))
1689
+ };
1690
+ }
1691
+ async function loadGitEntryShell(options) {
1692
+ const runGit = options.runGit ?? defaultRunGit;
1693
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
1694
+ const resolvedProjectDir = resolve(options.projectDir);
1695
+ if (options.selector.type === "uncommitted") return buildUncommittedShell({
1696
+ worktreePath: resolvedProjectDir,
1697
+ runGit,
1698
+ readPathTimestampMs
1699
+ });
1700
+ return buildCommitShell({
1701
+ worktreePath: resolvedProjectDir,
1702
+ hash: options.selector.hash,
1703
+ runGit
1704
+ });
1705
+ }
1706
+ function buildSelectorCacheKey(selector) {
1707
+ return selector.type === "commit" ? `commit:${selector.hash}` : "uncommitted";
1708
+ }
1709
+ function buildTrackedPatchArgs(selector) {
1710
+ return selector.type === "commit" ? [
1711
+ "show",
1712
+ "--patch",
1713
+ "--find-renames",
1714
+ "--format=",
1715
+ selector.hash
1716
+ ] : [
1717
+ "diff",
1718
+ "--patch",
1719
+ "--find-renames",
1720
+ "HEAD"
1721
+ ];
1722
+ }
1723
+ function decodeGitPatchPathToken(token) {
1724
+ const trimmed = token.trim();
1725
+ if (!trimmed || trimmed === "/dev/null") return null;
1726
+ let value = trimmed;
1727
+ if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) value = value.slice(1, -1).replace(/\\([\\"])/g, "$1").replace(/\\t/g, " ").replace(/\\n/g, "\n");
1728
+ if (value === "/dev/null") return null;
1729
+ if (value.startsWith("a/") || value.startsWith("b/")) return normalizeGitPath(value.slice(2));
1730
+ return normalizeGitPath(value);
1731
+ }
1732
+ function parseDiffGitHeaderPaths(line) {
1733
+ const rest = line.slice(11).trim();
1734
+ const quotedMatch = /^"a\/((?:[^"\\]|\\.)+)" "b\/((?:[^"\\]|\\.)+)"$/.exec(rest);
1735
+ if (quotedMatch) return {
1736
+ oldPath: decodeGitPatchPathToken(`"a/${quotedMatch[1] ?? ""}"`),
1737
+ newPath: decodeGitPatchPathToken(`"b/${quotedMatch[2] ?? ""}"`)
1738
+ };
1739
+ const plainMatch = /^a\/(.+?) b\/(.+)$/.exec(rest);
1740
+ if (plainMatch) return {
1741
+ oldPath: decodeGitPatchPathToken(`a/${plainMatch[1] ?? ""}`),
1742
+ newPath: decodeGitPatchPathToken(`b/${plainMatch[2] ?? ""}`)
1743
+ };
1744
+ return {
1745
+ oldPath: null,
1746
+ newPath: null
1747
+ };
1748
+ }
1749
+ function splitTrackedPatchBlocks(stdout) {
1750
+ const lines = splitPatchLines(stdout);
1751
+ const blocks = [];
1752
+ let current = [];
1753
+ for (const line of lines) {
1754
+ if (line.startsWith("diff --git ")) {
1755
+ if (current.length > 0) blocks.push(current.join("\n"));
1756
+ current = [line];
1757
+ continue;
1758
+ }
1759
+ if (current.length > 0) current.push(line);
1760
+ }
1761
+ if (current.length > 0) blocks.push(current.join("\n"));
1762
+ return blocks;
1763
+ }
1764
+ function resolveTrackedPatchBlockIdentity(block) {
1765
+ const lines = splitPatchLines(block);
1766
+ const pathCandidates = /* @__PURE__ */ new Set();
1767
+ const fileIdCandidates = /* @__PURE__ */ new Set();
1768
+ let oldPath = null;
1769
+ let newPath = null;
1770
+ let renameFrom = null;
1771
+ let renameTo = null;
1772
+ const headerLine = lines[0];
1773
+ if (headerLine?.startsWith("diff --git ")) {
1774
+ const parsed = parseDiffGitHeaderPaths(headerLine);
1775
+ oldPath = parsed.oldPath;
1776
+ newPath = parsed.newPath;
1777
+ }
1778
+ for (const line of lines) {
1779
+ if (line.startsWith("rename from ") || line.startsWith("copy from ")) {
1780
+ renameFrom = normalizeGitPath(line.slice(line.indexOf(" from ") + 6).trim());
1781
+ continue;
1782
+ }
1783
+ if (line.startsWith("rename to ") || line.startsWith("copy to ")) {
1784
+ renameTo = normalizeGitPath(line.slice(line.indexOf(" to ") + 4).trim());
1785
+ continue;
1786
+ }
1787
+ if (line.startsWith("--- ")) {
1788
+ oldPath = decodeGitPatchPathToken(line.slice(4));
1789
+ continue;
1790
+ }
1791
+ if (line.startsWith("+++ ")) newPath = decodeGitPatchPathToken(line.slice(4));
1792
+ }
1793
+ if (renameFrom) oldPath = renameFrom;
1794
+ if (renameTo) newPath = renameTo;
1795
+ if (newPath) {
1796
+ pathCandidates.add(newPath);
1797
+ fileIdCandidates.add(createGitFileId(newPath, null));
1798
+ }
1799
+ if (oldPath) {
1800
+ pathCandidates.add(oldPath);
1801
+ fileIdCandidates.add(createGitFileId(oldPath, null));
1802
+ }
1803
+ if (newPath && oldPath && newPath !== oldPath) fileIdCandidates.add(createGitFileId(newPath, oldPath));
1804
+ return {
1805
+ fileIdCandidates: [...fileIdCandidates],
1806
+ pathCandidates: [...pathCandidates]
1807
+ };
1808
+ }
1809
+ function buildTrackedPatchLookup(files, stdout) {
1810
+ const trackedFiles = files.filter((file) => file.source === "tracked");
1811
+ const fileIds = new Set(trackedFiles.map((file) => file.fileId));
1812
+ const fileIdsByPath = /* @__PURE__ */ new Map();
1813
+ for (const file of trackedFiles) {
1814
+ const pathCandidates = new Set([file.path]);
1815
+ if (file.previousPath) pathCandidates.add(file.previousPath);
1816
+ for (const path of pathCandidates) {
1817
+ const current = fileIdsByPath.get(path) ?? [];
1818
+ current.push(file.fileId);
1819
+ fileIdsByPath.set(path, current);
1820
+ }
1821
+ }
1822
+ const patchByFileId = /* @__PURE__ */ new Map();
1823
+ for (const block of splitTrackedPatchBlocks(stdout)) {
1824
+ const identity = resolveTrackedPatchBlockIdentity(block);
1825
+ let matchedFileId = identity.fileIdCandidates.find((fileId) => fileIds.has(fileId)) ?? null;
1826
+ if (!matchedFileId) for (const path of identity.pathCandidates) {
1827
+ const candidates = fileIdsByPath.get(path);
1828
+ if (!candidates || candidates.length === 0) continue;
1829
+ matchedFileId = candidates.find((fileId) => !patchByFileId.has(fileId)) ?? candidates[0] ?? null;
1830
+ if (matchedFileId) break;
1831
+ }
1832
+ if (matchedFileId && !patchByFileId.has(matchedFileId)) patchByFileId.set(matchedFileId, block.trimEnd());
1833
+ }
1834
+ return patchByFileId;
1835
+ }
1836
+ function buildTrackedPatchFile(file, rawPatch, available) {
1837
+ const normalized = normalizePatchState(rawPatch ?? "");
1838
+ return {
1839
+ ...file,
1840
+ patch: available ? normalized.patch : null,
1841
+ state: available ? normalized.state : "unavailable"
1842
+ };
1843
+ }
1844
+ function countPatchLines(file) {
1845
+ return file.patch ? splitPatchLines(file.patch).length : 0;
1846
+ }
1847
+ function projectGitEntryFiles(snapshot, eagerPatchLineBudget) {
1848
+ if (eagerPatchLineBudget <= 0) return {
1849
+ files: snapshot.files,
1850
+ eagerFiles: [],
1851
+ eagerPatchLineBudget,
1852
+ eagerPatchLineCount: 0
1853
+ };
1854
+ const eagerFiles = [];
1855
+ let eagerPatchLineCount = 0;
1856
+ for (const file of snapshot.files) {
1857
+ if (eagerPatchLineCount >= eagerPatchLineBudget) break;
1858
+ const patch = snapshot.patchByFileId.get(file.fileId);
1859
+ if (!patch) continue;
1860
+ eagerFiles.push(patch);
1861
+ eagerPatchLineCount += countPatchLines(patch);
1862
+ }
1863
+ return {
1864
+ files: snapshot.files,
1865
+ eagerFiles,
1866
+ eagerPatchLineBudget,
1867
+ eagerPatchLineCount
1868
+ };
1869
+ }
1870
+ async function buildGitEntrySnapshot(options) {
1871
+ const runGit = options.runGit ?? defaultRunGit;
1872
+ const resolvedProjectDir = resolve(options.projectDir);
1873
+ const shell = await loadGitEntryShell({
1874
+ ...options,
1875
+ projectDir: resolvedProjectDir
1876
+ });
1877
+ if (!shell.entry) return {
1878
+ entry: null,
1879
+ files: [],
1880
+ patchByFileId: /* @__PURE__ */ new Map()
1881
+ };
1882
+ const trackedFiles = shell.files.filter((file) => file.source === "tracked");
1883
+ const trackedPatchPromise = trackedFiles.length > 0 ? runGit(resolvedProjectDir, buildTrackedPatchArgs(options.selector)) : Promise.resolve({
1884
+ ok: true,
1885
+ stdout: ""
1886
+ });
1887
+ const untrackedPatchPromise = Promise.all(shell.files.filter((file) => file.source === "untracked").map(async (file) => [file.fileId, await buildUntrackedPatchFile(resolvedProjectDir, file)]));
1888
+ const [trackedPatchResult, untrackedPatches] = await Promise.all([trackedPatchPromise, untrackedPatchPromise]);
1889
+ const trackedPatchLookup = trackedPatchResult.ok ? buildTrackedPatchLookup(shell.files, trackedPatchResult.stdout) : /* @__PURE__ */ new Map();
1890
+ const patchByFileId = new Map(untrackedPatches);
1891
+ for (const file of trackedFiles) patchByFileId.set(file.fileId, buildTrackedPatchFile(file, trackedPatchLookup.get(file.fileId) ?? null, trackedPatchResult.ok));
1892
+ return {
1893
+ entry: shell.entry,
1894
+ files: shell.files,
1895
+ patchByFileId
1896
+ };
1897
+ }
1898
+ async function buildGitWorktreeOverview(options) {
1899
+ const resolvedProjectDir = resolve(options.projectDir);
1900
+ return getCachedGitPanelValue("overview", resolvedProjectDir, "overview", async () => {
1901
+ const runGit = options.runGit ?? defaultRunGit;
1902
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
1903
+ const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
1904
+ const summaries = await Promise.all(worktrees.map((worktree) => collectWorktreeSummary({
1905
+ projectDir: resolvedProjectDir,
1906
+ worktree,
1907
+ defaultBranch,
1908
+ runGit
1909
+ })));
1910
+ summaries.sort((left, right) => {
1911
+ if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
1912
+ return left.branchName.localeCompare(right.branchName);
1913
+ });
1914
+ return {
1915
+ defaultBranch,
1916
+ currentWorktree: summaries.find((worktree) => worktree.isCurrent) ?? null,
1917
+ otherWorktrees: summaries.filter((worktree) => !worktree.isCurrent)
1918
+ };
1919
+ });
1920
+ }
1921
+ async function listCurrentWorktreeGitEntries(options) {
1922
+ const resolvedProjectDir = resolve(options.projectDir);
1923
+ const limit = clampEntryLimit(options.limit);
1924
+ const offset = parseCursor(options.cursor);
1925
+ return getCachedGitPanelValue("entries", resolvedProjectDir, `entries:${offset}:${limit}`, async () => {
1926
+ const runGit = options.runGit ?? defaultRunGit;
1927
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
1928
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
1929
+ const uncommitted = await collectUncommittedEntrySummary({
1930
+ worktreePath: resolvedProjectDir,
1931
+ runGit,
1932
+ readPathTimestampMs
1933
+ });
1934
+ const includeUncommitted = offset === 0 && uncommitted.diff.files > 0;
1935
+ const commitLimit = includeUncommitted ? Math.max(0, limit - 1) : limit;
1936
+ const commitsPage = commitLimit > 0 ? await listGitCommitEntriesPage({
1937
+ worktreePath: resolvedProjectDir,
1938
+ defaultBranch,
1939
+ offset,
1940
+ limit: commitLimit,
1941
+ runGit
1942
+ }) : {
1943
+ items: [],
1944
+ nextCursor: null
1945
+ };
1946
+ return {
1947
+ items: includeUncommitted ? [uncommitted, ...commitsPage.items] : commitsPage.items,
1948
+ nextCursor: commitsPage.nextCursor
1949
+ };
1950
+ });
1951
+ }
1952
+ async function getCurrentWorktreeGitEntryShell(options) {
1953
+ const resolvedProjectDir = resolve(options.projectDir);
1954
+ return getCachedGitPanelValue("shell", resolvedProjectDir, buildSelectorCacheKey(options.selector), () => loadGitEntryShell({
1955
+ ...options,
1956
+ projectDir: resolvedProjectDir
1957
+ }));
1958
+ }
1959
+ async function getCurrentWorktreeGitEntryMeta(options) {
1960
+ const resolvedProjectDir = resolve(options.projectDir);
1961
+ return getCachedGitPanelValue("meta", resolvedProjectDir, buildSelectorCacheKey(options.selector), async () => {
1962
+ return (await getCurrentWorktreeGitEntryShell({
1963
+ ...options,
1964
+ projectDir: resolvedProjectDir
1965
+ })).entry;
1966
+ });
1967
+ }
1968
+ async function getCurrentWorktreeGitEntrySnapshot(options) {
1969
+ const resolvedProjectDir = resolve(options.projectDir);
1970
+ return getCachedGitPanelValue("snapshot", resolvedProjectDir, buildSelectorCacheKey(options.selector), () => buildGitEntrySnapshot({
1971
+ ...options,
1972
+ projectDir: resolvedProjectDir
1973
+ }));
1974
+ }
1975
+ async function getCurrentWorktreeGitEntryFiles(options) {
1976
+ return projectGitEntryFiles(await getCurrentWorktreeGitEntrySnapshot(options), options.eagerPatchLineBudget);
1977
+ }
1978
+ async function getCurrentWorktreeGitEntryPatch(options) {
1979
+ return { file: (await getCurrentWorktreeGitEntrySnapshot(options)).patchByFileId.get(options.fileId) ?? null };
1980
+ }
1981
+
1250
1982
  //#endregion
1251
1983
  //#region src/reactive-kv.ts
1252
1984
  /**
@@ -1373,6 +2105,10 @@ const OPSX_CORE_PROFILE_WORKFLOWS = [
1373
2105
  "apply",
1374
2106
  "archive"
1375
2107
  ];
2108
+ const gitEntrySelectorSchema = z.discriminatedUnion("type", [z.object({ type: z.literal("uncommitted") }), z.object({
2109
+ type: z.literal("commit"),
2110
+ hash: z.string().min(1)
2111
+ })]);
1376
2112
  function requireChangeId(changeId) {
1377
2113
  if (!changeId) throw new Error("change is required");
1378
2114
  return changeId;
@@ -1755,18 +2491,20 @@ const configRouter = router({
1755
2491
  codeEditor: z.object({ theme: CodeEditorThemeSchema.optional() }).optional(),
1756
2492
  appBaseUrl: z.string().optional(),
1757
2493
  terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
1758
- dashboard: DashboardConfigSchema.partial().optional()
2494
+ dashboard: DashboardConfigSchema.partial().optional(),
2495
+ git: GitConfigSchema.partial().optional()
1759
2496
  })).mutation(async ({ ctx, input }) => {
1760
2497
  const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
1761
2498
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
1762
2499
  if (hasCliCommand && !hasCliArgs) {
1763
2500
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
1764
- if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
2501
+ if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0 || input.git !== void 0) await ctx.configManager.writeConfig({
1765
2502
  theme: input.theme,
1766
2503
  codeEditor: input.codeEditor,
1767
2504
  appBaseUrl: input.appBaseUrl,
1768
2505
  terminal: input.terminal,
1769
- dashboard: input.dashboard
2506
+ dashboard: input.dashboard,
2507
+ git: input.git
1770
2508
  });
1771
2509
  return { success: true };
1772
2510
  }
@@ -2317,11 +3055,65 @@ const dashboardRouter = router({
2317
3055
  });
2318
3056
  })
2319
3057
  });
3058
+ const gitRouter = router({
3059
+ overview: publicProcedure.query(async ({ ctx }) => {
3060
+ return buildGitWorktreeOverview({ projectDir: ctx.projectDir });
3061
+ }),
3062
+ listEntries: publicProcedure.input(z.object({
3063
+ cursor: z.string().optional(),
3064
+ limit: z.number().int().min(1).max(100).optional()
3065
+ }).optional()).query(async ({ ctx, input }) => {
3066
+ return listCurrentWorktreeGitEntries({
3067
+ projectDir: ctx.projectDir,
3068
+ cursor: input?.cursor,
3069
+ limit: input?.limit
3070
+ });
3071
+ }),
3072
+ getEntryMeta: publicProcedure.input(z.object({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
3073
+ return getCurrentWorktreeGitEntryMeta({
3074
+ projectDir: ctx.projectDir,
3075
+ selector: input.selector
3076
+ });
3077
+ }),
3078
+ getEntryFiles: publicProcedure.input(z.object({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
3079
+ const config = await ctx.configManager.readConfig();
3080
+ return getCurrentWorktreeGitEntryFiles({
3081
+ projectDir: ctx.projectDir,
3082
+ selector: input.selector,
3083
+ eagerPatchLineBudget: config.git.diffEagerLineBudget
3084
+ });
3085
+ }),
3086
+ getEntryPatch: publicProcedure.input(z.object({
3087
+ selector: gitEntrySelectorSchema,
3088
+ fileId: z.string().min(1)
3089
+ })).query(async ({ ctx, input }) => {
3090
+ return getCurrentWorktreeGitEntryPatch({
3091
+ projectDir: ctx.projectDir,
3092
+ selector: input.selector,
3093
+ fileId: input.fileId
3094
+ });
3095
+ }),
3096
+ switchWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
3097
+ if (!ctx.gitWorktreeHandoff) throw new Error("Worktree handoff is unavailable in this runtime.");
3098
+ const overview = await buildGitWorktreeOverview({ projectDir: ctx.projectDir });
3099
+ const resolvedInputPath = resolve(input.path);
3100
+ let target = null;
3101
+ if (overview.currentWorktree && await sameGitPath(overview.currentWorktree.path, resolvedInputPath)) target = overview.currentWorktree;
3102
+ else for (const worktree of overview.otherWorktrees) if (await sameGitPath(worktree.path, resolvedInputPath)) {
3103
+ target = worktree;
3104
+ break;
3105
+ }
3106
+ if (!target) throw new Error("Worktree not found.");
3107
+ if (!target.pathAvailable) throw new Error("Worktree path is no longer available. Remove the stale worktree entry first.");
3108
+ return ctx.gitWorktreeHandoff.ensureWorktreeServer({ targetPath: target.path });
3109
+ })
3110
+ });
2320
3111
  /**
2321
3112
  * Main app router
2322
3113
  */
2323
3114
  const appRouter = router({
2324
3115
  dashboard: dashboardRouter,
3116
+ git: gitRouter,
2325
3117
  spec: specRouter,
2326
3118
  change: changeRouter,
2327
3119
  archive: archiveRouter,
@@ -2535,6 +3327,7 @@ function createServer(config) {
2535
3327
  kernel,
2536
3328
  searchService,
2537
3329
  dashboardOverviewService,
3330
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2538
3331
  watcher,
2539
3332
  projectDir: config.projectDir
2540
3333
  })
@@ -2547,6 +3340,7 @@ function createServer(config) {
2547
3340
  kernel,
2548
3341
  searchService,
2549
3342
  dashboardOverviewService,
3343
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2550
3344
  watcher,
2551
3345
  projectDir: config.projectDir
2552
3346
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "2.1.7",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
@@ -37,7 +37,7 @@
37
37
  "tsdown": "^0.16.6",
38
38
  "tsx": "^4.19.2",
39
39
  "typescript": "^5.7.2",
40
- "vitest": "^2.1.8"
40
+ "vitest": "^4.1.0"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",