openspecui 2.1.6 → 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/cli.mjs +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{src-DglsnBxF.mjs → src-dyNiaV8K.mjs} +1014 -94
- package/package.json +1 -1
- package/web/assets/CanvasRenderer-DisPlVVA.js +1 -0
- package/web/assets/WebGLRenderer-j4aqaGuJ.js +1 -0
- package/web/assets/WebGPURenderer-C5mY_Uw_.js +1 -0
- package/web/assets/browserAll-D8jeUZfq.js +1 -0
- package/web/assets/{dist-CQFpUBQu.js → dist-B-x9wXEa.js} +1 -1
- package/web/assets/{dist-DH4wkw64.js → dist-BGFc5nci.js} +1 -1
- package/web/assets/{dist-Bqlcw5p5.js → dist-ByFny60l.js} +1 -1
- package/web/assets/{dist-DZNziC27.js → dist-C7ewDjBY.js} +1 -1
- package/web/assets/dist-CCf7ZJGC.js +1 -0
- package/web/assets/{dist-DHt45678.js → dist-Cl5X7UXK.js} +1 -1
- package/web/assets/{dist-BVuO0PNY.js → dist-D26tMoy6.js} +1 -1
- package/web/assets/{dist-CxW4GGvi.js → dist-DVMpqbsN.js} +1 -1
- package/web/assets/{dist-BcS7MN40.js → dist-DbQb6N54.js} +1 -1
- package/web/assets/dist-DthZUjqM.js +1 -0
- package/web/assets/{dist-KeQ1cUcv.js → dist-QOeZTTdR.js} +1 -1
- package/web/assets/dist-QmD-tUHG.js +1 -0
- package/web/assets/{ghostty-web-DhqZ931y.js → ghostty-web-DZdQ5Qjf.js} +1 -1
- package/web/assets/index-BAKtl8O1.js +1548 -0
- package/web/assets/index-BrLPBk4f.css +1 -0
- package/web/assets/{init-CUcnIfzq.js → init-DfT5ivE3.js} +1 -1
- package/web/assets/trpc-Bs3tbH0-.js +1 -0
- package/web/assets/webworkerAll-Du63riS6.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/CanvasRenderer-DvQQ868U.js +0 -1
- package/web/assets/WebGLRenderer-Btkopy84.js +0 -1
- package/web/assets/WebGPURenderer-mbRLbuQ3.js +0 -1
- package/web/assets/browserAll-BN5cmbzO.js +0 -1
- package/web/assets/dist-CWn3VEyl.js +0 -1
- package/web/assets/dist-DgZXq45Q.js +0 -1
- package/web/assets/dist-RktJObPI.js +0 -1
- package/web/assets/index-DeFiKoWH.css +0 -1
- package/web/assets/index-XY2gI2Cr.js +0 -1541
- package/web/assets/trpc-Bc5K_hrC.js +0 -1
- package/web/assets/webworkerAll-BeYSpTrq.js +0 -1
|
@@ -7,17 +7,17 @@ import crypto from "crypto";
|
|
|
7
7
|
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
8
8
|
import { dirname, join } from "path";
|
|
9
9
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
-
import { mkdir as mkdir$1, readFile as readFile$1, readdir, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
|
|
10
|
+
import { mkdir as mkdir$1, readFile as readFile$1, readdir, realpath, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
|
|
11
11
|
import { basename as basename$1, dirname as dirname$1, join as join$1, matchesGlob, relative as relative$1, resolve as resolve$1, sep } from "node:path";
|
|
12
12
|
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import { EventEmitter } from "events";
|
|
14
14
|
import { watch } from "fs";
|
|
15
|
-
import { exec, spawn } from "child_process";
|
|
15
|
+
import { exec, execFile, spawn } from "child_process";
|
|
16
16
|
import { promisify } from "util";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { EventEmitter as EventEmitter$1 } from "node:events";
|
|
20
|
-
import { execFile } from "node:child_process";
|
|
20
|
+
import { execFile as execFile$1, spawn as spawn$1 } from "node:child_process";
|
|
21
21
|
import { promisify as promisify$1 } from "node:util";
|
|
22
22
|
import * as pty from "@lydell/node-pty";
|
|
23
23
|
import { Worker as Worker$1 } from "node:worker_threads";
|
|
@@ -5919,6 +5919,7 @@ var OpenSpecWatcher = class extends EventEmitter {
|
|
|
5919
5919
|
//#endregion
|
|
5920
5920
|
//#region ../core/src/config.ts
|
|
5921
5921
|
const execAsync = promisify(exec);
|
|
5922
|
+
const execFileAsync$2 = promisify(execFile);
|
|
5922
5923
|
const CLI_PROBE_TIMEOUT_MS = 2e4;
|
|
5923
5924
|
const THEME_VALUES = [
|
|
5924
5925
|
"light",
|
|
@@ -6078,6 +6079,53 @@ function commandToString(commandParts) {
|
|
|
6078
6079
|
};
|
|
6079
6080
|
return commandParts.map(formatToken).join(" ").trim();
|
|
6080
6081
|
}
|
|
6082
|
+
function isBareExecutableCommand(command) {
|
|
6083
|
+
if (!command) return false;
|
|
6084
|
+
if (command === "." || command === "..") return false;
|
|
6085
|
+
return !/[\\/]/.test(command);
|
|
6086
|
+
}
|
|
6087
|
+
function quotePosixShellArg(value) {
|
|
6088
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
6089
|
+
}
|
|
6090
|
+
async function resolveShellExecutablePath(command, cwd, env) {
|
|
6091
|
+
if (!isBareExecutableCommand(command)) return null;
|
|
6092
|
+
try {
|
|
6093
|
+
if (process.platform === "win32") {
|
|
6094
|
+
const { stdout: stdout$1 } = await execFileAsync$2("where", [command], {
|
|
6095
|
+
cwd,
|
|
6096
|
+
env,
|
|
6097
|
+
encoding: "utf8",
|
|
6098
|
+
timeout: 5e3
|
|
6099
|
+
});
|
|
6100
|
+
return stdout$1.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) || null;
|
|
6101
|
+
}
|
|
6102
|
+
const { stdout } = await execFileAsync$2(env.SHELL || process.env.SHELL || "/bin/sh", ["-lc", `command -v -- ${quotePosixShellArg(command)}`], {
|
|
6103
|
+
cwd,
|
|
6104
|
+
env,
|
|
6105
|
+
encoding: "utf8",
|
|
6106
|
+
timeout: 5e3
|
|
6107
|
+
});
|
|
6108
|
+
return stdout.split("\n").map((line) => line.trim()).find((line) => line.startsWith("/")) || null;
|
|
6109
|
+
} catch {
|
|
6110
|
+
return null;
|
|
6111
|
+
}
|
|
6112
|
+
}
|
|
6113
|
+
async function expandCliRunnerCandidates(candidates, cwd, env) {
|
|
6114
|
+
const expanded = [];
|
|
6115
|
+
for (const candidate of candidates) {
|
|
6116
|
+
const [command, ...rest] = candidate.commandParts;
|
|
6117
|
+
if ((candidate.id === "openspec" || candidate.id === "configured" && command.trim().toLowerCase() === "openspec") && command) {
|
|
6118
|
+
const shellResolved = await resolveShellExecutablePath(command, cwd, env);
|
|
6119
|
+
if (shellResolved && shellResolved !== command) expanded.push({
|
|
6120
|
+
...candidate,
|
|
6121
|
+
source: `${candidate.source} (shell)`,
|
|
6122
|
+
commandParts: [shellResolved, ...rest]
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
expanded.push(candidate);
|
|
6126
|
+
}
|
|
6127
|
+
return expanded;
|
|
6128
|
+
}
|
|
6081
6129
|
function getRunnerPriorityFromUserAgent(userAgent) {
|
|
6082
6130
|
if (!userAgent) return null;
|
|
6083
6131
|
if (userAgent.startsWith("bun")) return "bunx";
|
|
@@ -6183,8 +6231,9 @@ async function probeCliRunner(candidate, cwd, env) {
|
|
|
6183
6231
|
});
|
|
6184
6232
|
}
|
|
6185
6233
|
async function resolveCliRunner(candidates, cwd, env) {
|
|
6234
|
+
const expandedCandidates = await expandCliRunnerCandidates(candidates, cwd, env);
|
|
6186
6235
|
const attempts = [];
|
|
6187
|
-
for (const candidate of
|
|
6236
|
+
for (const candidate of expandedCandidates) {
|
|
6188
6237
|
const attempt = await probeCliRunner(candidate, cwd, env);
|
|
6189
6238
|
attempts.push(attempt);
|
|
6190
6239
|
if (attempt.success) return {
|
|
@@ -6237,7 +6286,13 @@ async function fetchLatestVersion() {
|
|
|
6237
6286
|
* 每次调用都会重新检测,不使用缓存。
|
|
6238
6287
|
*/
|
|
6239
6288
|
async function sniffGlobalCli() {
|
|
6240
|
-
const
|
|
6289
|
+
const env = createCleanCliEnv();
|
|
6290
|
+
const resolvedCommand = await resolveShellExecutablePath("openspec", process.cwd(), env) ?? "openspec";
|
|
6291
|
+
const [localResult, latestVersion] = await Promise.all([execFileAsync$2(resolvedCommand, ["--version"], {
|
|
6292
|
+
env,
|
|
6293
|
+
timeout: 1e4,
|
|
6294
|
+
encoding: "utf8"
|
|
6295
|
+
}).catch((err) => ({ error: err })), fetchLatestVersion()]);
|
|
6241
6296
|
if ("error" in localResult) {
|
|
6242
6297
|
const error = localResult.error instanceof Error ? localResult.error.message : String(localResult.error);
|
|
6243
6298
|
if (error.includes("not found") || error.includes("ENOENT") || error.includes("not recognized")) return {
|
|
@@ -23207,8 +23262,8 @@ function selectRecentDashboardItems(items, limit = DASHBOARD_RECENT_LIST_LIMIT)
|
|
|
23207
23262
|
}
|
|
23208
23263
|
|
|
23209
23264
|
//#endregion
|
|
23210
|
-
//#region ../server/src/
|
|
23211
|
-
const execFileAsync$1 = promisify$1(execFile);
|
|
23265
|
+
//#region ../server/src/git-shared.ts
|
|
23266
|
+
const execFileAsync$1 = promisify$1(execFile$1);
|
|
23212
23267
|
const EMPTY_DIFF = {
|
|
23213
23268
|
files: 0,
|
|
23214
23269
|
insertions: 0,
|
|
@@ -23232,6 +23287,22 @@ async function defaultRunGit(cwd, args) {
|
|
|
23232
23287
|
};
|
|
23233
23288
|
}
|
|
23234
23289
|
}
|
|
23290
|
+
async function defaultReadPathTimestampMs(absolutePath) {
|
|
23291
|
+
try {
|
|
23292
|
+
const stats = await stat(absolutePath);
|
|
23293
|
+
return Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0 ? stats.mtimeMs : null;
|
|
23294
|
+
} catch {
|
|
23295
|
+
return null;
|
|
23296
|
+
}
|
|
23297
|
+
}
|
|
23298
|
+
async function pathExists(absolutePath) {
|
|
23299
|
+
try {
|
|
23300
|
+
await stat(absolutePath);
|
|
23301
|
+
return true;
|
|
23302
|
+
} catch {
|
|
23303
|
+
return false;
|
|
23304
|
+
}
|
|
23305
|
+
}
|
|
23235
23306
|
function parseShortStat(output) {
|
|
23236
23307
|
const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
|
|
23237
23308
|
const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
|
|
@@ -23242,33 +23313,48 @@ function parseShortStat(output) {
|
|
|
23242
23313
|
deletions: Number.isFinite(deletions) ? deletions : 0
|
|
23243
23314
|
};
|
|
23244
23315
|
}
|
|
23245
|
-
function parseNumStat(output) {
|
|
23246
|
-
let files = 0;
|
|
23247
|
-
let insertions = 0;
|
|
23248
|
-
let deletions = 0;
|
|
23249
|
-
for (const line of output.split("\n")) {
|
|
23250
|
-
const trimmed = line.trim();
|
|
23251
|
-
if (!trimmed) continue;
|
|
23252
|
-
const [addRaw, deleteRaw] = trimmed.split(" ");
|
|
23253
|
-
if (!addRaw || !deleteRaw) continue;
|
|
23254
|
-
files += 1;
|
|
23255
|
-
if (addRaw !== "-") insertions += Number(addRaw) || 0;
|
|
23256
|
-
if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
|
|
23257
|
-
}
|
|
23258
|
-
return {
|
|
23259
|
-
files,
|
|
23260
|
-
insertions,
|
|
23261
|
-
deletions
|
|
23262
|
-
};
|
|
23263
|
-
}
|
|
23264
23316
|
function normalizeGitPath(path$1) {
|
|
23265
23317
|
return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
23266
23318
|
}
|
|
23319
|
+
function extractGitPathVariants(rawPath) {
|
|
23320
|
+
const trimmed = rawPath.trim();
|
|
23321
|
+
if (!trimmed) return [];
|
|
23322
|
+
const normalizedRaw = normalizeGitPath(trimmed);
|
|
23323
|
+
const braceRenameMatch = /^(.*?)\{(.*?) => (.*?)\}(.*)$/.exec(trimmed);
|
|
23324
|
+
if (braceRenameMatch) {
|
|
23325
|
+
const [, prefix = "", left = "", right = "", suffix = ""] = braceRenameMatch;
|
|
23326
|
+
const variants = /* @__PURE__ */ new Set();
|
|
23327
|
+
variants.add(normalizeGitPath(`${prefix}${left}${suffix}`));
|
|
23328
|
+
variants.add(normalizeGitPath(`${prefix}${right}${suffix}`));
|
|
23329
|
+
return [...variants];
|
|
23330
|
+
}
|
|
23331
|
+
const renameParts = trimmed.split(" => ");
|
|
23332
|
+
if (renameParts.length === 2) {
|
|
23333
|
+
const [left = "", right = ""] = renameParts;
|
|
23334
|
+
const variants = /* @__PURE__ */ new Set();
|
|
23335
|
+
variants.add(normalizeGitPath(left));
|
|
23336
|
+
variants.add(normalizeGitPath(right));
|
|
23337
|
+
return [...variants];
|
|
23338
|
+
}
|
|
23339
|
+
return [normalizedRaw];
|
|
23340
|
+
}
|
|
23267
23341
|
function relativePath(fromDir, target) {
|
|
23268
23342
|
const rel = relative$1(fromDir, target);
|
|
23269
23343
|
if (!rel || rel.length === 0) return ".";
|
|
23270
23344
|
return rel;
|
|
23271
23345
|
}
|
|
23346
|
+
async function canonicalGitPath(path$1) {
|
|
23347
|
+
const resolved = resolve$1(path$1);
|
|
23348
|
+
try {
|
|
23349
|
+
return await realpath(resolved);
|
|
23350
|
+
} catch {
|
|
23351
|
+
return resolved;
|
|
23352
|
+
}
|
|
23353
|
+
}
|
|
23354
|
+
async function sameGitPath(left, right) {
|
|
23355
|
+
const [canonicalLeft, canonicalRight] = await Promise.all([canonicalGitPath(left), canonicalGitPath(right)]);
|
|
23356
|
+
return canonicalLeft === canonicalRight;
|
|
23357
|
+
}
|
|
23272
23358
|
function parseBranchName(branchRef, detached) {
|
|
23273
23359
|
if (detached) return "(detached)";
|
|
23274
23360
|
if (!branchRef) return "(unknown)";
|
|
@@ -23297,10 +23383,7 @@ function parseWorktreeList(porcelain) {
|
|
|
23297
23383
|
current.branchRef = line.slice(7).trim();
|
|
23298
23384
|
continue;
|
|
23299
23385
|
}
|
|
23300
|
-
if (line === "detached")
|
|
23301
|
-
current.detached = true;
|
|
23302
|
-
continue;
|
|
23303
|
-
}
|
|
23386
|
+
if (line === "detached") current.detached = true;
|
|
23304
23387
|
}
|
|
23305
23388
|
flush();
|
|
23306
23389
|
return entries;
|
|
@@ -23309,16 +23392,17 @@ function parseRelatedChanges(paths) {
|
|
|
23309
23392
|
const related = /* @__PURE__ */ new Set();
|
|
23310
23393
|
for (const path$1 of paths) {
|
|
23311
23394
|
const normalized = normalizeGitPath(path$1);
|
|
23395
|
+
if (normalized.includes("{") || normalized.includes("=>")) continue;
|
|
23396
|
+
const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
|
|
23397
|
+
if (archiveMatch?.[1]) {
|
|
23398
|
+
related.add(archiveMatch[1].replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
23399
|
+
continue;
|
|
23400
|
+
}
|
|
23312
23401
|
const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
|
|
23313
23402
|
if (activeMatch?.[1]) {
|
|
23314
23403
|
related.add(activeMatch[1]);
|
|
23315
23404
|
continue;
|
|
23316
23405
|
}
|
|
23317
|
-
const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
|
|
23318
|
-
if (archiveMatch?.[1]) {
|
|
23319
|
-
const fullName = archiveMatch[1];
|
|
23320
|
-
related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
23321
|
-
}
|
|
23322
23406
|
}
|
|
23323
23407
|
return [...related].sort((a, b) => a.localeCompare(b));
|
|
23324
23408
|
}
|
|
@@ -23340,39 +23424,112 @@ async function resolveDefaultBranch(projectDir, runGit) {
|
|
|
23340
23424
|
if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
|
|
23341
23425
|
return "main";
|
|
23342
23426
|
}
|
|
23343
|
-
async function
|
|
23344
|
-
const
|
|
23345
|
-
const
|
|
23427
|
+
async function listGitWorktrees(projectDir, runGit) {
|
|
23428
|
+
const resolvedProjectDir = resolve$1(projectDir);
|
|
23429
|
+
const worktreeResult = await runGit(resolvedProjectDir, [
|
|
23430
|
+
"worktree",
|
|
23431
|
+
"list",
|
|
23432
|
+
"--porcelain"
|
|
23433
|
+
]);
|
|
23434
|
+
const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
|
|
23435
|
+
if (parsed.length > 0) return parsed;
|
|
23436
|
+
return [{
|
|
23437
|
+
path: resolvedProjectDir,
|
|
23438
|
+
branchRef: null,
|
|
23439
|
+
detached: false
|
|
23440
|
+
}];
|
|
23441
|
+
}
|
|
23442
|
+
|
|
23443
|
+
//#endregion
|
|
23444
|
+
//#region ../server/src/git-entry-summary.ts
|
|
23445
|
+
function createEmptyCommitRecord(hash, committedAt, title) {
|
|
23446
|
+
return {
|
|
23447
|
+
hash,
|
|
23448
|
+
committedAt,
|
|
23449
|
+
title,
|
|
23450
|
+
diff: { ...EMPTY_DIFF },
|
|
23451
|
+
changedPaths: []
|
|
23452
|
+
};
|
|
23453
|
+
}
|
|
23454
|
+
function parseGitLogNumstatRecords(stdout) {
|
|
23455
|
+
const records = [];
|
|
23456
|
+
for (const block of stdout.split("")) {
|
|
23457
|
+
const trimmedBlock = block.trim();
|
|
23458
|
+
if (!trimmedBlock) continue;
|
|
23459
|
+
const lines = trimmedBlock.split("\n");
|
|
23460
|
+
const header = lines.shift()?.trim();
|
|
23461
|
+
if (!header) continue;
|
|
23462
|
+
const [hash, committedAtRaw = "0", title = ""] = header.split("");
|
|
23463
|
+
if (!hash) continue;
|
|
23464
|
+
const committedAtSeconds = Number(committedAtRaw);
|
|
23465
|
+
const record = createEmptyCommitRecord(hash, Number.isFinite(committedAtSeconds) && committedAtSeconds > 0 ? committedAtSeconds * 1e3 : 0, title.trim() || hash.slice(0, 7));
|
|
23466
|
+
for (const line of lines) {
|
|
23467
|
+
const trimmedLine = line.trim();
|
|
23468
|
+
if (!trimmedLine) continue;
|
|
23469
|
+
const parts = trimmedLine.split(" ");
|
|
23470
|
+
if (parts.length < 3) continue;
|
|
23471
|
+
const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
|
|
23472
|
+
const rawPath = pathParts.join(" ").trim();
|
|
23473
|
+
if (!rawPath) continue;
|
|
23474
|
+
record.diff.files += 1;
|
|
23475
|
+
if (insertionsRaw !== "-") record.diff.insertions += Number(insertionsRaw) || 0;
|
|
23476
|
+
if (deletionsRaw !== "-") record.diff.deletions += Number(deletionsRaw) || 0;
|
|
23477
|
+
record.changedPaths.push(...extractGitPathVariants(rawPath));
|
|
23478
|
+
}
|
|
23479
|
+
records.push(record);
|
|
23480
|
+
}
|
|
23481
|
+
return records;
|
|
23482
|
+
}
|
|
23483
|
+
async function listGitCommitEntriesPage(options) {
|
|
23484
|
+
const { worktreePath, defaultBranch, offset, limit, runGit } = options;
|
|
23346
23485
|
const commits = await runGit(worktreePath, [
|
|
23347
23486
|
"log",
|
|
23348
|
-
"--format=%H%x1f%s",
|
|
23349
|
-
|
|
23487
|
+
"--format=%x1e%H%x1f%ct%x1f%s",
|
|
23488
|
+
"--numstat",
|
|
23489
|
+
`--skip=${offset}`,
|
|
23490
|
+
`-n${limit + 1}`,
|
|
23350
23491
|
`${defaultBranch}..HEAD`
|
|
23351
23492
|
]);
|
|
23352
|
-
if (commits.ok)
|
|
23353
|
-
|
|
23354
|
-
|
|
23355
|
-
|
|
23356
|
-
|
|
23357
|
-
|
|
23358
|
-
|
|
23359
|
-
"--format=",
|
|
23360
|
-
hash
|
|
23361
|
-
]);
|
|
23362
|
-
const changedFiles = (await runGit(worktreePath, [
|
|
23363
|
-
"show",
|
|
23364
|
-
"--name-only",
|
|
23365
|
-
"--format=",
|
|
23366
|
-
hash
|
|
23367
|
-
])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
23368
|
-
entries.push({
|
|
23493
|
+
if (!commits.ok) return {
|
|
23494
|
+
items: [],
|
|
23495
|
+
nextCursor: null
|
|
23496
|
+
};
|
|
23497
|
+
const records = parseGitLogNumstatRecords(commits.stdout);
|
|
23498
|
+
return {
|
|
23499
|
+
items: records.slice(0, limit).map((record) => ({
|
|
23369
23500
|
type: "commit",
|
|
23370
|
-
hash,
|
|
23371
|
-
title: title
|
|
23372
|
-
|
|
23373
|
-
|
|
23374
|
-
|
|
23375
|
-
|
|
23501
|
+
hash: record.hash,
|
|
23502
|
+
title: record.title,
|
|
23503
|
+
committedAt: record.committedAt,
|
|
23504
|
+
relatedChanges: parseRelatedChanges(record.changedPaths),
|
|
23505
|
+
diff: record.diff
|
|
23506
|
+
})),
|
|
23507
|
+
nextCursor: records.length > limit ? String(offset + limit) : null
|
|
23508
|
+
};
|
|
23509
|
+
}
|
|
23510
|
+
async function readGitCommitEntryByHash(options) {
|
|
23511
|
+
const { worktreePath, hash, runGit } = options;
|
|
23512
|
+
const result = await runGit(worktreePath, [
|
|
23513
|
+
"show",
|
|
23514
|
+
"--numstat",
|
|
23515
|
+
"--format=%x1e%H%x1f%ct%x1f%s",
|
|
23516
|
+
hash
|
|
23517
|
+
]);
|
|
23518
|
+
if (!result.ok) return null;
|
|
23519
|
+
const record = parseGitLogNumstatRecords(result.stdout)[0];
|
|
23520
|
+
if (!record) return null;
|
|
23521
|
+
return {
|
|
23522
|
+
type: "commit",
|
|
23523
|
+
hash: record.hash,
|
|
23524
|
+
title: record.title,
|
|
23525
|
+
committedAt: record.committedAt,
|
|
23526
|
+
relatedChanges: parseRelatedChanges(record.changedPaths),
|
|
23527
|
+
diff: record.diff
|
|
23528
|
+
};
|
|
23529
|
+
}
|
|
23530
|
+
async function collectUncommittedEntrySummary(options) {
|
|
23531
|
+
const { worktreePath, runGit } = options;
|
|
23532
|
+
const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
|
|
23376
23533
|
const trackedResult = await runGit(worktreePath, [
|
|
23377
23534
|
"diff",
|
|
23378
23535
|
"--numstat",
|
|
@@ -23390,24 +23547,57 @@ async function collectCommitEntries(options) {
|
|
|
23390
23547
|
]);
|
|
23391
23548
|
const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
23392
23549
|
const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
23550
|
+
const trackedDiff = parseGitLogNumstatRecords(`\u001ehead\u001f0\u001fUncommitted\n${trackedResult.stdout}`)[0]?.diff ?? EMPTY_DIFF;
|
|
23393
23551
|
const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
|
|
23394
|
-
|
|
23395
|
-
entries.push({
|
|
23552
|
+
return {
|
|
23396
23553
|
type: "uncommitted",
|
|
23397
23554
|
title: "Uncommitted",
|
|
23555
|
+
updatedAt: (await Promise.all([...allUncommittedFiles].map((path$1) => readPathTimestampMs(resolve$1(worktreePath, path$1))))).reduce((latest, current) => {
|
|
23556
|
+
if (!current || !Number.isFinite(current) || current <= 0) return latest;
|
|
23557
|
+
return latest === null || current > latest ? current : latest;
|
|
23558
|
+
}, null) ?? null,
|
|
23398
23559
|
relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
|
|
23399
23560
|
diff: {
|
|
23400
23561
|
files: allUncommittedFiles.size,
|
|
23401
23562
|
insertions: trackedDiff.insertions,
|
|
23402
23563
|
deletions: trackedDiff.deletions
|
|
23403
23564
|
}
|
|
23565
|
+
};
|
|
23566
|
+
}
|
|
23567
|
+
async function listRecentGitEntries(options) {
|
|
23568
|
+
const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
|
|
23569
|
+
const [uncommitted, commitsPage] = await Promise.all([collectUncommittedEntrySummary({
|
|
23570
|
+
worktreePath,
|
|
23571
|
+
runGit,
|
|
23572
|
+
readPathTimestampMs
|
|
23573
|
+
}), listGitCommitEntriesPage({
|
|
23574
|
+
worktreePath,
|
|
23575
|
+
defaultBranch,
|
|
23576
|
+
offset: 0,
|
|
23577
|
+
limit: maxCommitEntries,
|
|
23578
|
+
runGit
|
|
23579
|
+
})]);
|
|
23580
|
+
return [uncommitted, ...commitsPage.items];
|
|
23581
|
+
}
|
|
23582
|
+
|
|
23583
|
+
//#endregion
|
|
23584
|
+
//#region ../server/src/dashboard-git-snapshot.ts
|
|
23585
|
+
async function collectCommitEntries(options) {
|
|
23586
|
+
const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
|
|
23587
|
+
return listRecentGitEntries({
|
|
23588
|
+
worktreePath,
|
|
23589
|
+
defaultBranch,
|
|
23590
|
+
maxCommitEntries,
|
|
23591
|
+
runGit,
|
|
23592
|
+
readPathTimestampMs
|
|
23404
23593
|
});
|
|
23405
|
-
return entries;
|
|
23406
23594
|
}
|
|
23407
23595
|
async function collectWorktree(options) {
|
|
23408
|
-
const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
|
|
23596
|
+
const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries, readPathTimestampMs } = options;
|
|
23409
23597
|
const worktreePath = resolve$1(worktree.path);
|
|
23410
23598
|
const resolvedProjectDir = resolve$1(projectDir);
|
|
23599
|
+
const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
|
|
23600
|
+
const pathAvailable = await pathExists(worktreePath);
|
|
23411
23601
|
const aheadBehindResult = await runGit(worktreePath, [
|
|
23412
23602
|
"rev-list",
|
|
23413
23603
|
"--left-right",
|
|
@@ -23431,14 +23621,16 @@ async function collectWorktree(options) {
|
|
|
23431
23621
|
worktreePath,
|
|
23432
23622
|
defaultBranch,
|
|
23433
23623
|
maxCommitEntries,
|
|
23434
|
-
runGit
|
|
23624
|
+
runGit,
|
|
23625
|
+
readPathTimestampMs
|
|
23435
23626
|
});
|
|
23436
23627
|
return {
|
|
23437
23628
|
path: worktreePath,
|
|
23438
23629
|
relativePath: relativePath(resolvedProjectDir, worktreePath),
|
|
23630
|
+
pathAvailable,
|
|
23439
23631
|
branchName: parseBranchName(worktree.branchRef, worktree.detached),
|
|
23440
23632
|
detached: worktree.detached,
|
|
23441
|
-
isCurrent
|
|
23633
|
+
isCurrent,
|
|
23442
23634
|
ahead,
|
|
23443
23635
|
behind,
|
|
23444
23636
|
diff,
|
|
@@ -23449,14 +23641,13 @@ async function removeDetachedDashboardGitWorktree(options) {
|
|
|
23449
23641
|
const runGit = options.runGit ?? defaultRunGit;
|
|
23450
23642
|
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
23451
23643
|
const resolvedTargetPath = resolve$1(options.targetPath);
|
|
23452
|
-
if (resolvedTargetPath
|
|
23453
|
-
const
|
|
23454
|
-
|
|
23455
|
-
|
|
23456
|
-
|
|
23457
|
-
|
|
23458
|
-
|
|
23459
|
-
const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve$1(worktree.path) === resolvedTargetPath);
|
|
23644
|
+
if (await sameGitPath(resolvedTargetPath, resolvedProjectDir)) throw new Error("Cannot remove the current worktree.");
|
|
23645
|
+
const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
|
|
23646
|
+
let matched;
|
|
23647
|
+
for (const worktree of worktrees) if (await sameGitPath(worktree.path, resolvedTargetPath)) {
|
|
23648
|
+
matched = worktree;
|
|
23649
|
+
break;
|
|
23650
|
+
}
|
|
23460
23651
|
if (!matched) throw new Error("Worktree not found.");
|
|
23461
23652
|
if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
|
|
23462
23653
|
if (!(await runGit(resolvedProjectDir, [
|
|
@@ -23469,25 +23660,17 @@ async function removeDetachedDashboardGitWorktree(options) {
|
|
|
23469
23660
|
async function buildDashboardGitSnapshot(options) {
|
|
23470
23661
|
const runGit = options.runGit ?? defaultRunGit;
|
|
23471
23662
|
const maxCommitEntries = options.maxCommitEntries ?? 8;
|
|
23663
|
+
const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
|
|
23472
23664
|
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
23473
23665
|
const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
|
|
23474
|
-
const
|
|
23475
|
-
"worktree",
|
|
23476
|
-
"list",
|
|
23477
|
-
"--porcelain"
|
|
23478
|
-
]);
|
|
23479
|
-
const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
|
|
23480
|
-
const baseWorktrees = parsed.length > 0 ? parsed : [{
|
|
23481
|
-
path: resolvedProjectDir,
|
|
23482
|
-
branchRef: null,
|
|
23483
|
-
detached: false
|
|
23484
|
-
}];
|
|
23666
|
+
const baseWorktrees = await listGitWorktrees(resolvedProjectDir, runGit);
|
|
23485
23667
|
const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
|
|
23486
23668
|
projectDir: resolvedProjectDir,
|
|
23487
23669
|
worktree,
|
|
23488
23670
|
defaultBranch,
|
|
23489
23671
|
runGit,
|
|
23490
|
-
maxCommitEntries
|
|
23672
|
+
maxCommitEntries,
|
|
23673
|
+
readPathTimestampMs
|
|
23491
23674
|
})));
|
|
23492
23675
|
worktrees.sort((a, b) => {
|
|
23493
23676
|
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
|
|
@@ -23592,7 +23775,7 @@ function buildDashboardTimeTrends(options) {
|
|
|
23592
23775
|
|
|
23593
23776
|
//#endregion
|
|
23594
23777
|
//#region ../server/src/dashboard-overview.ts
|
|
23595
|
-
const execFileAsync = promisify$1(execFile);
|
|
23778
|
+
const execFileAsync = promisify$1(execFile$1);
|
|
23596
23779
|
const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
|
|
23597
23780
|
const dashboardGitTaskStatusEmitter = new EventEmitter$1();
|
|
23598
23781
|
dashboardGitTaskStatusEmitter.setMaxListeners(200);
|
|
@@ -24510,6 +24693,457 @@ function createCliStreamObservable(startStream) {
|
|
|
24510
24693
|
});
|
|
24511
24694
|
}
|
|
24512
24695
|
|
|
24696
|
+
//#endregion
|
|
24697
|
+
//#region ../server/src/git-panel-cache.ts
|
|
24698
|
+
const gitPanelCaches = {
|
|
24699
|
+
overview: /* @__PURE__ */ new Map(),
|
|
24700
|
+
entries: /* @__PURE__ */ new Map(),
|
|
24701
|
+
shell: /* @__PURE__ */ new Map(),
|
|
24702
|
+
patch: /* @__PURE__ */ new Map()
|
|
24703
|
+
};
|
|
24704
|
+
function buildCacheKey(projectDir, key) {
|
|
24705
|
+
return `${resolve$1(projectDir)}::${key}`;
|
|
24706
|
+
}
|
|
24707
|
+
function getCacheVersion() {
|
|
24708
|
+
return getDashboardGitTaskStatus().lastFinishedAt ?? 0;
|
|
24709
|
+
}
|
|
24710
|
+
async function getCachedGitPanelValue(scope, projectDir, key, load) {
|
|
24711
|
+
const cache = gitPanelCaches[scope];
|
|
24712
|
+
const cacheKey$1 = buildCacheKey(projectDir, key);
|
|
24713
|
+
const version = getCacheVersion();
|
|
24714
|
+
const hit = cache.get(cacheKey$1);
|
|
24715
|
+
if (hit && hit.version === version) return hit.value;
|
|
24716
|
+
const value = await load();
|
|
24717
|
+
cache.set(cacheKey$1, {
|
|
24718
|
+
version,
|
|
24719
|
+
value
|
|
24720
|
+
});
|
|
24721
|
+
return value;
|
|
24722
|
+
}
|
|
24723
|
+
|
|
24724
|
+
//#endregion
|
|
24725
|
+
//#region ../server/src/git-panel-data.ts
|
|
24726
|
+
const DEFAULT_ENTRY_PAGE_SIZE = 50;
|
|
24727
|
+
const MAX_ENTRY_PAGE_SIZE = 100;
|
|
24728
|
+
const MAX_PATCH_BYTES = 2e5;
|
|
24729
|
+
const MAX_SYNTHETIC_TEXT_BYTES = 2e5;
|
|
24730
|
+
function clampEntryLimit(limit) {
|
|
24731
|
+
if (!Number.isFinite(limit)) return DEFAULT_ENTRY_PAGE_SIZE;
|
|
24732
|
+
return Math.max(1, Math.min(MAX_ENTRY_PAGE_SIZE, Math.trunc(limit ?? DEFAULT_ENTRY_PAGE_SIZE)));
|
|
24733
|
+
}
|
|
24734
|
+
function parseCursor(cursor) {
|
|
24735
|
+
const value = Number(cursor);
|
|
24736
|
+
if (!Number.isFinite(value) || value < 0) return 0;
|
|
24737
|
+
return Math.trunc(value);
|
|
24738
|
+
}
|
|
24739
|
+
function createGitFileId(path$1, previousPath) {
|
|
24740
|
+
return JSON.stringify([previousPath ?? null, path$1]);
|
|
24741
|
+
}
|
|
24742
|
+
function parseGitNameStatus(stdout) {
|
|
24743
|
+
const entries = [];
|
|
24744
|
+
for (const line of stdout.split("\n")) {
|
|
24745
|
+
const trimmed = line.trim();
|
|
24746
|
+
if (!trimmed) continue;
|
|
24747
|
+
const parts = trimmed.split(" ");
|
|
24748
|
+
const normalized = (parts[0] ?? "")[0] ?? "";
|
|
24749
|
+
if (!normalized) continue;
|
|
24750
|
+
if ((normalized === "R" || normalized === "C") && parts.length >= 3) {
|
|
24751
|
+
entries.push({
|
|
24752
|
+
previousPath: parts[1] ?? null,
|
|
24753
|
+
path: parts[2] ?? "",
|
|
24754
|
+
changeType: normalized === "R" ? "renamed" : "copied"
|
|
24755
|
+
});
|
|
24756
|
+
continue;
|
|
24757
|
+
}
|
|
24758
|
+
if (parts.length < 2) continue;
|
|
24759
|
+
entries.push({
|
|
24760
|
+
previousPath: null,
|
|
24761
|
+
path: parts[1] ?? "",
|
|
24762
|
+
changeType: normalized === "A" ? "added" : normalized === "M" ? "modified" : normalized === "D" ? "deleted" : normalized === "T" ? "typechanged" : normalized === "U" ? "unmerged" : "unknown"
|
|
24763
|
+
});
|
|
24764
|
+
}
|
|
24765
|
+
return entries;
|
|
24766
|
+
}
|
|
24767
|
+
function parseNumStatMap(stdout) {
|
|
24768
|
+
const diffByPath = /* @__PURE__ */ new Map();
|
|
24769
|
+
for (const line of stdout.split("\n")) {
|
|
24770
|
+
const trimmed = line.trim();
|
|
24771
|
+
if (!trimmed) continue;
|
|
24772
|
+
const parts = trimmed.split(" ");
|
|
24773
|
+
if (parts.length < 3) continue;
|
|
24774
|
+
const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
|
|
24775
|
+
const rawPath = pathParts.join(" ").trim();
|
|
24776
|
+
const diff = {
|
|
24777
|
+
files: 1,
|
|
24778
|
+
insertions: insertionsRaw === "-" ? 0 : Number(insertionsRaw) || 0,
|
|
24779
|
+
deletions: deletionsRaw === "-" ? 0 : Number(deletionsRaw) || 0
|
|
24780
|
+
};
|
|
24781
|
+
for (const path$1 of extractGitPathVariants(rawPath)) diffByPath.set(path$1, diff);
|
|
24782
|
+
}
|
|
24783
|
+
return diffByPath;
|
|
24784
|
+
}
|
|
24785
|
+
function resolveTrackedDiff(diffByPath, status) {
|
|
24786
|
+
return diffByPath.get(status.path) ?? (status.previousPath ? diffByPath.get(status.previousPath) : void 0) ?? {
|
|
24787
|
+
files: 1,
|
|
24788
|
+
insertions: 0,
|
|
24789
|
+
deletions: 0
|
|
24790
|
+
};
|
|
24791
|
+
}
|
|
24792
|
+
function readyFileDiff(diff) {
|
|
24793
|
+
return {
|
|
24794
|
+
state: "ready",
|
|
24795
|
+
...diff
|
|
24796
|
+
};
|
|
24797
|
+
}
|
|
24798
|
+
function loadingFileDiff(files = 1) {
|
|
24799
|
+
return {
|
|
24800
|
+
state: "loading",
|
|
24801
|
+
files
|
|
24802
|
+
};
|
|
24803
|
+
}
|
|
24804
|
+
function unavailableFileDiff(files = 1) {
|
|
24805
|
+
return {
|
|
24806
|
+
state: "unavailable",
|
|
24807
|
+
files
|
|
24808
|
+
};
|
|
24809
|
+
}
|
|
24810
|
+
function buildTrackedFileSummaries(statuses, numStatOutput) {
|
|
24811
|
+
const diffByPath = parseNumStatMap(numStatOutput);
|
|
24812
|
+
return statuses.map((status) => ({
|
|
24813
|
+
fileId: createGitFileId(status.path, status.previousPath),
|
|
24814
|
+
source: "tracked",
|
|
24815
|
+
path: status.path,
|
|
24816
|
+
displayPath: status.previousPath ? `${status.previousPath} -> ${status.path}` : status.path,
|
|
24817
|
+
previousPath: status.previousPath,
|
|
24818
|
+
changeType: status.changeType,
|
|
24819
|
+
diff: readyFileDiff(resolveTrackedDiff(diffByPath, status))
|
|
24820
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
24821
|
+
}
|
|
24822
|
+
function buildUntrackedFileSummary(path$1) {
|
|
24823
|
+
return {
|
|
24824
|
+
fileId: createGitFileId(path$1, null),
|
|
24825
|
+
source: "untracked",
|
|
24826
|
+
path: path$1,
|
|
24827
|
+
displayPath: path$1,
|
|
24828
|
+
previousPath: null,
|
|
24829
|
+
changeType: "added",
|
|
24830
|
+
diff: loadingFileDiff()
|
|
24831
|
+
};
|
|
24832
|
+
}
|
|
24833
|
+
async function collectWorktreeSummary(options) {
|
|
24834
|
+
const { projectDir, worktree, defaultBranch, runGit } = options;
|
|
24835
|
+
const worktreePath = resolve$1(worktree.path);
|
|
24836
|
+
const resolvedProjectDir = resolve$1(projectDir);
|
|
24837
|
+
const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
|
|
24838
|
+
const pathAvailable = await pathExists(worktreePath);
|
|
24839
|
+
const aheadBehindResult = await runGit(worktreePath, [
|
|
24840
|
+
"rev-list",
|
|
24841
|
+
"--left-right",
|
|
24842
|
+
"--count",
|
|
24843
|
+
`${defaultBranch}...HEAD`
|
|
24844
|
+
]);
|
|
24845
|
+
let ahead = 0;
|
|
24846
|
+
let behind = 0;
|
|
24847
|
+
if (aheadBehindResult.ok) {
|
|
24848
|
+
const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
|
|
24849
|
+
ahead = Number(aheadRaw) || 0;
|
|
24850
|
+
behind = Number(behindRaw) || 0;
|
|
24851
|
+
}
|
|
24852
|
+
const diffResult = await runGit(worktreePath, [
|
|
24853
|
+
"diff",
|
|
24854
|
+
"--shortstat",
|
|
24855
|
+
`${defaultBranch}...HEAD`
|
|
24856
|
+
]);
|
|
24857
|
+
const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
|
|
24858
|
+
return {
|
|
24859
|
+
path: worktreePath,
|
|
24860
|
+
relativePath: relativePath(resolvedProjectDir, worktreePath),
|
|
24861
|
+
pathAvailable,
|
|
24862
|
+
branchName: parseBranchName(worktree.branchRef, worktree.detached),
|
|
24863
|
+
detached: worktree.detached,
|
|
24864
|
+
isCurrent,
|
|
24865
|
+
ahead,
|
|
24866
|
+
behind,
|
|
24867
|
+
diff
|
|
24868
|
+
};
|
|
24869
|
+
}
|
|
24870
|
+
function normalizePatchState(patch) {
|
|
24871
|
+
const trimmed = patch.trimEnd();
|
|
24872
|
+
if (!trimmed) return {
|
|
24873
|
+
state: "unavailable",
|
|
24874
|
+
patch: null
|
|
24875
|
+
};
|
|
24876
|
+
if (/^GIT binary patch$/m.test(trimmed) || /^Binary files .* differ$/m.test(trimmed)) return {
|
|
24877
|
+
state: "binary",
|
|
24878
|
+
patch: null
|
|
24879
|
+
};
|
|
24880
|
+
if (Buffer.byteLength(trimmed, "utf8") > MAX_PATCH_BYTES) return {
|
|
24881
|
+
state: "too-large",
|
|
24882
|
+
patch: null
|
|
24883
|
+
};
|
|
24884
|
+
return {
|
|
24885
|
+
state: "available",
|
|
24886
|
+
patch: trimmed
|
|
24887
|
+
};
|
|
24888
|
+
}
|
|
24889
|
+
function splitPatchLines(text) {
|
|
24890
|
+
if (!text) return [];
|
|
24891
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
24892
|
+
if (lines.at(-1) === "") lines.pop();
|
|
24893
|
+
return lines;
|
|
24894
|
+
}
|
|
24895
|
+
async function buildTrackedPatchFile(options) {
|
|
24896
|
+
const { worktreePath, file, runGit, selector } = options;
|
|
24897
|
+
const patchResult = await runGit(worktreePath, selector.type === "commit" ? [
|
|
24898
|
+
"show",
|
|
24899
|
+
"--patch",
|
|
24900
|
+
"--find-renames",
|
|
24901
|
+
"--format=",
|
|
24902
|
+
selector.hash,
|
|
24903
|
+
"--",
|
|
24904
|
+
file.path
|
|
24905
|
+
] : [
|
|
24906
|
+
"diff",
|
|
24907
|
+
"--patch",
|
|
24908
|
+
"--find-renames",
|
|
24909
|
+
"HEAD",
|
|
24910
|
+
"--",
|
|
24911
|
+
file.path
|
|
24912
|
+
]);
|
|
24913
|
+
const normalized = normalizePatchState(patchResult.stdout);
|
|
24914
|
+
return {
|
|
24915
|
+
...file,
|
|
24916
|
+
patch: normalized.patch,
|
|
24917
|
+
state: patchResult.ok ? normalized.state : "unavailable"
|
|
24918
|
+
};
|
|
24919
|
+
}
|
|
24920
|
+
async function buildUntrackedPatchFile(worktreePath, file) {
|
|
24921
|
+
try {
|
|
24922
|
+
const buffer = await readFile$1(resolve$1(worktreePath, file.path));
|
|
24923
|
+
if (buffer.byteLength > MAX_SYNTHETIC_TEXT_BYTES) return {
|
|
24924
|
+
...file,
|
|
24925
|
+
diff: unavailableFileDiff(),
|
|
24926
|
+
patch: null,
|
|
24927
|
+
state: "too-large"
|
|
24928
|
+
};
|
|
24929
|
+
if (buffer.includes(0)) return {
|
|
24930
|
+
...file,
|
|
24931
|
+
diff: unavailableFileDiff(),
|
|
24932
|
+
patch: null,
|
|
24933
|
+
state: "binary"
|
|
24934
|
+
};
|
|
24935
|
+
const lines = splitPatchLines(buffer.toString("utf8"));
|
|
24936
|
+
const hunkHeader = lines.length > 0 ? `@@ -0,0 +1,${lines.length} @@` : null;
|
|
24937
|
+
const body = lines.map((line) => `+${line}`);
|
|
24938
|
+
const patch = [
|
|
24939
|
+
`diff --git a/${file.path} b/${file.path}`,
|
|
24940
|
+
"new file mode 100644",
|
|
24941
|
+
"--- /dev/null",
|
|
24942
|
+
`+++ b/${file.path}`,
|
|
24943
|
+
...hunkHeader ? [hunkHeader] : [],
|
|
24944
|
+
...body
|
|
24945
|
+
].join("\n");
|
|
24946
|
+
return {
|
|
24947
|
+
...file,
|
|
24948
|
+
diff: readyFileDiff({
|
|
24949
|
+
files: 1,
|
|
24950
|
+
insertions: lines.length,
|
|
24951
|
+
deletions: 0
|
|
24952
|
+
}),
|
|
24953
|
+
patch: patch.trimEnd(),
|
|
24954
|
+
state: "available"
|
|
24955
|
+
};
|
|
24956
|
+
} catch {
|
|
24957
|
+
return {
|
|
24958
|
+
...file,
|
|
24959
|
+
diff: unavailableFileDiff(),
|
|
24960
|
+
patch: null,
|
|
24961
|
+
state: "unavailable"
|
|
24962
|
+
};
|
|
24963
|
+
}
|
|
24964
|
+
}
|
|
24965
|
+
async function buildCommitShell(options) {
|
|
24966
|
+
const { worktreePath, hash, runGit } = options;
|
|
24967
|
+
const [entry, nameStatusResult, numStatResult] = await Promise.all([
|
|
24968
|
+
readGitCommitEntryByHash({
|
|
24969
|
+
worktreePath,
|
|
24970
|
+
hash,
|
|
24971
|
+
runGit
|
|
24972
|
+
}),
|
|
24973
|
+
runGit(worktreePath, [
|
|
24974
|
+
"show",
|
|
24975
|
+
"--name-status",
|
|
24976
|
+
"--find-renames",
|
|
24977
|
+
"--format=",
|
|
24978
|
+
hash
|
|
24979
|
+
]),
|
|
24980
|
+
runGit(worktreePath, [
|
|
24981
|
+
"show",
|
|
24982
|
+
"--numstat",
|
|
24983
|
+
"--format=",
|
|
24984
|
+
hash
|
|
24985
|
+
])
|
|
24986
|
+
]);
|
|
24987
|
+
if (!entry) return {
|
|
24988
|
+
entry: null,
|
|
24989
|
+
files: []
|
|
24990
|
+
};
|
|
24991
|
+
return {
|
|
24992
|
+
entry,
|
|
24993
|
+
files: buildTrackedFileSummaries(nameStatusResult.ok ? parseGitNameStatus(nameStatusResult.stdout) : [], numStatResult.stdout)
|
|
24994
|
+
};
|
|
24995
|
+
}
|
|
24996
|
+
async function buildUncommittedShell(options) {
|
|
24997
|
+
const { worktreePath, runGit, readPathTimestampMs } = options;
|
|
24998
|
+
const [entry, trackedStatusResult, trackedNumStatResult, untrackedResult] = await Promise.all([
|
|
24999
|
+
collectUncommittedEntrySummary({
|
|
25000
|
+
worktreePath,
|
|
25001
|
+
runGit,
|
|
25002
|
+
readPathTimestampMs
|
|
25003
|
+
}),
|
|
25004
|
+
runGit(worktreePath, [
|
|
25005
|
+
"diff",
|
|
25006
|
+
"--name-status",
|
|
25007
|
+
"--find-renames",
|
|
25008
|
+
"HEAD"
|
|
25009
|
+
]),
|
|
25010
|
+
runGit(worktreePath, [
|
|
25011
|
+
"diff",
|
|
25012
|
+
"--numstat",
|
|
25013
|
+
"HEAD"
|
|
25014
|
+
]),
|
|
25015
|
+
runGit(worktreePath, [
|
|
25016
|
+
"ls-files",
|
|
25017
|
+
"--others",
|
|
25018
|
+
"--exclude-standard"
|
|
25019
|
+
])
|
|
25020
|
+
]);
|
|
25021
|
+
const trackedFiles = buildTrackedFileSummaries(trackedStatusResult.ok ? parseGitNameStatus(trackedStatusResult.stdout) : [], trackedNumStatResult.stdout);
|
|
25022
|
+
const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0).map((path$1) => buildUntrackedFileSummary(path$1));
|
|
25023
|
+
return {
|
|
25024
|
+
entry,
|
|
25025
|
+
files: [...trackedFiles, ...untrackedFiles].sort((left, right) => left.path.localeCompare(right.path))
|
|
25026
|
+
};
|
|
25027
|
+
}
|
|
25028
|
+
async function loadGitEntryShell(options) {
|
|
25029
|
+
const runGit = options.runGit ?? defaultRunGit;
|
|
25030
|
+
const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
|
|
25031
|
+
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
25032
|
+
if (options.selector.type === "uncommitted") return buildUncommittedShell({
|
|
25033
|
+
worktreePath: resolvedProjectDir,
|
|
25034
|
+
runGit,
|
|
25035
|
+
readPathTimestampMs
|
|
25036
|
+
});
|
|
25037
|
+
return buildCommitShell({
|
|
25038
|
+
worktreePath: resolvedProjectDir,
|
|
25039
|
+
hash: options.selector.hash,
|
|
25040
|
+
runGit
|
|
25041
|
+
});
|
|
25042
|
+
}
|
|
25043
|
+
async function buildGitWorktreeOverview(options) {
|
|
25044
|
+
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
25045
|
+
return getCachedGitPanelValue("overview", resolvedProjectDir, "overview", async () => {
|
|
25046
|
+
const runGit = options.runGit ?? defaultRunGit;
|
|
25047
|
+
const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
|
|
25048
|
+
const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
|
|
25049
|
+
const summaries = await Promise.all(worktrees.map((worktree) => collectWorktreeSummary({
|
|
25050
|
+
projectDir: resolvedProjectDir,
|
|
25051
|
+
worktree,
|
|
25052
|
+
defaultBranch,
|
|
25053
|
+
runGit
|
|
25054
|
+
})));
|
|
25055
|
+
summaries.sort((left, right) => {
|
|
25056
|
+
if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
|
|
25057
|
+
return left.branchName.localeCompare(right.branchName);
|
|
25058
|
+
});
|
|
25059
|
+
return {
|
|
25060
|
+
defaultBranch,
|
|
25061
|
+
currentWorktree: summaries.find((worktree) => worktree.isCurrent) ?? null,
|
|
25062
|
+
otherWorktrees: summaries.filter((worktree) => !worktree.isCurrent)
|
|
25063
|
+
};
|
|
25064
|
+
});
|
|
25065
|
+
}
|
|
25066
|
+
async function listCurrentWorktreeGitEntries(options) {
|
|
25067
|
+
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
25068
|
+
const limit = clampEntryLimit(options.limit);
|
|
25069
|
+
const offset = parseCursor(options.cursor);
|
|
25070
|
+
return getCachedGitPanelValue("entries", resolvedProjectDir, `entries:${offset}:${limit}`, async () => {
|
|
25071
|
+
const runGit = options.runGit ?? defaultRunGit;
|
|
25072
|
+
const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
|
|
25073
|
+
const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
|
|
25074
|
+
const uncommitted = await collectUncommittedEntrySummary({
|
|
25075
|
+
worktreePath: resolvedProjectDir,
|
|
25076
|
+
runGit,
|
|
25077
|
+
readPathTimestampMs
|
|
25078
|
+
});
|
|
25079
|
+
const includeUncommitted = offset === 0 && uncommitted.diff.files > 0;
|
|
25080
|
+
const commitLimit = includeUncommitted ? Math.max(0, limit - 1) : limit;
|
|
25081
|
+
const commitsPage = commitLimit > 0 ? await listGitCommitEntriesPage({
|
|
25082
|
+
worktreePath: resolvedProjectDir,
|
|
25083
|
+
defaultBranch,
|
|
25084
|
+
offset,
|
|
25085
|
+
limit: commitLimit,
|
|
25086
|
+
runGit
|
|
25087
|
+
}) : {
|
|
25088
|
+
items: [],
|
|
25089
|
+
nextCursor: null
|
|
25090
|
+
};
|
|
25091
|
+
return {
|
|
25092
|
+
items: includeUncommitted ? [uncommitted, ...commitsPage.items] : commitsPage.items,
|
|
25093
|
+
nextCursor: commitsPage.nextCursor
|
|
25094
|
+
};
|
|
25095
|
+
});
|
|
25096
|
+
}
|
|
25097
|
+
async function getCurrentWorktreeGitEntryShell(options) {
|
|
25098
|
+
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
25099
|
+
return getCachedGitPanelValue("shell", resolvedProjectDir, options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted", () => loadGitEntryShell({
|
|
25100
|
+
...options,
|
|
25101
|
+
projectDir: resolvedProjectDir
|
|
25102
|
+
}));
|
|
25103
|
+
}
|
|
25104
|
+
async function getCurrentWorktreeGitEntryPatch(options) {
|
|
25105
|
+
const resolvedProjectDir = resolve$1(options.projectDir);
|
|
25106
|
+
return getCachedGitPanelValue("patch", resolvedProjectDir, `${options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted"}:${options.fileId}`, async () => {
|
|
25107
|
+
const runGit = options.runGit ?? defaultRunGit;
|
|
25108
|
+
const shell = await getCurrentWorktreeGitEntryShell({
|
|
25109
|
+
...options,
|
|
25110
|
+
projectDir: resolvedProjectDir
|
|
25111
|
+
});
|
|
25112
|
+
const file = shell.files.find((candidate) => candidate.fileId === options.fileId) ?? null;
|
|
25113
|
+
if (!shell.entry || !file) return {
|
|
25114
|
+
entry: shell.entry,
|
|
25115
|
+
file: null
|
|
25116
|
+
};
|
|
25117
|
+
const patch = file.source === "untracked" ? await buildUntrackedPatchFile(resolvedProjectDir, file) : await buildTrackedPatchFile({
|
|
25118
|
+
worktreePath: resolvedProjectDir,
|
|
25119
|
+
file,
|
|
25120
|
+
runGit,
|
|
25121
|
+
selector: options.selector
|
|
25122
|
+
});
|
|
25123
|
+
return {
|
|
25124
|
+
entry: shell.entry,
|
|
25125
|
+
file: patch
|
|
25126
|
+
};
|
|
25127
|
+
});
|
|
25128
|
+
}
|
|
25129
|
+
async function getCurrentWorktreeGitEntryDetail(options) {
|
|
25130
|
+
const shell = await getCurrentWorktreeGitEntryShell(options);
|
|
25131
|
+
if (!shell.entry) return {
|
|
25132
|
+
entry: null,
|
|
25133
|
+
files: []
|
|
25134
|
+
};
|
|
25135
|
+
const patches = await Promise.all(shell.files.map(async (file) => {
|
|
25136
|
+
return (await getCurrentWorktreeGitEntryPatch({
|
|
25137
|
+
...options,
|
|
25138
|
+
fileId: file.fileId
|
|
25139
|
+
})).file;
|
|
25140
|
+
}));
|
|
25141
|
+
return {
|
|
25142
|
+
entry: shell.entry,
|
|
25143
|
+
files: patches.filter((file) => file !== null)
|
|
25144
|
+
};
|
|
25145
|
+
}
|
|
25146
|
+
|
|
24513
25147
|
//#endregion
|
|
24514
25148
|
//#region ../server/src/reactive-kv.ts
|
|
24515
25149
|
/**
|
|
@@ -24636,6 +25270,10 @@ const OPSX_CORE_PROFILE_WORKFLOWS = [
|
|
|
24636
25270
|
"apply",
|
|
24637
25271
|
"archive"
|
|
24638
25272
|
];
|
|
25273
|
+
const gitEntrySelectorSchema = discriminatedUnionType("type", [objectType({ type: literalType("uncommitted") }), objectType({
|
|
25274
|
+
type: literalType("commit"),
|
|
25275
|
+
hash: stringType().min(1)
|
|
25276
|
+
})]);
|
|
24639
25277
|
function requireChangeId(changeId) {
|
|
24640
25278
|
if (!changeId) throw new Error("change is required");
|
|
24641
25279
|
return changeId;
|
|
@@ -24882,6 +25520,9 @@ const changeRouter = router({
|
|
|
24882
25520
|
if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
|
|
24883
25521
|
return { success: true };
|
|
24884
25522
|
}),
|
|
25523
|
+
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
25524
|
+
return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
|
|
25525
|
+
}),
|
|
24885
25526
|
subscribeFiles: publicProcedure.input(objectType({ id: stringType() })).subscription(({ ctx, input }) => {
|
|
24886
25527
|
return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
|
|
24887
25528
|
})
|
|
@@ -25546,8 +26187,8 @@ const dashboardRouter = router({
|
|
|
25546
26187
|
}),
|
|
25547
26188
|
refreshGitSnapshot: publicProcedure.input(objectType({ reason: stringType().optional() }).optional()).mutation(async ({ ctx, input }) => {
|
|
25548
26189
|
const reason = input?.reason?.trim() || "manual-refresh";
|
|
25549
|
-
await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
|
|
25550
26190
|
await ctx.dashboardOverviewService.refresh(reason);
|
|
26191
|
+
await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
|
|
25551
26192
|
return { success: true };
|
|
25552
26193
|
}),
|
|
25553
26194
|
removeDetachedWorktree: publicProcedure.input(objectType({ path: stringType().min(1) })).mutation(async ({ ctx, input }) => {
|
|
@@ -25556,8 +26197,8 @@ const dashboardRouter = router({
|
|
|
25556
26197
|
projectDir: ctx.projectDir,
|
|
25557
26198
|
targetPath: input.path
|
|
25558
26199
|
});
|
|
25559
|
-
await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
|
|
25560
26200
|
await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
|
|
26201
|
+
await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
|
|
25561
26202
|
return { success: true };
|
|
25562
26203
|
}),
|
|
25563
26204
|
gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
|
|
@@ -25577,11 +26218,63 @@ const dashboardRouter = router({
|
|
|
25577
26218
|
});
|
|
25578
26219
|
})
|
|
25579
26220
|
});
|
|
26221
|
+
const gitRouter = router({
|
|
26222
|
+
overview: publicProcedure.query(async ({ ctx }) => {
|
|
26223
|
+
return buildGitWorktreeOverview({ projectDir: ctx.projectDir });
|
|
26224
|
+
}),
|
|
26225
|
+
listEntries: publicProcedure.input(objectType({
|
|
26226
|
+
cursor: stringType().optional(),
|
|
26227
|
+
limit: numberType().int().min(1).max(100).optional()
|
|
26228
|
+
}).optional()).query(async ({ ctx, input }) => {
|
|
26229
|
+
return listCurrentWorktreeGitEntries({
|
|
26230
|
+
projectDir: ctx.projectDir,
|
|
26231
|
+
cursor: input?.cursor,
|
|
26232
|
+
limit: input?.limit
|
|
26233
|
+
});
|
|
26234
|
+
}),
|
|
26235
|
+
getEntryDetail: publicProcedure.input(objectType({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
|
|
26236
|
+
return getCurrentWorktreeGitEntryDetail({
|
|
26237
|
+
projectDir: ctx.projectDir,
|
|
26238
|
+
selector: input.selector
|
|
26239
|
+
});
|
|
26240
|
+
}),
|
|
26241
|
+
getEntryShell: publicProcedure.input(objectType({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
|
|
26242
|
+
return getCurrentWorktreeGitEntryShell({
|
|
26243
|
+
projectDir: ctx.projectDir,
|
|
26244
|
+
selector: input.selector
|
|
26245
|
+
});
|
|
26246
|
+
}),
|
|
26247
|
+
getEntryPatch: publicProcedure.input(objectType({
|
|
26248
|
+
selector: gitEntrySelectorSchema,
|
|
26249
|
+
fileId: stringType().min(1)
|
|
26250
|
+
})).query(async ({ ctx, input }) => {
|
|
26251
|
+
return getCurrentWorktreeGitEntryPatch({
|
|
26252
|
+
projectDir: ctx.projectDir,
|
|
26253
|
+
selector: input.selector,
|
|
26254
|
+
fileId: input.fileId
|
|
26255
|
+
});
|
|
26256
|
+
}),
|
|
26257
|
+
switchWorktree: publicProcedure.input(objectType({ path: stringType().min(1) })).mutation(async ({ ctx, input }) => {
|
|
26258
|
+
if (!ctx.gitWorktreeHandoff) throw new Error("Worktree handoff is unavailable in this runtime.");
|
|
26259
|
+
const overview = await buildGitWorktreeOverview({ projectDir: ctx.projectDir });
|
|
26260
|
+
const resolvedInputPath = resolve$1(input.path);
|
|
26261
|
+
let target = null;
|
|
26262
|
+
if (overview.currentWorktree && await sameGitPath(overview.currentWorktree.path, resolvedInputPath)) target = overview.currentWorktree;
|
|
26263
|
+
else for (const worktree of overview.otherWorktrees) if (await sameGitPath(worktree.path, resolvedInputPath)) {
|
|
26264
|
+
target = worktree;
|
|
26265
|
+
break;
|
|
26266
|
+
}
|
|
26267
|
+
if (!target) throw new Error("Worktree not found.");
|
|
26268
|
+
if (!target.pathAvailable) throw new Error("Worktree path is no longer available. Remove the stale worktree entry first.");
|
|
26269
|
+
return ctx.gitWorktreeHandoff.ensureWorktreeServer({ targetPath: target.path });
|
|
26270
|
+
})
|
|
26271
|
+
});
|
|
25580
26272
|
/**
|
|
25581
26273
|
* Main app router
|
|
25582
26274
|
*/
|
|
25583
26275
|
const appRouter = router({
|
|
25584
26276
|
dashboard: dashboardRouter,
|
|
26277
|
+
git: gitRouter,
|
|
25585
26278
|
spec: specRouter,
|
|
25586
26279
|
change: changeRouter,
|
|
25587
26280
|
archive: archiveRouter,
|
|
@@ -25882,6 +26575,7 @@ function createServer$2(config) {
|
|
|
25882
26575
|
kernel,
|
|
25883
26576
|
searchService,
|
|
25884
26577
|
dashboardOverviewService,
|
|
26578
|
+
gitWorktreeHandoff: config.gitWorktreeHandoff,
|
|
25885
26579
|
watcher,
|
|
25886
26580
|
projectDir: config.projectDir
|
|
25887
26581
|
})
|
|
@@ -25894,6 +26588,7 @@ function createServer$2(config) {
|
|
|
25894
26588
|
kernel,
|
|
25895
26589
|
searchService,
|
|
25896
26590
|
dashboardOverviewService,
|
|
26591
|
+
gitWorktreeHandoff: config.gitWorktreeHandoff,
|
|
25897
26592
|
watcher,
|
|
25898
26593
|
projectDir: config.projectDir
|
|
25899
26594
|
});
|
|
@@ -26010,6 +26705,214 @@ function getWebAssetsDirCandidates(runtimeDir) {
|
|
|
26010
26705
|
return [join$1(runtimeDir, "..", "..", "web", "dist"), prodPath];
|
|
26011
26706
|
}
|
|
26012
26707
|
|
|
26708
|
+
//#endregion
|
|
26709
|
+
//#region src/worktree-instance-manager.ts
|
|
26710
|
+
const DEFAULT_CHILD_TIMEOUT_MS = 15e3;
|
|
26711
|
+
const DEFAULT_PORT_START = 3100;
|
|
26712
|
+
const DEFAULT_PORT_ATTEMPTS = 200;
|
|
26713
|
+
function resolveLocalCliWorkspace(runtimeDir) {
|
|
26714
|
+
const repoRoot = resolve$1(runtimeDir, "..", "..", "..");
|
|
26715
|
+
const rootPackageJson = join$1(repoRoot, "package.json");
|
|
26716
|
+
const cliPackageJson = join$1(repoRoot, "packages", "cli", "package.json");
|
|
26717
|
+
const cliSourceEntry = join$1(repoRoot, "packages", "cli", "src", "cli.ts");
|
|
26718
|
+
if (!existsSync(rootPackageJson) || !existsSync(cliPackageJson) || !existsSync(cliSourceEntry)) return null;
|
|
26719
|
+
return {
|
|
26720
|
+
repoRoot,
|
|
26721
|
+
cliPackageDir: join$1(repoRoot, "packages", "cli")
|
|
26722
|
+
};
|
|
26723
|
+
}
|
|
26724
|
+
function resolvePnpmCommand() {
|
|
26725
|
+
return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
26726
|
+
}
|
|
26727
|
+
function createWorktreeServerCommand(options) {
|
|
26728
|
+
const workspace = resolveLocalCliWorkspace(options.runtimeDir);
|
|
26729
|
+
if (workspace) return {
|
|
26730
|
+
command: resolvePnpmCommand(),
|
|
26731
|
+
args: [
|
|
26732
|
+
"--filter",
|
|
26733
|
+
"openspecui",
|
|
26734
|
+
"run",
|
|
26735
|
+
"dev",
|
|
26736
|
+
"--dir",
|
|
26737
|
+
options.projectDir,
|
|
26738
|
+
"--port",
|
|
26739
|
+
String(options.port),
|
|
26740
|
+
"--no-open"
|
|
26741
|
+
],
|
|
26742
|
+
cwd: workspace.repoRoot,
|
|
26743
|
+
env: { ...process.env }
|
|
26744
|
+
};
|
|
26745
|
+
return {
|
|
26746
|
+
command: process.execPath,
|
|
26747
|
+
args: [
|
|
26748
|
+
join$1(options.runtimeDir, "cli.mjs"),
|
|
26749
|
+
"start",
|
|
26750
|
+
options.projectDir,
|
|
26751
|
+
"--port",
|
|
26752
|
+
String(options.port),
|
|
26753
|
+
"--no-open"
|
|
26754
|
+
],
|
|
26755
|
+
cwd: options.projectDir,
|
|
26756
|
+
env: { ...process.env }
|
|
26757
|
+
};
|
|
26758
|
+
}
|
|
26759
|
+
async function waitForServerReady(options) {
|
|
26760
|
+
let exitMessage = null;
|
|
26761
|
+
let startupError = null;
|
|
26762
|
+
options.child.once("error", (error) => {
|
|
26763
|
+
startupError = error;
|
|
26764
|
+
});
|
|
26765
|
+
options.child.once("exit", (code, signal) => {
|
|
26766
|
+
exitMessage = signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`;
|
|
26767
|
+
});
|
|
26768
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
26769
|
+
while (Date.now() < deadline) {
|
|
26770
|
+
if (startupError) throw startupError;
|
|
26771
|
+
if (exitMessage) throw new Error(`Worktree server exited before becoming ready (${exitMessage})`);
|
|
26772
|
+
try {
|
|
26773
|
+
const response = await fetch(`${options.serverUrl}/api/health`, {
|
|
26774
|
+
headers: { accept: "application/json" },
|
|
26775
|
+
cache: "no-store"
|
|
26776
|
+
});
|
|
26777
|
+
if (response.ok) {
|
|
26778
|
+
if ((await response.json()).projectDir === options.projectDir) return;
|
|
26779
|
+
}
|
|
26780
|
+
} catch {}
|
|
26781
|
+
await delay(250);
|
|
26782
|
+
}
|
|
26783
|
+
throw new Error(`Timed out waiting for worktree server at ${options.serverUrl}`);
|
|
26784
|
+
}
|
|
26785
|
+
async function isHealthyInstance(instance) {
|
|
26786
|
+
try {
|
|
26787
|
+
const response = await fetch(`${instance.serverUrl}/api/health`, {
|
|
26788
|
+
headers: { accept: "application/json" },
|
|
26789
|
+
cache: "no-store"
|
|
26790
|
+
});
|
|
26791
|
+
if (!response.ok) return false;
|
|
26792
|
+
return (await response.json()).projectDir === instance.projectDir;
|
|
26793
|
+
} catch {
|
|
26794
|
+
return false;
|
|
26795
|
+
}
|
|
26796
|
+
}
|
|
26797
|
+
function killChildProcess(child, signal) {
|
|
26798
|
+
if (process.platform !== "win32" && child.pid) try {
|
|
26799
|
+
process.kill(-child.pid, signal);
|
|
26800
|
+
return;
|
|
26801
|
+
} catch {}
|
|
26802
|
+
child.kill(signal);
|
|
26803
|
+
}
|
|
26804
|
+
function waitForChildExit(child, timeoutMs) {
|
|
26805
|
+
return new Promise((resolvePromise) => {
|
|
26806
|
+
const timer = setTimeout(() => {
|
|
26807
|
+
cleanup();
|
|
26808
|
+
resolvePromise(false);
|
|
26809
|
+
}, timeoutMs);
|
|
26810
|
+
const onExit = () => {
|
|
26811
|
+
cleanup();
|
|
26812
|
+
resolvePromise(true);
|
|
26813
|
+
};
|
|
26814
|
+
const cleanup = () => {
|
|
26815
|
+
clearTimeout(timer);
|
|
26816
|
+
child.off("exit", onExit);
|
|
26817
|
+
};
|
|
26818
|
+
child.once("exit", onExit);
|
|
26819
|
+
});
|
|
26820
|
+
}
|
|
26821
|
+
async function stopChildProcess(child) {
|
|
26822
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
26823
|
+
killChildProcess(child, "SIGTERM");
|
|
26824
|
+
if (await waitForChildExit(child, 5e3)) return;
|
|
26825
|
+
killChildProcess(child, "SIGKILL");
|
|
26826
|
+
await waitForChildExit(child, 1e3);
|
|
26827
|
+
}
|
|
26828
|
+
function delay(ms) {
|
|
26829
|
+
return new Promise((resolvePromise) => {
|
|
26830
|
+
setTimeout(resolvePromise, ms);
|
|
26831
|
+
});
|
|
26832
|
+
}
|
|
26833
|
+
function createWorktreeInstanceManager(options) {
|
|
26834
|
+
const currentProjectDir = resolve$1(options.currentProjectDir);
|
|
26835
|
+
const instances = /* @__PURE__ */ new Map();
|
|
26836
|
+
const pending = /* @__PURE__ */ new Map();
|
|
26837
|
+
const ensureWorktreeServer = async (input) => {
|
|
26838
|
+
const targetPath = resolve$1(input.targetPath);
|
|
26839
|
+
if (targetPath === currentProjectDir) return {
|
|
26840
|
+
projectDir: currentProjectDir,
|
|
26841
|
+
serverUrl: options.currentServerUrl
|
|
26842
|
+
};
|
|
26843
|
+
const existing = instances.get(targetPath);
|
|
26844
|
+
if (existing && await isHealthyInstance(existing)) {
|
|
26845
|
+
existing.lastUsedAt = Date.now();
|
|
26846
|
+
return {
|
|
26847
|
+
projectDir: existing.projectDir,
|
|
26848
|
+
serverUrl: existing.serverUrl
|
|
26849
|
+
};
|
|
26850
|
+
}
|
|
26851
|
+
if (existing) {
|
|
26852
|
+
instances.delete(targetPath);
|
|
26853
|
+
await stopChildProcess(existing.child);
|
|
26854
|
+
}
|
|
26855
|
+
const pendingInstance = pending.get(targetPath);
|
|
26856
|
+
if (pendingInstance) return pendingInstance;
|
|
26857
|
+
const promise = (async () => {
|
|
26858
|
+
const port = await findAvailablePort(options.preferredPortStart ?? DEFAULT_PORT_START, DEFAULT_PORT_ATTEMPTS);
|
|
26859
|
+
const command = createWorktreeServerCommand({
|
|
26860
|
+
runtimeDir: options.runtimeDir,
|
|
26861
|
+
projectDir: targetPath,
|
|
26862
|
+
port
|
|
26863
|
+
});
|
|
26864
|
+
const child = spawn$1(command.command, command.args, {
|
|
26865
|
+
cwd: command.cwd,
|
|
26866
|
+
env: command.env,
|
|
26867
|
+
stdio: "inherit",
|
|
26868
|
+
detached: process.platform !== "win32"
|
|
26869
|
+
});
|
|
26870
|
+
const serverUrl = `http://localhost:${port}`;
|
|
26871
|
+
try {
|
|
26872
|
+
await waitForServerReady({
|
|
26873
|
+
serverUrl,
|
|
26874
|
+
projectDir: targetPath,
|
|
26875
|
+
child,
|
|
26876
|
+
timeoutMs: options.readinessTimeoutMs ?? DEFAULT_CHILD_TIMEOUT_MS
|
|
26877
|
+
});
|
|
26878
|
+
} catch (error) {
|
|
26879
|
+
await stopChildProcess(child);
|
|
26880
|
+
throw error;
|
|
26881
|
+
}
|
|
26882
|
+
const instance = {
|
|
26883
|
+
projectDir: targetPath,
|
|
26884
|
+
serverUrl,
|
|
26885
|
+
child,
|
|
26886
|
+
lastUsedAt: Date.now()
|
|
26887
|
+
};
|
|
26888
|
+
instances.set(targetPath, instance);
|
|
26889
|
+
child.once("exit", () => {
|
|
26890
|
+
if (instances.get(targetPath)?.child === child) instances.delete(targetPath);
|
|
26891
|
+
});
|
|
26892
|
+
return {
|
|
26893
|
+
projectDir: targetPath,
|
|
26894
|
+
serverUrl
|
|
26895
|
+
};
|
|
26896
|
+
})();
|
|
26897
|
+
pending.set(targetPath, promise);
|
|
26898
|
+
try {
|
|
26899
|
+
return await promise;
|
|
26900
|
+
} finally {
|
|
26901
|
+
pending.delete(targetPath);
|
|
26902
|
+
}
|
|
26903
|
+
};
|
|
26904
|
+
const close = async () => {
|
|
26905
|
+
await Promise.all([...instances.values()].map(async (instance) => {
|
|
26906
|
+
instances.delete(instance.projectDir);
|
|
26907
|
+
await stopChildProcess(instance.child);
|
|
26908
|
+
}));
|
|
26909
|
+
};
|
|
26910
|
+
return {
|
|
26911
|
+
ensureWorktreeServer,
|
|
26912
|
+
close
|
|
26913
|
+
};
|
|
26914
|
+
}
|
|
26915
|
+
|
|
26013
26916
|
//#endregion
|
|
26014
26917
|
//#region src/index.ts
|
|
26015
26918
|
const __dirname = dirname$1(fileURLToPath(import.meta.url));
|
|
@@ -26055,12 +26958,29 @@ function setupStaticFiles(app) {
|
|
|
26055
26958
|
}
|
|
26056
26959
|
async function startServer$1(options = {}) {
|
|
26057
26960
|
const { projectDir = process.cwd(), port = 3100, enableWatcher = true, corsOrigins } = options;
|
|
26058
|
-
|
|
26961
|
+
let worktreeManager = null;
|
|
26962
|
+
const server = await startServer({
|
|
26059
26963
|
projectDir,
|
|
26060
26964
|
port,
|
|
26061
26965
|
enableWatcher,
|
|
26062
|
-
corsOrigins
|
|
26966
|
+
corsOrigins,
|
|
26967
|
+
gitWorktreeHandoff: { ensureWorktreeServer: async ({ targetPath }) => {
|
|
26968
|
+
if (!worktreeManager) throw new Error("Worktree handoff is not ready yet.");
|
|
26969
|
+
return worktreeManager.ensureWorktreeServer({ targetPath });
|
|
26970
|
+
} }
|
|
26063
26971
|
}, setupStaticFiles);
|
|
26972
|
+
worktreeManager = createWorktreeInstanceManager({
|
|
26973
|
+
currentProjectDir: projectDir,
|
|
26974
|
+
currentServerUrl: server.url,
|
|
26975
|
+
runtimeDir: __dirname
|
|
26976
|
+
});
|
|
26977
|
+
return {
|
|
26978
|
+
...server,
|
|
26979
|
+
close: async () => {
|
|
26980
|
+
await worktreeManager?.close();
|
|
26981
|
+
await server.close();
|
|
26982
|
+
}
|
|
26983
|
+
};
|
|
26064
26984
|
}
|
|
26065
26985
|
|
|
26066
26986
|
//#endregion
|