@openspecui/server 2.1.5 → 2.2.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 +726 -86
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -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,
@@ -178,6 +178,22 @@ async function defaultRunGit(cwd, args) {
178
178
  };
179
179
  }
180
180
  }
181
+ async function defaultReadPathTimestampMs(absolutePath) {
182
+ try {
183
+ const stats = await stat(absolutePath);
184
+ return Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0 ? stats.mtimeMs : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+ async function pathExists(absolutePath) {
190
+ try {
191
+ await stat(absolutePath);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
181
197
  function parseShortStat(output) {
182
198
  const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
183
199
  const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
@@ -188,33 +204,48 @@ function parseShortStat(output) {
188
204
  deletions: Number.isFinite(deletions) ? deletions : 0
189
205
  };
190
206
  }
191
- function parseNumStat(output) {
192
- let files = 0;
193
- let insertions = 0;
194
- let deletions = 0;
195
- for (const line of output.split("\n")) {
196
- const trimmed = line.trim();
197
- if (!trimmed) continue;
198
- const [addRaw, deleteRaw] = trimmed.split(" ");
199
- if (!addRaw || !deleteRaw) continue;
200
- files += 1;
201
- if (addRaw !== "-") insertions += Number(addRaw) || 0;
202
- if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
203
- }
204
- return {
205
- files,
206
- insertions,
207
- deletions
208
- };
209
- }
210
207
  function normalizeGitPath(path) {
211
208
  return path.replace(/\\/g, "/").replace(/^\.\//, "");
212
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
+ }
213
232
  function relativePath(fromDir, target) {
214
233
  const rel = relative(fromDir, target);
215
234
  if (!rel || rel.length === 0) return ".";
216
235
  return rel;
217
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
+ }
218
249
  function parseBranchName(branchRef, detached) {
219
250
  if (detached) return "(detached)";
220
251
  if (!branchRef) return "(unknown)";
@@ -243,10 +274,7 @@ function parseWorktreeList(porcelain) {
243
274
  current.branchRef = line.slice(7).trim();
244
275
  continue;
245
276
  }
246
- if (line === "detached") {
247
- current.detached = true;
248
- continue;
249
- }
277
+ if (line === "detached") current.detached = true;
250
278
  }
251
279
  flush();
252
280
  return entries;
@@ -255,16 +283,17 @@ function parseRelatedChanges(paths) {
255
283
  const related = /* @__PURE__ */ new Set();
256
284
  for (const path of paths) {
257
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
+ }
258
292
  const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
259
293
  if (activeMatch?.[1]) {
260
294
  related.add(activeMatch[1]);
261
295
  continue;
262
296
  }
263
- const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
264
- if (archiveMatch?.[1]) {
265
- const fullName = archiveMatch[1];
266
- related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
267
- }
268
297
  }
269
298
  return [...related].sort((a, b) => a.localeCompare(b));
270
299
  }
@@ -286,39 +315,112 @@ async function resolveDefaultBranch(projectDir, runGit) {
286
315
  if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
287
316
  return "main";
288
317
  }
289
- async function collectCommitEntries(options) {
290
- const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
291
- const entries = [];
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;
292
376
  const commits = await runGit(worktreePath, [
293
377
  "log",
294
- "--format=%H%x1f%s",
295
- `-n${maxCommitEntries}`,
378
+ "--format=%x1e%H%x1f%ct%x1f%s",
379
+ "--numstat",
380
+ `--skip=${offset}`,
381
+ `-n${limit + 1}`,
296
382
  `${defaultBranch}..HEAD`
297
383
  ]);
298
- if (commits.ok) for (const line of commits.stdout.split("\n")) {
299
- if (!line.trim()) continue;
300
- const [hash, title = ""] = line.split("");
301
- if (!hash) continue;
302
- const diffResult = await runGit(worktreePath, [
303
- "show",
304
- "--numstat",
305
- "--format=",
306
- hash
307
- ]);
308
- const changedFiles = (await runGit(worktreePath, [
309
- "show",
310
- "--name-only",
311
- "--format=",
312
- hash
313
- ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
314
- entries.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) => ({
315
391
  type: "commit",
316
- hash,
317
- title: title.trim() || hash.slice(0, 7),
318
- relatedChanges: parseRelatedChanges(changedFiles),
319
- diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
320
- });
321
- }
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;
322
424
  const trackedResult = await runGit(worktreePath, [
323
425
  "diff",
324
426
  "--numstat",
@@ -336,24 +438,57 @@ async function collectCommitEntries(options) {
336
438
  ]);
337
439
  const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
338
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;
339
442
  const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
340
- const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
341
- entries.push({
443
+ return {
342
444
  type: "uncommitted",
343
445
  title: "Uncommitted",
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,
344
450
  relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
345
451
  diff: {
346
452
  files: allUncommittedFiles.size,
347
453
  insertions: trackedDiff.insertions,
348
454
  deletions: trackedDiff.deletions
349
455
  }
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
350
484
  });
351
- return entries;
352
485
  }
353
486
  async function collectWorktree(options) {
354
- const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
487
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries, readPathTimestampMs } = options;
355
488
  const worktreePath = resolve(worktree.path);
356
489
  const resolvedProjectDir = resolve(projectDir);
490
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
491
+ const pathAvailable = await pathExists(worktreePath);
357
492
  const aheadBehindResult = await runGit(worktreePath, [
358
493
  "rev-list",
359
494
  "--left-right",
@@ -377,14 +512,16 @@ async function collectWorktree(options) {
377
512
  worktreePath,
378
513
  defaultBranch,
379
514
  maxCommitEntries,
380
- runGit
515
+ runGit,
516
+ readPathTimestampMs
381
517
  });
382
518
  return {
383
519
  path: worktreePath,
384
520
  relativePath: relativePath(resolvedProjectDir, worktreePath),
521
+ pathAvailable,
385
522
  branchName: parseBranchName(worktree.branchRef, worktree.detached),
386
523
  detached: worktree.detached,
387
- isCurrent: resolvedProjectDir === worktreePath,
524
+ isCurrent,
388
525
  ahead,
389
526
  behind,
390
527
  diff,
@@ -395,14 +532,13 @@ async function removeDetachedDashboardGitWorktree(options) {
395
532
  const runGit = options.runGit ?? defaultRunGit;
396
533
  const resolvedProjectDir = resolve(options.projectDir);
397
534
  const resolvedTargetPath = resolve(options.targetPath);
398
- if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
399
- const worktreeResult = await runGit(resolvedProjectDir, [
400
- "worktree",
401
- "list",
402
- "--porcelain"
403
- ]);
404
- if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
405
- 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
+ }
406
542
  if (!matched) throw new Error("Worktree not found.");
407
543
  if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
408
544
  if (!(await runGit(resolvedProjectDir, [
@@ -415,25 +551,17 @@ async function removeDetachedDashboardGitWorktree(options) {
415
551
  async function buildDashboardGitSnapshot(options) {
416
552
  const runGit = options.runGit ?? defaultRunGit;
417
553
  const maxCommitEntries = options.maxCommitEntries ?? 8;
554
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
418
555
  const resolvedProjectDir = resolve(options.projectDir);
419
556
  const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
420
- const worktreeResult = await runGit(resolvedProjectDir, [
421
- "worktree",
422
- "list",
423
- "--porcelain"
424
- ]);
425
- const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
426
- const baseWorktrees = parsed.length > 0 ? parsed : [{
427
- path: resolvedProjectDir,
428
- branchRef: null,
429
- detached: false
430
- }];
557
+ const baseWorktrees = await listGitWorktrees(resolvedProjectDir, runGit);
431
558
  const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
432
559
  projectDir: resolvedProjectDir,
433
560
  worktree,
434
561
  defaultBranch,
435
562
  runGit,
436
- maxCommitEntries
563
+ maxCommitEntries,
564
+ readPathTimestampMs
437
565
  })));
438
566
  worktrees.sort((a, b) => {
439
567
  if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
@@ -1226,6 +1354,457 @@ function createCliStreamObservable(startStream) {
1226
1354
  });
1227
1355
  }
1228
1356
 
1357
+ //#endregion
1358
+ //#region src/git-panel-cache.ts
1359
+ const gitPanelCaches = {
1360
+ overview: /* @__PURE__ */ new Map(),
1361
+ entries: /* @__PURE__ */ new Map(),
1362
+ shell: /* @__PURE__ */ new Map(),
1363
+ patch: /* @__PURE__ */ new Map()
1364
+ };
1365
+ function buildCacheKey(projectDir, key) {
1366
+ return `${resolve(projectDir)}::${key}`;
1367
+ }
1368
+ function getCacheVersion() {
1369
+ return getDashboardGitTaskStatus().lastFinishedAt ?? 0;
1370
+ }
1371
+ async function getCachedGitPanelValue(scope, projectDir, key, load) {
1372
+ const cache = gitPanelCaches[scope];
1373
+ const cacheKey = buildCacheKey(projectDir, key);
1374
+ const version = getCacheVersion();
1375
+ const hit = cache.get(cacheKey);
1376
+ if (hit && hit.version === version) return hit.value;
1377
+ const value = await load();
1378
+ cache.set(cacheKey, {
1379
+ version,
1380
+ value
1381
+ });
1382
+ return value;
1383
+ }
1384
+
1385
+ //#endregion
1386
+ //#region src/git-panel-data.ts
1387
+ const DEFAULT_ENTRY_PAGE_SIZE = 50;
1388
+ const MAX_ENTRY_PAGE_SIZE = 100;
1389
+ const MAX_PATCH_BYTES = 2e5;
1390
+ const MAX_SYNTHETIC_TEXT_BYTES = 2e5;
1391
+ function clampEntryLimit(limit) {
1392
+ if (!Number.isFinite(limit)) return DEFAULT_ENTRY_PAGE_SIZE;
1393
+ return Math.max(1, Math.min(MAX_ENTRY_PAGE_SIZE, Math.trunc(limit ?? DEFAULT_ENTRY_PAGE_SIZE)));
1394
+ }
1395
+ function parseCursor(cursor) {
1396
+ const value = Number(cursor);
1397
+ if (!Number.isFinite(value) || value < 0) return 0;
1398
+ return Math.trunc(value);
1399
+ }
1400
+ function createGitFileId(path, previousPath) {
1401
+ return JSON.stringify([previousPath ?? null, path]);
1402
+ }
1403
+ function parseGitNameStatus(stdout) {
1404
+ const entries = [];
1405
+ for (const line of stdout.split("\n")) {
1406
+ const trimmed = line.trim();
1407
+ if (!trimmed) continue;
1408
+ const parts = trimmed.split(" ");
1409
+ const normalized = (parts[0] ?? "")[0] ?? "";
1410
+ if (!normalized) continue;
1411
+ if ((normalized === "R" || normalized === "C") && parts.length >= 3) {
1412
+ entries.push({
1413
+ previousPath: parts[1] ?? null,
1414
+ path: parts[2] ?? "",
1415
+ changeType: normalized === "R" ? "renamed" : "copied"
1416
+ });
1417
+ continue;
1418
+ }
1419
+ if (parts.length < 2) continue;
1420
+ entries.push({
1421
+ previousPath: null,
1422
+ path: parts[1] ?? "",
1423
+ changeType: normalized === "A" ? "added" : normalized === "M" ? "modified" : normalized === "D" ? "deleted" : normalized === "T" ? "typechanged" : normalized === "U" ? "unmerged" : "unknown"
1424
+ });
1425
+ }
1426
+ return entries;
1427
+ }
1428
+ function parseNumStatMap(stdout) {
1429
+ const diffByPath = /* @__PURE__ */ new Map();
1430
+ for (const line of stdout.split("\n")) {
1431
+ const trimmed = line.trim();
1432
+ if (!trimmed) continue;
1433
+ const parts = trimmed.split(" ");
1434
+ if (parts.length < 3) continue;
1435
+ const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
1436
+ const rawPath = pathParts.join(" ").trim();
1437
+ const diff = {
1438
+ files: 1,
1439
+ insertions: insertionsRaw === "-" ? 0 : Number(insertionsRaw) || 0,
1440
+ deletions: deletionsRaw === "-" ? 0 : Number(deletionsRaw) || 0
1441
+ };
1442
+ for (const path of extractGitPathVariants(rawPath)) diffByPath.set(path, diff);
1443
+ }
1444
+ return diffByPath;
1445
+ }
1446
+ function resolveTrackedDiff(diffByPath, status) {
1447
+ return diffByPath.get(status.path) ?? (status.previousPath ? diffByPath.get(status.previousPath) : void 0) ?? {
1448
+ files: 1,
1449
+ insertions: 0,
1450
+ deletions: 0
1451
+ };
1452
+ }
1453
+ function readyFileDiff(diff) {
1454
+ return {
1455
+ state: "ready",
1456
+ ...diff
1457
+ };
1458
+ }
1459
+ function loadingFileDiff(files = 1) {
1460
+ return {
1461
+ state: "loading",
1462
+ files
1463
+ };
1464
+ }
1465
+ function unavailableFileDiff(files = 1) {
1466
+ return {
1467
+ state: "unavailable",
1468
+ files
1469
+ };
1470
+ }
1471
+ function buildTrackedFileSummaries(statuses, numStatOutput) {
1472
+ const diffByPath = parseNumStatMap(numStatOutput);
1473
+ return statuses.map((status) => ({
1474
+ fileId: createGitFileId(status.path, status.previousPath),
1475
+ source: "tracked",
1476
+ path: status.path,
1477
+ displayPath: status.previousPath ? `${status.previousPath} -> ${status.path}` : status.path,
1478
+ previousPath: status.previousPath,
1479
+ changeType: status.changeType,
1480
+ diff: readyFileDiff(resolveTrackedDiff(diffByPath, status))
1481
+ })).sort((left, right) => left.path.localeCompare(right.path));
1482
+ }
1483
+ function buildUntrackedFileSummary(path) {
1484
+ return {
1485
+ fileId: createGitFileId(path, null),
1486
+ source: "untracked",
1487
+ path,
1488
+ displayPath: path,
1489
+ previousPath: null,
1490
+ changeType: "added",
1491
+ diff: loadingFileDiff()
1492
+ };
1493
+ }
1494
+ async function collectWorktreeSummary(options) {
1495
+ const { projectDir, worktree, defaultBranch, runGit } = options;
1496
+ const worktreePath = resolve(worktree.path);
1497
+ const resolvedProjectDir = resolve(projectDir);
1498
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
1499
+ const pathAvailable = await pathExists(worktreePath);
1500
+ const aheadBehindResult = await runGit(worktreePath, [
1501
+ "rev-list",
1502
+ "--left-right",
1503
+ "--count",
1504
+ `${defaultBranch}...HEAD`
1505
+ ]);
1506
+ let ahead = 0;
1507
+ let behind = 0;
1508
+ if (aheadBehindResult.ok) {
1509
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
1510
+ ahead = Number(aheadRaw) || 0;
1511
+ behind = Number(behindRaw) || 0;
1512
+ }
1513
+ const diffResult = await runGit(worktreePath, [
1514
+ "diff",
1515
+ "--shortstat",
1516
+ `${defaultBranch}...HEAD`
1517
+ ]);
1518
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
1519
+ return {
1520
+ path: worktreePath,
1521
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
1522
+ pathAvailable,
1523
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
1524
+ detached: worktree.detached,
1525
+ isCurrent,
1526
+ ahead,
1527
+ behind,
1528
+ diff
1529
+ };
1530
+ }
1531
+ function normalizePatchState(patch) {
1532
+ const trimmed = patch.trimEnd();
1533
+ if (!trimmed) return {
1534
+ state: "unavailable",
1535
+ patch: null
1536
+ };
1537
+ if (/^GIT binary patch$/m.test(trimmed) || /^Binary files .* differ$/m.test(trimmed)) return {
1538
+ state: "binary",
1539
+ patch: null
1540
+ };
1541
+ if (Buffer.byteLength(trimmed, "utf8") > MAX_PATCH_BYTES) return {
1542
+ state: "too-large",
1543
+ patch: null
1544
+ };
1545
+ return {
1546
+ state: "available",
1547
+ patch: trimmed
1548
+ };
1549
+ }
1550
+ function splitPatchLines(text) {
1551
+ if (!text) return [];
1552
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
1553
+ if (lines.at(-1) === "") lines.pop();
1554
+ return lines;
1555
+ }
1556
+ async function buildTrackedPatchFile(options) {
1557
+ const { worktreePath, file, runGit, selector } = options;
1558
+ const patchResult = await runGit(worktreePath, selector.type === "commit" ? [
1559
+ "show",
1560
+ "--patch",
1561
+ "--find-renames",
1562
+ "--format=",
1563
+ selector.hash,
1564
+ "--",
1565
+ file.path
1566
+ ] : [
1567
+ "diff",
1568
+ "--patch",
1569
+ "--find-renames",
1570
+ "HEAD",
1571
+ "--",
1572
+ file.path
1573
+ ]);
1574
+ const normalized = normalizePatchState(patchResult.stdout);
1575
+ return {
1576
+ ...file,
1577
+ patch: normalized.patch,
1578
+ state: patchResult.ok ? normalized.state : "unavailable"
1579
+ };
1580
+ }
1581
+ async function buildUntrackedPatchFile(worktreePath, file) {
1582
+ try {
1583
+ const buffer = await readFile(resolve(worktreePath, file.path));
1584
+ if (buffer.byteLength > MAX_SYNTHETIC_TEXT_BYTES) return {
1585
+ ...file,
1586
+ diff: unavailableFileDiff(),
1587
+ patch: null,
1588
+ state: "too-large"
1589
+ };
1590
+ if (buffer.includes(0)) return {
1591
+ ...file,
1592
+ diff: unavailableFileDiff(),
1593
+ patch: null,
1594
+ state: "binary"
1595
+ };
1596
+ const lines = splitPatchLines(buffer.toString("utf8"));
1597
+ const hunkHeader = lines.length > 0 ? `@@ -0,0 +1,${lines.length} @@` : null;
1598
+ const body = lines.map((line) => `+${line}`);
1599
+ const patch = [
1600
+ `diff --git a/${file.path} b/${file.path}`,
1601
+ "new file mode 100644",
1602
+ "--- /dev/null",
1603
+ `+++ b/${file.path}`,
1604
+ ...hunkHeader ? [hunkHeader] : [],
1605
+ ...body
1606
+ ].join("\n");
1607
+ return {
1608
+ ...file,
1609
+ diff: readyFileDiff({
1610
+ files: 1,
1611
+ insertions: lines.length,
1612
+ deletions: 0
1613
+ }),
1614
+ patch: patch.trimEnd(),
1615
+ state: "available"
1616
+ };
1617
+ } catch {
1618
+ return {
1619
+ ...file,
1620
+ diff: unavailableFileDiff(),
1621
+ patch: null,
1622
+ state: "unavailable"
1623
+ };
1624
+ }
1625
+ }
1626
+ async function buildCommitShell(options) {
1627
+ const { worktreePath, hash, runGit } = options;
1628
+ const [entry, nameStatusResult, numStatResult] = await Promise.all([
1629
+ readGitCommitEntryByHash({
1630
+ worktreePath,
1631
+ hash,
1632
+ runGit
1633
+ }),
1634
+ runGit(worktreePath, [
1635
+ "show",
1636
+ "--name-status",
1637
+ "--find-renames",
1638
+ "--format=",
1639
+ hash
1640
+ ]),
1641
+ runGit(worktreePath, [
1642
+ "show",
1643
+ "--numstat",
1644
+ "--format=",
1645
+ hash
1646
+ ])
1647
+ ]);
1648
+ if (!entry) return {
1649
+ entry: null,
1650
+ files: []
1651
+ };
1652
+ return {
1653
+ entry,
1654
+ files: buildTrackedFileSummaries(nameStatusResult.ok ? parseGitNameStatus(nameStatusResult.stdout) : [], numStatResult.stdout)
1655
+ };
1656
+ }
1657
+ async function buildUncommittedShell(options) {
1658
+ const { worktreePath, runGit, readPathTimestampMs } = options;
1659
+ const [entry, trackedStatusResult, trackedNumStatResult, untrackedResult] = await Promise.all([
1660
+ collectUncommittedEntrySummary({
1661
+ worktreePath,
1662
+ runGit,
1663
+ readPathTimestampMs
1664
+ }),
1665
+ runGit(worktreePath, [
1666
+ "diff",
1667
+ "--name-status",
1668
+ "--find-renames",
1669
+ "HEAD"
1670
+ ]),
1671
+ runGit(worktreePath, [
1672
+ "diff",
1673
+ "--numstat",
1674
+ "HEAD"
1675
+ ]),
1676
+ runGit(worktreePath, [
1677
+ "ls-files",
1678
+ "--others",
1679
+ "--exclude-standard"
1680
+ ])
1681
+ ]);
1682
+ const trackedFiles = buildTrackedFileSummaries(trackedStatusResult.ok ? parseGitNameStatus(trackedStatusResult.stdout) : [], trackedNumStatResult.stdout);
1683
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0).map((path) => buildUntrackedFileSummary(path));
1684
+ return {
1685
+ entry,
1686
+ files: [...trackedFiles, ...untrackedFiles].sort((left, right) => left.path.localeCompare(right.path))
1687
+ };
1688
+ }
1689
+ async function loadGitEntryShell(options) {
1690
+ const runGit = options.runGit ?? defaultRunGit;
1691
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
1692
+ const resolvedProjectDir = resolve(options.projectDir);
1693
+ if (options.selector.type === "uncommitted") return buildUncommittedShell({
1694
+ worktreePath: resolvedProjectDir,
1695
+ runGit,
1696
+ readPathTimestampMs
1697
+ });
1698
+ return buildCommitShell({
1699
+ worktreePath: resolvedProjectDir,
1700
+ hash: options.selector.hash,
1701
+ runGit
1702
+ });
1703
+ }
1704
+ async function buildGitWorktreeOverview(options) {
1705
+ const resolvedProjectDir = resolve(options.projectDir);
1706
+ return getCachedGitPanelValue("overview", resolvedProjectDir, "overview", async () => {
1707
+ const runGit = options.runGit ?? defaultRunGit;
1708
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
1709
+ const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
1710
+ const summaries = await Promise.all(worktrees.map((worktree) => collectWorktreeSummary({
1711
+ projectDir: resolvedProjectDir,
1712
+ worktree,
1713
+ defaultBranch,
1714
+ runGit
1715
+ })));
1716
+ summaries.sort((left, right) => {
1717
+ if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
1718
+ return left.branchName.localeCompare(right.branchName);
1719
+ });
1720
+ return {
1721
+ defaultBranch,
1722
+ currentWorktree: summaries.find((worktree) => worktree.isCurrent) ?? null,
1723
+ otherWorktrees: summaries.filter((worktree) => !worktree.isCurrent)
1724
+ };
1725
+ });
1726
+ }
1727
+ async function listCurrentWorktreeGitEntries(options) {
1728
+ const resolvedProjectDir = resolve(options.projectDir);
1729
+ const limit = clampEntryLimit(options.limit);
1730
+ const offset = parseCursor(options.cursor);
1731
+ return getCachedGitPanelValue("entries", resolvedProjectDir, `entries:${offset}:${limit}`, async () => {
1732
+ const runGit = options.runGit ?? defaultRunGit;
1733
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
1734
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
1735
+ const uncommitted = await collectUncommittedEntrySummary({
1736
+ worktreePath: resolvedProjectDir,
1737
+ runGit,
1738
+ readPathTimestampMs
1739
+ });
1740
+ const includeUncommitted = offset === 0 && uncommitted.diff.files > 0;
1741
+ const commitLimit = includeUncommitted ? Math.max(0, limit - 1) : limit;
1742
+ const commitsPage = commitLimit > 0 ? await listGitCommitEntriesPage({
1743
+ worktreePath: resolvedProjectDir,
1744
+ defaultBranch,
1745
+ offset,
1746
+ limit: commitLimit,
1747
+ runGit
1748
+ }) : {
1749
+ items: [],
1750
+ nextCursor: null
1751
+ };
1752
+ return {
1753
+ items: includeUncommitted ? [uncommitted, ...commitsPage.items] : commitsPage.items,
1754
+ nextCursor: commitsPage.nextCursor
1755
+ };
1756
+ });
1757
+ }
1758
+ async function getCurrentWorktreeGitEntryShell(options) {
1759
+ const resolvedProjectDir = resolve(options.projectDir);
1760
+ return getCachedGitPanelValue("shell", resolvedProjectDir, options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted", () => loadGitEntryShell({
1761
+ ...options,
1762
+ projectDir: resolvedProjectDir
1763
+ }));
1764
+ }
1765
+ async function getCurrentWorktreeGitEntryPatch(options) {
1766
+ const resolvedProjectDir = resolve(options.projectDir);
1767
+ return getCachedGitPanelValue("patch", resolvedProjectDir, `${options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted"}:${options.fileId}`, async () => {
1768
+ const runGit = options.runGit ?? defaultRunGit;
1769
+ const shell = await getCurrentWorktreeGitEntryShell({
1770
+ ...options,
1771
+ projectDir: resolvedProjectDir
1772
+ });
1773
+ const file = shell.files.find((candidate) => candidate.fileId === options.fileId) ?? null;
1774
+ if (!shell.entry || !file) return {
1775
+ entry: shell.entry,
1776
+ file: null
1777
+ };
1778
+ const patch = file.source === "untracked" ? await buildUntrackedPatchFile(resolvedProjectDir, file) : await buildTrackedPatchFile({
1779
+ worktreePath: resolvedProjectDir,
1780
+ file,
1781
+ runGit,
1782
+ selector: options.selector
1783
+ });
1784
+ return {
1785
+ entry: shell.entry,
1786
+ file: patch
1787
+ };
1788
+ });
1789
+ }
1790
+ async function getCurrentWorktreeGitEntryDetail(options) {
1791
+ const shell = await getCurrentWorktreeGitEntryShell(options);
1792
+ if (!shell.entry) return {
1793
+ entry: null,
1794
+ files: []
1795
+ };
1796
+ const patches = await Promise.all(shell.files.map(async (file) => {
1797
+ return (await getCurrentWorktreeGitEntryPatch({
1798
+ ...options,
1799
+ fileId: file.fileId
1800
+ })).file;
1801
+ }));
1802
+ return {
1803
+ entry: shell.entry,
1804
+ files: patches.filter((file) => file !== null)
1805
+ };
1806
+ }
1807
+
1229
1808
  //#endregion
1230
1809
  //#region src/reactive-kv.ts
1231
1810
  /**
@@ -1352,6 +1931,10 @@ const OPSX_CORE_PROFILE_WORKFLOWS = [
1352
1931
  "apply",
1353
1932
  "archive"
1354
1933
  ];
1934
+ const gitEntrySelectorSchema = z.discriminatedUnion("type", [z.object({ type: z.literal("uncommitted") }), z.object({
1935
+ type: z.literal("commit"),
1936
+ hash: z.string().min(1)
1937
+ })]);
1355
1938
  function requireChangeId(changeId) {
1356
1939
  if (!changeId) throw new Error("change is required");
1357
1940
  return changeId;
@@ -1598,6 +2181,9 @@ const changeRouter = router({
1598
2181
  if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
1599
2182
  return { success: true };
1600
2183
  }),
2184
+ subscribe: publicProcedure.subscription(({ ctx }) => {
2185
+ return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
2186
+ }),
1601
2187
  subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
1602
2188
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
1603
2189
  })
@@ -2262,8 +2848,8 @@ const dashboardRouter = router({
2262
2848
  }),
2263
2849
  refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
2264
2850
  const reason = input?.reason?.trim() || "manual-refresh";
2265
- await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
2266
2851
  await ctx.dashboardOverviewService.refresh(reason);
2852
+ await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
2267
2853
  return { success: true };
2268
2854
  }),
2269
2855
  removeDetachedWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
@@ -2272,8 +2858,8 @@ const dashboardRouter = router({
2272
2858
  projectDir: ctx.projectDir,
2273
2859
  targetPath: input.path
2274
2860
  });
2275
- await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
2276
2861
  await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
2862
+ await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
2277
2863
  return { success: true };
2278
2864
  }),
2279
2865
  gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
@@ -2293,11 +2879,63 @@ const dashboardRouter = router({
2293
2879
  });
2294
2880
  })
2295
2881
  });
2882
+ const gitRouter = router({
2883
+ overview: publicProcedure.query(async ({ ctx }) => {
2884
+ return buildGitWorktreeOverview({ projectDir: ctx.projectDir });
2885
+ }),
2886
+ listEntries: publicProcedure.input(z.object({
2887
+ cursor: z.string().optional(),
2888
+ limit: z.number().int().min(1).max(100).optional()
2889
+ }).optional()).query(async ({ ctx, input }) => {
2890
+ return listCurrentWorktreeGitEntries({
2891
+ projectDir: ctx.projectDir,
2892
+ cursor: input?.cursor,
2893
+ limit: input?.limit
2894
+ });
2895
+ }),
2896
+ getEntryDetail: publicProcedure.input(z.object({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
2897
+ return getCurrentWorktreeGitEntryDetail({
2898
+ projectDir: ctx.projectDir,
2899
+ selector: input.selector
2900
+ });
2901
+ }),
2902
+ getEntryShell: publicProcedure.input(z.object({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
2903
+ return getCurrentWorktreeGitEntryShell({
2904
+ projectDir: ctx.projectDir,
2905
+ selector: input.selector
2906
+ });
2907
+ }),
2908
+ getEntryPatch: publicProcedure.input(z.object({
2909
+ selector: gitEntrySelectorSchema,
2910
+ fileId: z.string().min(1)
2911
+ })).query(async ({ ctx, input }) => {
2912
+ return getCurrentWorktreeGitEntryPatch({
2913
+ projectDir: ctx.projectDir,
2914
+ selector: input.selector,
2915
+ fileId: input.fileId
2916
+ });
2917
+ }),
2918
+ switchWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
2919
+ if (!ctx.gitWorktreeHandoff) throw new Error("Worktree handoff is unavailable in this runtime.");
2920
+ const overview = await buildGitWorktreeOverview({ projectDir: ctx.projectDir });
2921
+ const resolvedInputPath = resolve(input.path);
2922
+ let target = null;
2923
+ if (overview.currentWorktree && await sameGitPath(overview.currentWorktree.path, resolvedInputPath)) target = overview.currentWorktree;
2924
+ else for (const worktree of overview.otherWorktrees) if (await sameGitPath(worktree.path, resolvedInputPath)) {
2925
+ target = worktree;
2926
+ break;
2927
+ }
2928
+ if (!target) throw new Error("Worktree not found.");
2929
+ if (!target.pathAvailable) throw new Error("Worktree path is no longer available. Remove the stale worktree entry first.");
2930
+ return ctx.gitWorktreeHandoff.ensureWorktreeServer({ targetPath: target.path });
2931
+ })
2932
+ });
2296
2933
  /**
2297
2934
  * Main app router
2298
2935
  */
2299
2936
  const appRouter = router({
2300
2937
  dashboard: dashboardRouter,
2938
+ git: gitRouter,
2301
2939
  spec: specRouter,
2302
2940
  change: changeRouter,
2303
2941
  archive: archiveRouter,
@@ -2511,6 +3149,7 @@ function createServer(config) {
2511
3149
  kernel,
2512
3150
  searchService,
2513
3151
  dashboardOverviewService,
3152
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2514
3153
  watcher,
2515
3154
  projectDir: config.projectDir
2516
3155
  })
@@ -2523,6 +3162,7 @@ function createServer(config) {
2523
3162
  kernel,
2524
3163
  searchService,
2525
3164
  dashboardOverviewService,
3165
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2526
3166
  watcher,
2527
3167
  projectDir: config.projectDir
2528
3168
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {