@openspecui/server 2.1.7 → 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 +708 -92
  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,
@@ -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,457 @@ 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
+ 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
+
1250
1808
  //#endregion
1251
1809
  //#region src/reactive-kv.ts
1252
1810
  /**
@@ -1373,6 +1931,10 @@ const OPSX_CORE_PROFILE_WORKFLOWS = [
1373
1931
  "apply",
1374
1932
  "archive"
1375
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
+ })]);
1376
1938
  function requireChangeId(changeId) {
1377
1939
  if (!changeId) throw new Error("change is required");
1378
1940
  return changeId;
@@ -2317,11 +2879,63 @@ const dashboardRouter = router({
2317
2879
  });
2318
2880
  })
2319
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
+ });
2320
2933
  /**
2321
2934
  * Main app router
2322
2935
  */
2323
2936
  const appRouter = router({
2324
2937
  dashboard: dashboardRouter,
2938
+ git: gitRouter,
2325
2939
  spec: specRouter,
2326
2940
  change: changeRouter,
2327
2941
  archive: archiveRouter,
@@ -2535,6 +3149,7 @@ function createServer(config) {
2535
3149
  kernel,
2536
3150
  searchService,
2537
3151
  dashboardOverviewService,
3152
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2538
3153
  watcher,
2539
3154
  projectDir: config.projectDir
2540
3155
  })
@@ -2547,6 +3162,7 @@ function createServer(config) {
2547
3162
  kernel,
2548
3163
  searchService,
2549
3164
  dashboardOverviewService,
3165
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
2550
3166
  watcher,
2551
3167
  projectDir: config.projectDir
2552
3168
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "2.1.7",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {