@solaqua/gji 0.10.0 → 0.12.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/README.md +83 -426
- package/dist/back.d.ts +3 -0
- package/dist/back.js +19 -7
- package/dist/bootstrap-output.d.ts +3 -0
- package/dist/bootstrap-output.js +27 -0
- package/dist/bootstrap-preview.d.ts +10 -0
- package/dist/bootstrap-preview.js +21 -0
- package/dist/clean.js +27 -3
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +140 -22
- package/dist/clone-failure-store.d.ts +18 -0
- package/dist/clone-failure-store.js +230 -0
- package/dist/command-runner.d.ts +6 -0
- package/dist/command-runner.js +38 -0
- package/dist/completion.js +7 -2
- package/dist/config-command.d.ts +1 -0
- package/dist/config-command.js +7 -1
- package/dist/config.d.ts +26 -1
- package/dist/config.js +110 -6
- package/dist/context-card.d.ts +1 -0
- package/dist/context-card.js +25 -0
- package/dist/dependency-bootstrap-prompt.d.ts +24 -0
- package/dist/dependency-bootstrap-prompt.js +103 -0
- package/dist/dependency-bootstrap.d.ts +129 -0
- package/dist/dependency-bootstrap.js +566 -0
- package/dist/dir-clone.d.ts +33 -0
- package/dist/dir-clone.js +505 -0
- package/dist/doctor.js +21 -0
- package/dist/done.d.ts +10 -0
- package/dist/done.js +210 -0
- package/dist/file-sync.js +71 -22
- package/dist/format-bytes.d.ts +1 -0
- package/dist/format-bytes.js +14 -0
- package/dist/fs-utils.d.ts +4 -0
- package/dist/fs-utils.js +26 -0
- package/dist/git.d.ts +5 -0
- package/dist/git.js +50 -0
- package/dist/gji-bundle.mjs +6189 -1313
- package/dist/go-resolver.d.ts +35 -0
- package/dist/go-resolver.js +185 -0
- package/dist/go.d.ts +6 -2
- package/dist/go.js +270 -62
- package/dist/hooks.d.ts +2 -1
- package/dist/hooks.js +20 -8
- package/dist/hub.d.ts +31 -0
- package/dist/hub.js +287 -0
- package/dist/init.js +30 -22
- package/dist/install-prompt.d.ts +1 -0
- package/dist/install-prompt.js +3 -25
- package/dist/ls.js +9 -1
- package/dist/navigation-output.d.ts +11 -0
- package/dist/navigation-output.js +6 -0
- package/dist/new.d.ts +14 -2
- package/dist/new.js +422 -38
- package/dist/pr-open.js +2 -10
- package/dist/pr.d.ts +6 -2
- package/dist/pr.js +135 -30
- package/dist/pull-requests.d.ts +1 -0
- package/dist/pull-requests.js +32 -3
- package/dist/remove.js +22 -0
- package/dist/repo.d.ts +4 -0
- package/dist/repo.js +61 -6
- package/dist/run-hook.js +2 -0
- package/dist/safe-destination.d.ts +15 -0
- package/dist/safe-destination.js +166 -0
- package/dist/shell-completion.js +52 -13
- package/dist/slots.d.ts +8 -0
- package/dist/slots.js +61 -0
- package/dist/status.d.ts +2 -0
- package/dist/status.js +9 -1
- package/dist/sync-directories.d.ts +33 -0
- package/dist/sync-directories.js +97 -0
- package/dist/sync-plan.d.ts +16 -0
- package/dist/sync-plan.js +128 -0
- package/dist/sync.js +7 -13
- package/dist/task-command.d.ts +9 -0
- package/dist/task-command.js +29 -0
- package/dist/task.d.ts +9 -0
- package/dist/task.js +37 -0
- package/dist/terminal-text.d.ts +4 -0
- package/dist/terminal-text.js +72 -0
- package/dist/undo.d.ts +36 -0
- package/dist/undo.js +392 -0
- package/dist/warp.d.ts +3 -1
- package/dist/warp.js +32 -125
- package/dist/worktree-bootstrap.d.ts +35 -0
- package/dist/worktree-bootstrap.js +96 -0
- package/dist/worktree-info.d.ts +6 -1
- package/dist/worktree-info.js +14 -4
- package/dist/worktree-management.d.ts +1 -0
- package/dist/worktree-management.js +23 -4
- package/dist/worktree-picker.d.ts +24 -11
- package/dist/worktree-picker.js +176 -95
- package/dist/worktree-prompts.js +1 -1
- package/dist/worktree-source.d.ts +6 -0
- package/dist/worktree-source.js +1 -0
- package/dist/worktree-sources.d.ts +4 -0
- package/dist/worktree-sources.js +77 -0
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-doctor.1 +1 -1
- package/man/man1/gji-done.1 +19 -0
- package/man/man1/gji-go.1 +12 -3
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +21 -3
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +7 -1
- package/man/man1/gji-remove.1 +3 -3
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-task.1 +16 -0
- package/man/man1/gji-undo.1 +16 -0
- package/man/man1/gji-warp.1 +3 -6
- package/man/man1/gji.1 +17 -5
- package/package.json +3 -1
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { chmod, cp, lstat, mkdir, mkdtemp, opendir, readdir, readFile, readlink, realpath, rename, rm, rmdir, symlink, unlink, utimes, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js";
|
|
8
|
+
import { inspectDestination, openDestinationDirectory, } from "./safe-destination.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const CLONE_LOCK_SUFFIX = ".gji-clone-lock";
|
|
12
|
+
const SIZE_ESTIMATE_MAX_ENTRIES = 1_000_000;
|
|
13
|
+
const SIZE_ESTIMATE_MAX_MS = 5_000;
|
|
14
|
+
export async function waitForCloneLock(destination, timeoutMs = 5_000) {
|
|
15
|
+
const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`;
|
|
16
|
+
const deadline = Date.now() + timeoutMs;
|
|
17
|
+
while (await cloneLockExists(lockPath)) {
|
|
18
|
+
if (await cloneLockIsStale(lockPath))
|
|
19
|
+
return true;
|
|
20
|
+
if (Date.now() >= deadline)
|
|
21
|
+
return false;
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
async function cloneLockIsStale(lockPath) {
|
|
27
|
+
try {
|
|
28
|
+
const freshnessPath = await lockFreshnessPath(lockPath);
|
|
29
|
+
const stats = await lstat(freshnessPath);
|
|
30
|
+
return Date.now() - stats.mtimeMs >= CLONE_LOCK_TTL_MS;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (isNotFoundError(error))
|
|
34
|
+
return false;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function cloneLockExists(lockPath) {
|
|
39
|
+
try {
|
|
40
|
+
return await destinationExists(lockPath);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if ("code" in error &&
|
|
44
|
+
error.code === "ENOTDIR") {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function cloneDir(source, destination, options = {}) {
|
|
51
|
+
const platform = options.platform ?? process.platform;
|
|
52
|
+
const strategy = cloneStrategy(platform);
|
|
53
|
+
if (!isClonePlatformSupported(platform)) {
|
|
54
|
+
throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`);
|
|
55
|
+
}
|
|
56
|
+
const sourcePath = await realpath(source);
|
|
57
|
+
const sourceStats = await lstat(sourcePath);
|
|
58
|
+
if (!sourceStats.isDirectory()) {
|
|
59
|
+
throw new Error("source is not a directory");
|
|
60
|
+
}
|
|
61
|
+
if (await destinationExists(destination)) {
|
|
62
|
+
throw new CloneDestinationExistsError(destination);
|
|
63
|
+
}
|
|
64
|
+
const startedAt = Date.now();
|
|
65
|
+
const parent = dirname(destination);
|
|
66
|
+
let safeParent;
|
|
67
|
+
try {
|
|
68
|
+
if (options.destinationRoot) {
|
|
69
|
+
const parentInspection = await inspectDestination(options.destinationRoot, parent);
|
|
70
|
+
if (parentInspection.kind === "unsafe") {
|
|
71
|
+
throw new Error(parentInspection.reason);
|
|
72
|
+
}
|
|
73
|
+
safeParent = await openDestinationDirectory(options.destinationRoot, parent);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await mkdir(parent, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
const operationParent = safeParent?.path ?? parent;
|
|
79
|
+
const operationDestination = join(operationParent, basename(destination));
|
|
80
|
+
if (await destinationExists(operationDestination)) {
|
|
81
|
+
throw new CloneDestinationExistsError(destination);
|
|
82
|
+
}
|
|
83
|
+
const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`;
|
|
84
|
+
const lockToken = await acquireCloneLock(lockPath, destination);
|
|
85
|
+
const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken);
|
|
86
|
+
let temporaryRoot;
|
|
87
|
+
let reservationPath;
|
|
88
|
+
const reservationEntries = [];
|
|
89
|
+
try {
|
|
90
|
+
reservationPath = await reserveDestination(operationDestination);
|
|
91
|
+
temporaryRoot = await mkdtemp(join(operationParent, `.${basename(destination)}.gji-clone-`));
|
|
92
|
+
const temporaryDestination = join(temporaryRoot, basename(destination));
|
|
93
|
+
const copyDirectory = options.copyDirectory ??
|
|
94
|
+
(platformIsDarwin(platform)
|
|
95
|
+
? runNativeCloneDirectory
|
|
96
|
+
: async (source, target) => {
|
|
97
|
+
if (!strategy) {
|
|
98
|
+
throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`);
|
|
99
|
+
}
|
|
100
|
+
const runCommand = options.runCommand ?? runCloneCommand;
|
|
101
|
+
await runCommand("cp", strategy(source, target));
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
await copyDirectory(sourcePath, temporaryDestination);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (isUnsupportedCloneError(error)) {
|
|
108
|
+
throw new CloneUnsupportedError(toErrorMessage(error));
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
await publishCloneContents(temporaryDestination, operationDestination, reservationPath, (entry) => reservationEntries.push(entry), options.copyFile ?? runForcedCloneFileCopy);
|
|
113
|
+
reservationPath = undefined;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
stopLockHeartbeat();
|
|
117
|
+
if (reservationPath) {
|
|
118
|
+
await cleanupReservedDestination(operationDestination, reservationPath, reservationEntries);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
if (temporaryRoot) {
|
|
122
|
+
await rm(temporaryRoot, { force: true, recursive: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Cleanup is best effort and must not mask the clone result.
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await releaseCloneLock(lockPath, lockToken);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// A stale lock is reclaimed on a later attempt.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const bytes = options.measureBytes === false
|
|
136
|
+
? undefined
|
|
137
|
+
: await estimateCloneBytes(sourcePath);
|
|
138
|
+
return { bytes, ms: Date.now() - startedAt };
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await safeParent?.close().catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function runNativeCloneDirectory(source, destination) {
|
|
145
|
+
await cp(source, destination, {
|
|
146
|
+
errorOnExist: true,
|
|
147
|
+
force: false,
|
|
148
|
+
mode: constants.COPYFILE_FICLONE_FORCE,
|
|
149
|
+
preserveTimestamps: true,
|
|
150
|
+
recursive: true,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export class CloneDestinationExistsError extends Error {
|
|
154
|
+
code = "GJI_CLONE_DESTINATION_EXISTS";
|
|
155
|
+
constructor(destination) {
|
|
156
|
+
super(`destination already exists: ${destination}`);
|
|
157
|
+
this.name = "CloneDestinationExistsError";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export class CloneUnsupportedError extends Error {
|
|
161
|
+
code = "GJI_CLONE_UNSUPPORTED";
|
|
162
|
+
constructor(reason) {
|
|
163
|
+
super(`copy-on-write cloning is not supported: ${reason}`);
|
|
164
|
+
this.name = "CloneUnsupportedError";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export class CloneInProgressError extends Error {
|
|
168
|
+
code = "GJI_CLONE_IN_PROGRESS";
|
|
169
|
+
constructor(destination) {
|
|
170
|
+
super(`copy-on-write clone already in progress: ${destination}`);
|
|
171
|
+
this.name = "CloneInProgressError";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export function isCloneDestinationExistsError(error) {
|
|
175
|
+
return error instanceof CloneDestinationExistsError;
|
|
176
|
+
}
|
|
177
|
+
export function isCloneUnsupportedError(error) {
|
|
178
|
+
return (error instanceof CloneUnsupportedError || isUnsupportedCloneError(error));
|
|
179
|
+
}
|
|
180
|
+
export function isCloneInProgressError(error) {
|
|
181
|
+
return error instanceof CloneInProgressError;
|
|
182
|
+
}
|
|
183
|
+
export async function directorySize(path) {
|
|
184
|
+
const stats = await lstat(path);
|
|
185
|
+
if (!stats.isDirectory())
|
|
186
|
+
return stats.size;
|
|
187
|
+
const pending = [path];
|
|
188
|
+
let total = 0;
|
|
189
|
+
let entryCount = 0;
|
|
190
|
+
const startedAt = Date.now();
|
|
191
|
+
while (pending.length > 0) {
|
|
192
|
+
const current = pending.pop();
|
|
193
|
+
if (!current)
|
|
194
|
+
continue;
|
|
195
|
+
const directory = await opendir(current);
|
|
196
|
+
try {
|
|
197
|
+
for await (const entry of directory) {
|
|
198
|
+
entryCount += 1;
|
|
199
|
+
if (entryCount > SIZE_ESTIMATE_MAX_ENTRIES ||
|
|
200
|
+
Date.now() - startedAt > SIZE_ESTIMATE_MAX_MS) {
|
|
201
|
+
throw new Error("directory size estimate exceeded its safety limit");
|
|
202
|
+
}
|
|
203
|
+
const entryPath = join(current, entry.name);
|
|
204
|
+
if (entry.isDirectory())
|
|
205
|
+
pending.push(entryPath);
|
|
206
|
+
else
|
|
207
|
+
total += (await lstat(entryPath)).size;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await directory.close().catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return total;
|
|
215
|
+
}
|
|
216
|
+
function cloneStrategy(platform) {
|
|
217
|
+
if (platform === "linux") {
|
|
218
|
+
return (source, destination) => [
|
|
219
|
+
"-a",
|
|
220
|
+
"--reflink=always",
|
|
221
|
+
source,
|
|
222
|
+
destination,
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
function isClonePlatformSupported(platform) {
|
|
228
|
+
return platform === "darwin" || platform === "linux";
|
|
229
|
+
}
|
|
230
|
+
async function estimateCloneBytes(path) {
|
|
231
|
+
try {
|
|
232
|
+
return await directorySize(path);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function runCloneCommand(command, args) {
|
|
239
|
+
try {
|
|
240
|
+
await execFileAsync(command, args);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (isUnsupportedCloneError(error)) {
|
|
244
|
+
throw new CloneUnsupportedError(toErrorMessage(error));
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function acquireCloneLock(lockPath, destination) {
|
|
250
|
+
const lockToken = randomUUID();
|
|
251
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
252
|
+
try {
|
|
253
|
+
await mkdir(lockPath);
|
|
254
|
+
try {
|
|
255
|
+
await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
|
|
256
|
+
flag: "wx",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
await rm(lockPath, { force: true, recursive: true });
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
return lockToken;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (!isAlreadyExistsError(error))
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
let lockStats;
|
|
270
|
+
try {
|
|
271
|
+
lockStats = await lstat(await lockFreshnessPath(lockPath));
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (isNotFoundError(error))
|
|
275
|
+
continue;
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
if (Date.now() - lockStats.mtimeMs < CLONE_LOCK_TTL_MS) {
|
|
279
|
+
throw new CloneInProgressError(destination);
|
|
280
|
+
}
|
|
281
|
+
const stalePath = `${lockPath}.stale-${randomUUID()}`;
|
|
282
|
+
try {
|
|
283
|
+
await rename(lockPath, stalePath);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (isNotFoundError(error))
|
|
287
|
+
continue;
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await mkdir(lockPath);
|
|
292
|
+
try {
|
|
293
|
+
await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
|
|
294
|
+
flag: "wx",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
await rm(lockPath, { force: true, recursive: true });
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
await rm(stalePath, { force: true, recursive: true });
|
|
303
|
+
}
|
|
304
|
+
catch (cleanupError) {
|
|
305
|
+
await releaseCloneLock(lockPath, lockToken).catch(() => undefined);
|
|
306
|
+
throw cleanupError;
|
|
307
|
+
}
|
|
308
|
+
return lockToken;
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
await rm(stalePath, { force: true, recursive: true }).catch(() => undefined);
|
|
312
|
+
if (isAlreadyExistsError(error))
|
|
313
|
+
continue;
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new CloneInProgressError(destination);
|
|
318
|
+
}
|
|
319
|
+
async function lockFreshnessPath(lockPath) {
|
|
320
|
+
try {
|
|
321
|
+
const entries = await readdir(lockPath);
|
|
322
|
+
const owner = entries.find((entry) => entry.startsWith("owner-"));
|
|
323
|
+
return owner ? join(lockPath, owner) : lockPath;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (isNotFoundError(error))
|
|
327
|
+
throw error;
|
|
328
|
+
return lockPath;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function publishCloneContents(temporaryDestination, destination, reservationPath, onEntryPublished, copyFileEntry) {
|
|
332
|
+
const reservationName = basename(reservationPath);
|
|
333
|
+
const destinationEntries = await readdir(destination);
|
|
334
|
+
if (destinationEntries.length !== 1 ||
|
|
335
|
+
destinationEntries[0] !== reservationName) {
|
|
336
|
+
throw new CloneDestinationExistsError(destination);
|
|
337
|
+
}
|
|
338
|
+
const temporaryEntries = await readdir(temporaryDestination);
|
|
339
|
+
for (const entry of temporaryEntries) {
|
|
340
|
+
await publishCloneEntry(join(temporaryDestination, entry), join(destination, entry), () => onEntryPublished(entry), copyFileEntry);
|
|
341
|
+
}
|
|
342
|
+
await unlink(reservationPath);
|
|
343
|
+
}
|
|
344
|
+
async function publishCloneEntry(source, destination, onCreated, copyFileEntry) {
|
|
345
|
+
const sourceStats = await lstat(source);
|
|
346
|
+
if (sourceStats.isDirectory()) {
|
|
347
|
+
try {
|
|
348
|
+
await mkdir(destination);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (isAlreadyExistsError(error)) {
|
|
352
|
+
throw new CloneDestinationExistsError(destination);
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
onCreated();
|
|
357
|
+
for (const entry of await readdir(source)) {
|
|
358
|
+
await publishCloneEntry(join(source, entry), join(destination, entry), () => undefined, copyFileEntry);
|
|
359
|
+
}
|
|
360
|
+
await copyCloneMetadata(sourceStats, destination);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (sourceStats.isSymbolicLink()) {
|
|
364
|
+
try {
|
|
365
|
+
await symlink(await readlink(source), destination);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (isAlreadyExistsError(error)) {
|
|
369
|
+
throw new CloneDestinationExistsError(destination);
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
onCreated();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!sourceStats.isFile()) {
|
|
377
|
+
throw new Error(`unsupported clone entry type: ${source}`);
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
await copyFileEntry(source, destination);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (isAlreadyExistsError(error)) {
|
|
384
|
+
throw new CloneDestinationExistsError(destination);
|
|
385
|
+
}
|
|
386
|
+
if (isUnsupportedCloneError(error)) {
|
|
387
|
+
throw new CloneUnsupportedError(toErrorMessage(error));
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
onCreated();
|
|
392
|
+
await copyCloneMetadata(sourceStats, destination);
|
|
393
|
+
}
|
|
394
|
+
async function runForcedCloneFileCopy(source, destination) {
|
|
395
|
+
await cp(source, destination, {
|
|
396
|
+
errorOnExist: true,
|
|
397
|
+
force: false,
|
|
398
|
+
mode: constants.COPYFILE_FICLONE_FORCE,
|
|
399
|
+
preserveTimestamps: true,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async function copyCloneMetadata(sourceStats, destination) {
|
|
403
|
+
await chmod(destination, Number(sourceStats.mode) & 0o7777);
|
|
404
|
+
await utimes(destination, sourceStats.atime, sourceStats.mtime);
|
|
405
|
+
}
|
|
406
|
+
async function reserveDestination(destination) {
|
|
407
|
+
try {
|
|
408
|
+
await mkdir(destination);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (isAlreadyExistsError(error)) {
|
|
412
|
+
throw new CloneDestinationExistsError(destination);
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
const reservationPath = join(destination, `.gji-clone-reservation-${randomUUID()}`);
|
|
417
|
+
try {
|
|
418
|
+
await writeFile(reservationPath, "gji clone reservation\n", {
|
|
419
|
+
flag: "wx",
|
|
420
|
+
});
|
|
421
|
+
return reservationPath;
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
await rmdir(destination).catch(() => undefined);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function cleanupReservedDestination(destination, reservationPath, reservationEntries) {
|
|
429
|
+
try {
|
|
430
|
+
const entries = await readdir(destination);
|
|
431
|
+
const ownedEntries = new Set([
|
|
432
|
+
basename(reservationPath),
|
|
433
|
+
...reservationEntries,
|
|
434
|
+
]);
|
|
435
|
+
if (entries.every((entry) => ownedEntries.has(entry))) {
|
|
436
|
+
await rm(destination, { force: true, recursive: true });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Preserve a destination that was changed by another process.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function destinationExists(path) {
|
|
444
|
+
try {
|
|
445
|
+
await lstat(path);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (isNotFoundError(error))
|
|
450
|
+
return false;
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function startLockHeartbeat(lockPath, lockToken) {
|
|
455
|
+
const timer = setInterval(() => {
|
|
456
|
+
void refreshCloneLock(lockPath, lockToken);
|
|
457
|
+
}, CLONE_LOCK_TTL_MS / 3);
|
|
458
|
+
timer.unref?.();
|
|
459
|
+
return () => clearInterval(timer);
|
|
460
|
+
}
|
|
461
|
+
async function refreshCloneLock(lockPath, lockToken) {
|
|
462
|
+
try {
|
|
463
|
+
const ownerPath = join(lockPath, ownerFileName(lockToken));
|
|
464
|
+
await readFile(ownerPath, "utf8");
|
|
465
|
+
const now = new Date();
|
|
466
|
+
await utimes(ownerPath, now, now);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Lock refresh is advisory; the owner check prevents touching a replacement lock.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function releaseCloneLock(lockPath, lockToken) {
|
|
473
|
+
try {
|
|
474
|
+
const ownerPath = join(lockPath, ownerFileName(lockToken));
|
|
475
|
+
await unlink(ownerPath);
|
|
476
|
+
await rmdir(lockPath);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
if (!isNotFoundError(error) && !isDirectoryNotEmptyError(error))
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function ownerFileName(lockToken) {
|
|
484
|
+
return `owner-${lockToken}`;
|
|
485
|
+
}
|
|
486
|
+
function isUnsupportedCloneError(error) {
|
|
487
|
+
if (!(error instanceof Error))
|
|
488
|
+
return false;
|
|
489
|
+
const code = "code" in error ? error.code : "";
|
|
490
|
+
if (["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"].includes(code ?? "")) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
return /clonefile|reflink|unsupported|operation not supported|not supported|invalid cross-device|cross-device/iu.test(error.message);
|
|
494
|
+
}
|
|
495
|
+
function isDirectoryNotEmptyError(error) {
|
|
496
|
+
return (error instanceof Error &&
|
|
497
|
+
"code" in error &&
|
|
498
|
+
error.code === "ENOTEMPTY");
|
|
499
|
+
}
|
|
500
|
+
function toErrorMessage(error) {
|
|
501
|
+
return error instanceof Error ? error.message : String(error);
|
|
502
|
+
}
|
|
503
|
+
function platformIsDarwin(platform) {
|
|
504
|
+
return platform === "darwin";
|
|
505
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { detectRepository } from "./repo.js";
|
|
|
12
12
|
import { loadRegistry, REGISTRY_FILE_PATH, removeMissingRegistryEntries, } from "./repo-registry.js";
|
|
13
13
|
import { resolveSupportedShell } from "./shell.js";
|
|
14
14
|
import { executableExists, hasShellIntegration, resolveCompletionPath, resolveShellConfigPath, } from "./shell-setup.js";
|
|
15
|
+
import { loadSlots } from "./slots.js";
|
|
15
16
|
const execFileAsync = promisify(execFile);
|
|
16
17
|
const MINIMUM_GIT_VERSION = { major: 2, minor: 17 };
|
|
17
18
|
export async function runDoctorCommand(options) {
|
|
@@ -82,10 +83,30 @@ async function collectDoctorInspection(cwd, home, shell) {
|
|
|
82
83
|
await checkWorktreeBase(repository, effectiveConfig, home),
|
|
83
84
|
registry.check,
|
|
84
85
|
await checkEditor(effectiveConfig),
|
|
86
|
+
await checkOrphanSlots(home),
|
|
85
87
|
],
|
|
86
88
|
missingRegistryPaths: registry.missingPaths,
|
|
87
89
|
};
|
|
88
90
|
}
|
|
91
|
+
async function checkOrphanSlots(home) {
|
|
92
|
+
const slots = await loadSlots(home);
|
|
93
|
+
const missing = (await Promise.all(Object.keys(slots).map(async (path) => {
|
|
94
|
+
try {
|
|
95
|
+
await access(path);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}))).filter(Boolean).length;
|
|
102
|
+
return {
|
|
103
|
+
id: "slots",
|
|
104
|
+
message: missing === 0
|
|
105
|
+
? "worktree slots healthy"
|
|
106
|
+
: `${missing} orphaned worktree slot${missing === 1 ? "" : "s"}`,
|
|
107
|
+
status: "ok",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
89
110
|
function buildDoctorFixes(missingRegistryPaths) {
|
|
90
111
|
if (missingRegistryPaths.length === 0)
|
|
91
112
|
return [];
|
package/dist/done.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface DoneCommandOptions {
|
|
2
|
+
branch?: string;
|
|
3
|
+
cwd: string;
|
|
4
|
+
force?: boolean;
|
|
5
|
+
json?: boolean;
|
|
6
|
+
keepBranch?: boolean;
|
|
7
|
+
stderr: (chunk: string) => void;
|
|
8
|
+
stdout: (chunk: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function runDoneCommand(options: DoneCommandOptions): Promise<number>;
|