@larose/pi-web 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, lstat, mkdir, open, readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { AppError } from "./errors.js";
|
|
6
|
+
const MANAGED_EXCLUDE = "/.pi/worktrees/";
|
|
7
|
+
const GIT_MAX_BUFFER = 4 * 1024 * 1024;
|
|
8
|
+
class GitCommandError extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
unavailable;
|
|
11
|
+
stderr;
|
|
12
|
+
constructor(message, exitCode, unavailable, stderr, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = "GitCommandError";
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.unavailable = unavailable;
|
|
17
|
+
this.stderr = stderr;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function runFile(file, args) {
|
|
21
|
+
return new Promise((resolvePromise, reject) => {
|
|
22
|
+
execFile(file, [...args], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
25
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
26
|
+
}, (error, stdout, stderr) => {
|
|
27
|
+
if (!error) {
|
|
28
|
+
resolvePromise({ stdout, stderr });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const systemError = error;
|
|
32
|
+
const unavailable = systemError.code === "ENOENT";
|
|
33
|
+
const exitCode = typeof systemError.code === "number" ? systemError.code : null;
|
|
34
|
+
reject(new GitCommandError("Git command failed", exitCode, unavailable, stderr, { cause: error }));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function parsePorcelainWorktrees(output) {
|
|
39
|
+
const records = [];
|
|
40
|
+
for (const block of output.split("\0\0")) {
|
|
41
|
+
if (!block) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const fields = block.split("\0");
|
|
45
|
+
const pathField = fields.find((field) => field.startsWith("worktree "));
|
|
46
|
+
if (!pathField) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const commitField = fields.find((field) => field.startsWith("HEAD "));
|
|
50
|
+
const branchField = fields.find((field) => field.startsWith("branch refs/heads/"));
|
|
51
|
+
records.push({
|
|
52
|
+
path: pathField.slice("worktree ".length),
|
|
53
|
+
commit: commitField ? commitField.slice("HEAD ".length) : null,
|
|
54
|
+
branch: branchField ? branchField.slice("branch refs/heads/".length) : null,
|
|
55
|
+
detached: fields.includes("detached"),
|
|
56
|
+
bare: fields.includes("bare"),
|
|
57
|
+
prunable: fields.some((field) => field === "prunable" || field.startsWith("prunable ")),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return records;
|
|
61
|
+
}
|
|
62
|
+
function headFromRecord(record) {
|
|
63
|
+
if (record.branch) {
|
|
64
|
+
return { type: "branch", name: record.branch };
|
|
65
|
+
}
|
|
66
|
+
if ((record.detached || !record.branch) && record.commit) {
|
|
67
|
+
return { type: "detached", commit: record.commit, shortCommit: record.commit.slice(0, 12) };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function pathIsWithin(parent, candidate) {
|
|
72
|
+
const child = relative(parent, candidate);
|
|
73
|
+
return child === "" || (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child));
|
|
74
|
+
}
|
|
75
|
+
async function canonicalDirectory(path, code = "invalid_cwd") {
|
|
76
|
+
try {
|
|
77
|
+
const canonical = await realpath(resolve(path));
|
|
78
|
+
const details = await stat(canonical);
|
|
79
|
+
if (!details.isDirectory()) {
|
|
80
|
+
throw new Error("Not a directory");
|
|
81
|
+
}
|
|
82
|
+
return canonical;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new AppError(code, "The working directory does not exist or is inaccessible", 400, { cause: error });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function optionalCanonicalDirectory(path) {
|
|
89
|
+
try {
|
|
90
|
+
const canonical = await realpath(path);
|
|
91
|
+
return (await stat(canonical)).isDirectory() ? canonical : null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function unavailable(reasonCode, reason) {
|
|
98
|
+
return { available: false, reasonCode, reason };
|
|
99
|
+
}
|
|
100
|
+
function gitFailureMessage(error, fallback) {
|
|
101
|
+
if (!(error instanceof GitCommandError)) {
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
const detail = error.stderr.trim().split("\n")[0];
|
|
105
|
+
return detail ? `${fallback}: ${detail}` : fallback;
|
|
106
|
+
}
|
|
107
|
+
export class GitWorkspaceService {
|
|
108
|
+
gitBinary;
|
|
109
|
+
constructor(options = {}) {
|
|
110
|
+
this.gitBinary = options.gitBinary ?? "git";
|
|
111
|
+
}
|
|
112
|
+
async inspect(cwd) {
|
|
113
|
+
let canonicalCwd;
|
|
114
|
+
try {
|
|
115
|
+
canonicalCwd = await canonicalDirectory(cwd);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return {
|
|
119
|
+
context: null,
|
|
120
|
+
worktrees: [],
|
|
121
|
+
creation: unavailable("inspection_failed", "The working directory does not exist or is inaccessible."),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const details = await this.repositoryDetails(canonicalCwd);
|
|
126
|
+
if (!details) {
|
|
127
|
+
if (await this.isBareRepository(canonicalCwd)) {
|
|
128
|
+
return {
|
|
129
|
+
context: null,
|
|
130
|
+
worktrees: [],
|
|
131
|
+
creation: unavailable("unsupported_repository", "This is a bare Git repository; managed worktrees require a non-bare repository checkout."),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
context: null,
|
|
136
|
+
worktrees: [],
|
|
137
|
+
creation: unavailable("not_git", "This directory is not inside a Git working tree."),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const worktrees = await this.mappedWorktrees(details);
|
|
141
|
+
const creation = await this.creationAvailability(details);
|
|
142
|
+
return { context: details.context, worktrees, creation };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof GitCommandError && error.unavailable) {
|
|
146
|
+
return {
|
|
147
|
+
context: null,
|
|
148
|
+
worktrees: [],
|
|
149
|
+
creation: unavailable("git_unavailable", "Git is not installed or is not available to the server."),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
context: null,
|
|
154
|
+
worktrees: [],
|
|
155
|
+
creation: unavailable("inspection_failed", "Git metadata for this working directory could not be inspected."),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async removeWorktree(cwd, inputTarget, protectedCwds = []) {
|
|
160
|
+
const sourceCwd = await canonicalDirectory(cwd);
|
|
161
|
+
if (typeof inputTarget !== "string" || !isAbsolute(inputTarget.trim())) {
|
|
162
|
+
throw new AppError("invalid_worktree", "An absolute linked worktree path is required");
|
|
163
|
+
}
|
|
164
|
+
const target = await canonicalDirectory(inputTarget.trim(), "invalid_worktree");
|
|
165
|
+
let details;
|
|
166
|
+
try {
|
|
167
|
+
details = await this.repositoryDetails(sourceCwd);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error instanceof GitCommandError && error.unavailable) {
|
|
171
|
+
throw new AppError("git_unavailable", "Git is not installed or is not available to the server", 503, {
|
|
172
|
+
cause: error,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
throw new AppError("git_inspection_failed", "Could not inspect Git metadata for the working directory", 422, {
|
|
176
|
+
cause: error,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (!details) {
|
|
180
|
+
throw new AppError("not_git", "The working directory is not inside a Git working tree", 422);
|
|
181
|
+
}
|
|
182
|
+
if (!details.primaryRoot) {
|
|
183
|
+
throw new AppError("primary_worktree_unavailable", "Removing a linked worktree requires a usable repository checkout", 422);
|
|
184
|
+
}
|
|
185
|
+
if (target === details.primaryRoot) {
|
|
186
|
+
throw new AppError("primary_worktree_removal", "The repository checkout cannot be removed", 409);
|
|
187
|
+
}
|
|
188
|
+
let attached = false;
|
|
189
|
+
for (let index = 1; index < details.worktrees.length; index += 1) {
|
|
190
|
+
const worktree = details.worktrees[index];
|
|
191
|
+
if (!worktree || worktree.bare || worktree.prunable) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if ((await optionalCanonicalDirectory(worktree.path)) === target) {
|
|
195
|
+
attached = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!attached) {
|
|
200
|
+
throw new AppError("unknown_worktree", "The directory is not an attached linked worktree", 404);
|
|
201
|
+
}
|
|
202
|
+
for (const protectedCwd of protectedCwds) {
|
|
203
|
+
if (pathIsWithin(target, resolve(protectedCwd))) {
|
|
204
|
+
throw new AppError("worktree_in_use", "The linked worktree contains the Pi Web startup directory or an active session", 409);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
await this.git(details.primaryRoot, ["worktree", "remove", target]);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error instanceof GitCommandError && error.unavailable) {
|
|
212
|
+
throw new AppError("git_unavailable", "Git is not installed or is not available to the server", 503, {
|
|
213
|
+
cause: error,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
throw new AppError("worktree_remove_failed", gitFailureMessage(error, "Git could not remove the linked worktree"), 409, { cause: error });
|
|
217
|
+
}
|
|
218
|
+
return { worktreeRoot: target };
|
|
219
|
+
}
|
|
220
|
+
async prepareWorktree(cwd, inputName) {
|
|
221
|
+
const sourceCwd = await canonicalDirectory(cwd);
|
|
222
|
+
const branch = await this.validateName(inputName, sourceCwd);
|
|
223
|
+
let details;
|
|
224
|
+
try {
|
|
225
|
+
details = await this.repositoryDetails(sourceCwd);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error instanceof GitCommandError && error.unavailable) {
|
|
229
|
+
throw new AppError("git_unavailable", "Git is not installed or is not available to the server", 503, {
|
|
230
|
+
cause: error,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
throw new AppError("git_inspection_failed", "Could not inspect Git metadata for the working directory", 422, {
|
|
234
|
+
cause: error,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (!details) {
|
|
238
|
+
if (await this.isBareRepository(sourceCwd)) {
|
|
239
|
+
throw new AppError("unsupported_repository", "Managed worktree creation requires a usable non-bare repository checkout", 422);
|
|
240
|
+
}
|
|
241
|
+
throw new AppError("not_git", "The working directory is not inside a Git working tree", 422);
|
|
242
|
+
}
|
|
243
|
+
const creation = await this.creationAvailability(details);
|
|
244
|
+
if (!creation.available) {
|
|
245
|
+
const code = creation.reasonCode === "primary_worktree_unavailable"
|
|
246
|
+
? "primary_worktree_unavailable"
|
|
247
|
+
: "unsupported_repository";
|
|
248
|
+
throw new AppError(code, creation.reason, 422);
|
|
249
|
+
}
|
|
250
|
+
const repositoryRoot = details.context.repositoryRoot;
|
|
251
|
+
const managedParent = resolve(repositoryRoot, ".pi", "worktrees");
|
|
252
|
+
const target = resolve(managedParent, branch);
|
|
253
|
+
if (!pathIsWithin(repositoryRoot, managedParent) ||
|
|
254
|
+
!pathIsWithin(managedParent, target) ||
|
|
255
|
+
target === managedParent) {
|
|
256
|
+
throw new AppError("invalid_worktree_name", "The worktree name must resolve beneath the repository worktree directory");
|
|
257
|
+
}
|
|
258
|
+
await this.validateManagedAncestors(repositoryRoot);
|
|
259
|
+
try {
|
|
260
|
+
await lstat(target);
|
|
261
|
+
throw new AppError("worktree_path_exists", `The managed worktree path already exists: ${target}`, 409);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error instanceof AppError) {
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
if (error.code !== "ENOENT") {
|
|
268
|
+
throw new AppError("worktree_path_unavailable", "The managed worktree path could not be inspected", 422, {
|
|
269
|
+
cause: error,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await this.git(sourceCwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
275
|
+
throw new AppError("worktree_branch_exists", `The branch already exists: ${branch}`, 409);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error instanceof AppError) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
if (!(error instanceof GitCommandError) || error.exitCode !== 1) {
|
|
282
|
+
throw new AppError("git_command_failed", "Could not check whether the worktree branch exists", 502, {
|
|
283
|
+
cause: error,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await mkdir(managedParent, { recursive: true });
|
|
289
|
+
const canonicalManagedParent = await realpath(managedParent);
|
|
290
|
+
if (!pathIsWithin(repositoryRoot, canonicalManagedParent)) {
|
|
291
|
+
throw new AppError("worktree_path_outside_repository", "The managed worktree directory resolves outside the repository checkout", 422);
|
|
292
|
+
}
|
|
293
|
+
await this.ensureExcluded(details.commonGitDir);
|
|
294
|
+
await this.git(sourceCwd, ["worktree", "add", "-b", branch, target, "HEAD"]);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof AppError) {
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
throw new AppError("worktree_create_failed", gitFailureMessage(error, "Git could not create the worktree"), 502, {
|
|
301
|
+
cause: error,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
let rolledBack = false;
|
|
305
|
+
const rollback = async () => {
|
|
306
|
+
if (rolledBack) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const failures = [];
|
|
310
|
+
try {
|
|
311
|
+
await this.git(sourceCwd, ["worktree", "remove", "--force", target]);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
failures.push(gitFailureMessage(error, "could not remove the checkout"));
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
await this.git(sourceCwd, ["branch", "-D", "--", branch]);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
failures.push(gitFailureMessage(error, "could not remove the branch"));
|
|
321
|
+
}
|
|
322
|
+
if (failures.length > 0) {
|
|
323
|
+
throw new AppError("worktree_cleanup_failed", `Could not fully roll back the new worktree: ${failures.join("; ")}`, 500);
|
|
324
|
+
}
|
|
325
|
+
rolledBack = true;
|
|
326
|
+
};
|
|
327
|
+
const mappedCwd = resolve(target, details.context.relativeCwd);
|
|
328
|
+
if (!pathIsWithin(target, mappedCwd)) {
|
|
329
|
+
return await this.rollbackPreparation(rollback, "The mapped working directory resolves outside the new worktree");
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const canonicalMappedCwd = await canonicalDirectory(mappedCwd, "worktree_mapped_cwd_missing");
|
|
333
|
+
if (!pathIsWithin(await realpath(target), canonicalMappedCwd)) {
|
|
334
|
+
return await this.rollbackPreparation(rollback, "The mapped working directory resolves outside the new worktree");
|
|
335
|
+
}
|
|
336
|
+
return { cwd: canonicalMappedCwd, worktreeRoot: await realpath(target), branch, rollback };
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error instanceof AppError && error.code === "worktree_cleanup_failed") {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
return await this.rollbackPreparation(rollback, "The corresponding working directory does not exist in the new worktree", error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async rollbackPreparation(rollback, message, cause) {
|
|
346
|
+
try {
|
|
347
|
+
await rollback();
|
|
348
|
+
}
|
|
349
|
+
catch (cleanupError) {
|
|
350
|
+
throw new AppError("worktree_cleanup_failed", `${message}. Cleanup also failed: ${cleanupError.message}`, 500, { cause: cleanupError });
|
|
351
|
+
}
|
|
352
|
+
throw new AppError("worktree_mapped_cwd_missing", message, 422, cause ? { cause } : undefined);
|
|
353
|
+
}
|
|
354
|
+
async validateManagedAncestors(repositoryRoot) {
|
|
355
|
+
for (const path of [resolve(repositoryRoot, ".pi"), resolve(repositoryRoot, ".pi", "worktrees")]) {
|
|
356
|
+
try {
|
|
357
|
+
await lstat(path);
|
|
358
|
+
const canonical = await optionalCanonicalDirectory(path);
|
|
359
|
+
if (!canonical) {
|
|
360
|
+
throw new AppError("worktree_path_unavailable", "A managed worktree parent path is not a directory", 422);
|
|
361
|
+
}
|
|
362
|
+
if (!pathIsWithin(repositoryRoot, canonical)) {
|
|
363
|
+
throw new AppError("worktree_path_outside_repository", "The managed worktree directory resolves outside the repository checkout", 422);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error instanceof AppError) {
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
if (error.code === "ENOENT") {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
throw new AppError("worktree_path_unavailable", "A managed worktree parent path could not be inspected", 422, {
|
|
374
|
+
cause: error,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async validateName(input, cwd) {
|
|
380
|
+
if (typeof input !== "string") {
|
|
381
|
+
throw new AppError("invalid_worktree_name", "A worktree name is required");
|
|
382
|
+
}
|
|
383
|
+
const name = input.trim();
|
|
384
|
+
if (!name ||
|
|
385
|
+
name === "." ||
|
|
386
|
+
name === ".." ||
|
|
387
|
+
name.startsWith("-") ||
|
|
388
|
+
name.includes("/") ||
|
|
389
|
+
name.includes("\\") ||
|
|
390
|
+
basename(name) !== name) {
|
|
391
|
+
throw new AppError("invalid_worktree_name", "The worktree name must be one safe path segment and branch name");
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await this.git(cwd, ["check-ref-format", `refs/heads/${name}`]);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (error instanceof GitCommandError && error.unavailable) {
|
|
398
|
+
throw new AppError("git_unavailable", "Git is not installed or is not available to the server", 503, {
|
|
399
|
+
cause: error,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
throw new AppError("invalid_worktree_name", "The worktree name is not a valid Git branch name");
|
|
403
|
+
}
|
|
404
|
+
return name;
|
|
405
|
+
}
|
|
406
|
+
async isBareRepository(cwd) {
|
|
407
|
+
try {
|
|
408
|
+
return (await this.git(cwd, ["rev-parse", "--is-bare-repository"])).stdout.trim() === "true";
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (error instanceof GitCommandError && error.exitCode !== null) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async repositoryDetails(cwd) {
|
|
418
|
+
let worktreeRootOutput;
|
|
419
|
+
try {
|
|
420
|
+
worktreeRootOutput = await this.git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (error instanceof GitCommandError && error.exitCode !== null) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
const worktreeRoot = await optionalCanonicalDirectory(worktreeRootOutput.stdout.trim());
|
|
429
|
+
if (!worktreeRoot || !pathIsWithin(worktreeRoot, cwd)) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
const [commonDirResult, worktreeListResult] = await Promise.all([
|
|
433
|
+
this.git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]),
|
|
434
|
+
this.git(cwd, ["worktree", "list", "--porcelain", "-z"]),
|
|
435
|
+
]);
|
|
436
|
+
const commonGitDir = await realpath(commonDirResult.stdout.trim());
|
|
437
|
+
const worktrees = parsePorcelainWorktrees(worktreeListResult.stdout);
|
|
438
|
+
const primaryRecord = worktrees[0];
|
|
439
|
+
const primaryRoot = primaryRecord && !primaryRecord.bare ? await optionalCanonicalDirectory(primaryRecord.path) : null;
|
|
440
|
+
const currentRecord = await this.findCurrentRecord(worktrees, worktreeRoot);
|
|
441
|
+
let head = currentRecord ? headFromRecord(currentRecord) : null;
|
|
442
|
+
if (!head) {
|
|
443
|
+
try {
|
|
444
|
+
const branch = (await this.git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
|
|
445
|
+
if (branch) {
|
|
446
|
+
head = { type: "branch", name: branch };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
if (!(error instanceof GitCommandError) || error.exitCode === null) {
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!head) {
|
|
456
|
+
const commit = (await this.git(cwd, ["rev-parse", "HEAD"])).stdout.trim();
|
|
457
|
+
head = { type: "detached", commit, shortCommit: commit.slice(0, 12) };
|
|
458
|
+
}
|
|
459
|
+
const relativeCwd = relative(worktreeRoot, cwd) || ".";
|
|
460
|
+
const repositoryRoot = primaryRoot ?? worktreeRoot;
|
|
461
|
+
return {
|
|
462
|
+
context: {
|
|
463
|
+
repositoryRoot,
|
|
464
|
+
worktreeRoot,
|
|
465
|
+
relativeCwd,
|
|
466
|
+
head,
|
|
467
|
+
isLinkedWorktree: primaryRoot ? worktreeRoot !== primaryRoot : true,
|
|
468
|
+
},
|
|
469
|
+
commonGitDir,
|
|
470
|
+
worktrees,
|
|
471
|
+
primaryRoot,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
async findCurrentRecord(worktrees, worktreeRoot) {
|
|
475
|
+
for (const worktree of worktrees) {
|
|
476
|
+
const candidate = await optionalCanonicalDirectory(worktree.path);
|
|
477
|
+
if (candidate === worktreeRoot) {
|
|
478
|
+
return worktree;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
async mappedWorktrees(details) {
|
|
484
|
+
const summaries = [];
|
|
485
|
+
for (let index = 0; index < details.worktrees.length; index += 1) {
|
|
486
|
+
const worktree = details.worktrees[index];
|
|
487
|
+
if (!worktree || worktree.bare || worktree.prunable) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const root = await optionalCanonicalDirectory(worktree.path);
|
|
491
|
+
const head = headFromRecord(worktree);
|
|
492
|
+
if (!root || !head) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const mapped = resolve(root, details.context.relativeCwd);
|
|
496
|
+
if (!pathIsWithin(root, mapped)) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const cwd = await optionalCanonicalDirectory(mapped);
|
|
500
|
+
if (!cwd || !pathIsWithin(root, cwd)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
summaries.push({ worktreeRoot: root, cwd, head, isLinkedWorktree: index !== 0 });
|
|
504
|
+
}
|
|
505
|
+
return summaries;
|
|
506
|
+
}
|
|
507
|
+
async creationAvailability(details) {
|
|
508
|
+
if (!details.primaryRoot) {
|
|
509
|
+
return unavailable("primary_worktree_unavailable", "A usable non-bare repository checkout is required to create a managed worktree.");
|
|
510
|
+
}
|
|
511
|
+
if (!isAbsolute(details.commonGitDir) || !isAbsolute(details.primaryRoot)) {
|
|
512
|
+
return unavailable("unsupported_repository", "This Git repository layout does not support managed worktrees.");
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
await access(details.primaryRoot, constants.R_OK | constants.W_OK);
|
|
516
|
+
const [bareResult, primaryCommonResult] = await Promise.all([
|
|
517
|
+
this.git(details.primaryRoot, ["rev-parse", "--is-bare-repository"]),
|
|
518
|
+
this.git(details.primaryRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]),
|
|
519
|
+
]);
|
|
520
|
+
const primaryCommonGitDir = await realpath(primaryCommonResult.stdout.trim());
|
|
521
|
+
if (bareResult.stdout.trim() !== "false" || primaryCommonGitDir !== details.commonGitDir) {
|
|
522
|
+
return unavailable("unsupported_repository", "This Git repository layout does not support managed worktrees.");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return unavailable("primary_worktree_unavailable", "The repository checkout is not accessible and writable, so a managed worktree cannot be created.");
|
|
527
|
+
}
|
|
528
|
+
return { available: true, reasonCode: null, reason: null };
|
|
529
|
+
}
|
|
530
|
+
async ensureExcluded(commonGitDir) {
|
|
531
|
+
const infoDir = join(commonGitDir, "info");
|
|
532
|
+
const excludePath = join(infoDir, "exclude");
|
|
533
|
+
await mkdir(infoDir, { recursive: true });
|
|
534
|
+
let contents = "";
|
|
535
|
+
try {
|
|
536
|
+
contents = await readFile(excludePath, "utf8");
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (error.code !== "ENOENT") {
|
|
540
|
+
throw new AppError("git_exclude_failed", "Could not read Git's local exclusion file", 500, { cause: error });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const lines = contents.split(/\r?\n/);
|
|
544
|
+
if (lines.includes(MANAGED_EXCLUDE)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const prefix = contents.length > 0 && !contents.endsWith("\n") ? "\n" : "";
|
|
548
|
+
const file = await open(excludePath, "a");
|
|
549
|
+
try {
|
|
550
|
+
await file.write(`${prefix}${MANAGED_EXCLUDE}\n`);
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
await file.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
git(cwd, args) {
|
|
557
|
+
return runFile(this.gitBinary, ["-C", cwd, ...args]);
|
|
558
|
+
}
|
|
559
|
+
}
|