@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.
- package/dist/index.mjs +890 -96
- 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/
|
|
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
|
|
298
|
-
const
|
|
299
|
-
const
|
|
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
|
-
|
|
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)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
327
|
-
committedAt:
|
|
328
|
-
relatedChanges: parseRelatedChanges(
|
|
329
|
-
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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.
|
|
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": "^
|
|
40
|
+
"vitest": "^4.1.0"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|