@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.
- package/dist/index.mjs +726 -86
- 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/
|
|
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
|
|
290
|
-
const
|
|
291
|
-
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;
|
|
292
376
|
const commits = await runGit(worktreePath, [
|
|
293
377
|
"log",
|
|
294
|
-
"--format=%H%x1f%s",
|
|
295
|
-
|
|
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)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
});
|