@solaqua/gji 0.9.0 → 0.11.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 +93 -20
- 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 +25 -3
- package/dist/cli.js +105 -19
- 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/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/done.d.ts +10 -0
- package/dist/done.js +208 -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 +5489 -1206
- package/dist/go-resolver.d.ts +35 -0
- package/dist/go-resolver.js +185 -0
- package/dist/go.d.ts +5 -2
- package/dist/go.js +262 -62
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +13 -7
- package/dist/init.js +30 -22
- package/dist/install-prompt.d.ts +1 -0
- package/dist/install-prompt.js +3 -25
- 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 +434 -36
- package/dist/open.d.ts +1 -0
- package/dist/open.js +14 -2
- package/dist/pr.d.ts +6 -2
- package/dist/pr.js +135 -30
- package/dist/remove.js +20 -0
- package/dist/repo.d.ts +3 -0
- package/dist/repo.js +57 -6
- package/dist/safe-destination.d.ts +15 -0
- package/dist/safe-destination.js +166 -0
- package/dist/shell-completion.js +47 -14
- 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/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 +93 -0
- package/dist/worktree-management.d.ts +1 -0
- package/dist/worktree-management.js +23 -4
- package/dist/worktree-picker.d.ts +21 -8
- package/dist/worktree-picker.js +107 -15
- 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 +3 -0
- package/dist/worktree-sources.js +31 -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 +9 -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 +4 -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-undo.1 +16 -0
- package/man/man1/gji-warp.1 +3 -6
- package/man/man1/gji.1 +13 -5
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runCleanCommand } from "./clean.js";
|
|
|
6
6
|
import { runCompletionCommand } from "./completion.js";
|
|
7
7
|
import { runConfigCommand } from "./config-command.js";
|
|
8
8
|
import { runDoctorCommand } from "./doctor.js";
|
|
9
|
+
import { runDoneCommand } from "./done.js";
|
|
9
10
|
import { runGoCommand } from "./go.js";
|
|
10
11
|
import { isHeadless } from "./headless.js";
|
|
11
12
|
import { runHistoryCommand } from "./history-command.js";
|
|
@@ -23,6 +24,7 @@ import { runHookCommand } from "./run-hook.js";
|
|
|
23
24
|
import { runStatusCommand } from "./status.js";
|
|
24
25
|
import { runSyncCommand } from "./sync.js";
|
|
25
26
|
import { runSyncFilesCommand } from "./sync-files-command.js";
|
|
27
|
+
import { runUndoCommand } from "./undo.js";
|
|
26
28
|
import { runWarpCommand } from "./warp.js";
|
|
27
29
|
export function createProgram() {
|
|
28
30
|
const program = new Command();
|
|
@@ -34,6 +36,11 @@ export function createProgram() {
|
|
|
34
36
|
.showHelpAfterError()
|
|
35
37
|
.showSuggestionAfterError();
|
|
36
38
|
registerCommands(program);
|
|
39
|
+
program.addHelpText("after", "\nCommon workflows:\n" +
|
|
40
|
+
" gji new <branch> create a worktree\n" +
|
|
41
|
+
" gji go <branch> navigate to a worktree\n" +
|
|
42
|
+
" gji go choose; press Tab for all repositories\n" +
|
|
43
|
+
" gji go - return to the previous worktree\n");
|
|
37
44
|
return program;
|
|
38
45
|
}
|
|
39
46
|
function readPackageMetadata() {
|
|
@@ -46,13 +53,13 @@ function readPackageMetadata() {
|
|
|
46
53
|
}
|
|
47
54
|
export async function runCli(argv, options = {}) {
|
|
48
55
|
await maybeNotifyForUpdates(argv);
|
|
49
|
-
if (argv[0] !== "doctor") {
|
|
50
|
-
maybeRegisterCurrentRepo(options.cwd ?? process.cwd());
|
|
51
|
-
}
|
|
52
|
-
const program = createProgram();
|
|
53
56
|
const cwd = options.cwd ?? process.cwd();
|
|
54
57
|
const stdout = options.stdout ?? (() => undefined);
|
|
55
58
|
const stderr = options.stderr ?? (() => undefined);
|
|
59
|
+
if (shouldRegisterCurrentRepo(argv)) {
|
|
60
|
+
await maybeRegisterCurrentRepo(cwd);
|
|
61
|
+
}
|
|
62
|
+
const program = createProgram();
|
|
56
63
|
program.configureOutput({
|
|
57
64
|
writeErr: stderr,
|
|
58
65
|
writeOut: stdout,
|
|
@@ -109,22 +116,58 @@ function defaultNotifyForUpdates(pkg) {
|
|
|
109
116
|
const notifier = updateNotifier({ pkg });
|
|
110
117
|
notifier.notify();
|
|
111
118
|
}
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
function shouldRegisterCurrentRepo(argv) {
|
|
120
|
+
const command = argv[0];
|
|
121
|
+
return (argv.length > 0 &&
|
|
122
|
+
![
|
|
123
|
+
"--help",
|
|
124
|
+
"-h",
|
|
125
|
+
"--version",
|
|
126
|
+
"-V",
|
|
127
|
+
"completion",
|
|
128
|
+
"config",
|
|
129
|
+
"doctor",
|
|
130
|
+
"help",
|
|
131
|
+
"init",
|
|
132
|
+
].includes(command ?? ""));
|
|
133
|
+
}
|
|
134
|
+
async function maybeRegisterCurrentRepo(cwd) {
|
|
135
|
+
try {
|
|
136
|
+
const { repoRoot } = await detectRepository(cwd);
|
|
137
|
+
await registerRepo(repoRoot);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Registration is best effort; command behaviour should not depend on it.
|
|
141
|
+
}
|
|
116
142
|
}
|
|
117
143
|
function registerCommands(program) {
|
|
118
144
|
program
|
|
119
145
|
.command("new [branch]")
|
|
120
|
-
.description("create a new branch or detached linked worktree")
|
|
146
|
+
.description("create a new branch or detached linked worktree and CoW-bootstrap configured directories")
|
|
121
147
|
.option("-f, --force", "remove and recreate the worktree if the target path already exists")
|
|
122
148
|
.option("--detached", "create a detached worktree without a branch")
|
|
149
|
+
.option("--from-current", "base the new branch on the current worktree instead of the main worktree (cannot be combined with --detached)")
|
|
150
|
+
.option("--no-fetch", "skip refreshing the remote default branch before creating the new branch")
|
|
151
|
+
.option("--take", "move current uncommitted changes into the new worktree")
|
|
152
|
+
.option("--copy", "copy current uncommitted changes instead of moving them (requires --take)")
|
|
123
153
|
.option("--open", "open the new worktree in an editor after creation")
|
|
124
154
|
.option("--editor <cli>", "editor CLI to use with --open (code, cursor, zed, …)")
|
|
125
155
|
.option("--dry-run", "show what would be created without executing any git commands or writing files")
|
|
126
156
|
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
127
157
|
.action(notImplemented("new"));
|
|
158
|
+
program
|
|
159
|
+
.command("done [branch]")
|
|
160
|
+
.description("finish the current linked worktree and return safely")
|
|
161
|
+
.option("--force", "remove dirty or unmerged worktrees without prompting")
|
|
162
|
+
.option("--keep-branch", "remove the worktree but preserve its branch")
|
|
163
|
+
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
164
|
+
.action(notImplemented("done"));
|
|
165
|
+
program
|
|
166
|
+
.command("undo [id]")
|
|
167
|
+
.description("restore the most recent destructive worktree operation")
|
|
168
|
+
.option("--list", "list recoverable operations")
|
|
169
|
+
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
170
|
+
.action(notImplemented("undo"));
|
|
128
171
|
program
|
|
129
172
|
.command("init [shell]")
|
|
130
173
|
.description("set up shell integration interactively or print a shell wrapper")
|
|
@@ -167,6 +210,7 @@ function registerCommands(program) {
|
|
|
167
210
|
program
|
|
168
211
|
.command("open [branch]")
|
|
169
212
|
.description("open the worktree in an editor")
|
|
213
|
+
.option("--select", "choose a worktree with the interactive selector")
|
|
170
214
|
.option("--editor <cli>", "editor CLI to use (code, cursor, zed, windsurf, subl, …)")
|
|
171
215
|
.option("--save", "save the chosen editor to global config")
|
|
172
216
|
.option("--workspace", "generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)")
|
|
@@ -174,8 +218,10 @@ function registerCommands(program) {
|
|
|
174
218
|
program
|
|
175
219
|
.command("go [branch]")
|
|
176
220
|
.alias("jump")
|
|
177
|
-
.description("
|
|
221
|
+
.description("resolve and jump to a worktree path")
|
|
222
|
+
.option("--root", "navigate to the main repository root")
|
|
178
223
|
.option("--print", "print the resolved worktree path explicitly")
|
|
224
|
+
.option("--json", "emit JSON for an existing worktree destination")
|
|
179
225
|
.action(notImplemented("go"));
|
|
180
226
|
program
|
|
181
227
|
.command("root")
|
|
@@ -231,7 +277,7 @@ function registerCommands(program) {
|
|
|
231
277
|
program
|
|
232
278
|
.command("remove [branch]")
|
|
233
279
|
.alias("rm")
|
|
234
|
-
.description("
|
|
280
|
+
.description("deprecated: use gji done for one worktree or gji clean for bulk cleanup")
|
|
235
281
|
.option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
|
|
236
282
|
.option("--dry-run", "show what would be deleted without removing anything")
|
|
237
283
|
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
@@ -243,8 +289,7 @@ function registerCommands(program) {
|
|
|
243
289
|
.action(notImplemented("run-hook"));
|
|
244
290
|
program
|
|
245
291
|
.command("warp [branch]")
|
|
246
|
-
.description("
|
|
247
|
-
.option("-n, --new [branch]", "create a new worktree in a registered repo")
|
|
292
|
+
.description("deprecated: use gji go (press Tab for all known repos)")
|
|
248
293
|
// --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
|
|
249
294
|
// The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
|
|
250
295
|
.option("--print", "print the resolved worktree path without changing directory")
|
|
@@ -274,17 +319,50 @@ function attachCommandActions(program, options) {
|
|
|
274
319
|
const exitCode = await runNewCommand({
|
|
275
320
|
...options,
|
|
276
321
|
branch,
|
|
322
|
+
copy: commandOptions.copy,
|
|
277
323
|
detached: commandOptions.detached,
|
|
278
324
|
dryRun: commandOptions.dryRun,
|
|
279
325
|
editor: commandOptions.editor,
|
|
326
|
+
fromCurrent: commandOptions.fromCurrent,
|
|
280
327
|
force: commandOptions.force,
|
|
328
|
+
noFetch: commandOptions.fetch === false,
|
|
281
329
|
json: commandOptions.json,
|
|
282
330
|
open: commandOptions.open,
|
|
331
|
+
take: commandOptions.take,
|
|
283
332
|
});
|
|
284
333
|
if (exitCode !== 0) {
|
|
285
334
|
throw commanderExit(exitCode);
|
|
286
335
|
}
|
|
287
336
|
});
|
|
337
|
+
program.commands
|
|
338
|
+
.find((command) => command.name() === "done")
|
|
339
|
+
?.action(async (branch, commandOptions) => {
|
|
340
|
+
const exitCode = await runDoneCommand({
|
|
341
|
+
branch,
|
|
342
|
+
cwd: options.cwd,
|
|
343
|
+
force: commandOptions.force,
|
|
344
|
+
json: commandOptions.json,
|
|
345
|
+
keepBranch: commandOptions.keepBranch,
|
|
346
|
+
stderr: options.stderr,
|
|
347
|
+
stdout: options.stdout,
|
|
348
|
+
});
|
|
349
|
+
if (exitCode !== 0)
|
|
350
|
+
throw commanderExit(exitCode);
|
|
351
|
+
});
|
|
352
|
+
program.commands
|
|
353
|
+
.find((command) => command.name() === "undo")
|
|
354
|
+
?.action(async (id, commandOptions) => {
|
|
355
|
+
const exitCode = await runUndoCommand({
|
|
356
|
+
cwd: options.cwd,
|
|
357
|
+
id,
|
|
358
|
+
json: commandOptions.json,
|
|
359
|
+
list: commandOptions.list,
|
|
360
|
+
stderr: options.stderr,
|
|
361
|
+
stdout: options.stdout,
|
|
362
|
+
});
|
|
363
|
+
if (exitCode !== 0)
|
|
364
|
+
throw commanderExit(exitCode);
|
|
365
|
+
});
|
|
288
366
|
program.commands
|
|
289
367
|
.find((command) => command.name() === "init")
|
|
290
368
|
?.action(async (shell, commandOptions) => {
|
|
@@ -397,6 +475,7 @@ function attachCommandActions(program, options) {
|
|
|
397
475
|
cwd: options.cwd,
|
|
398
476
|
editor: commandOptions.editor,
|
|
399
477
|
save: commandOptions.save,
|
|
478
|
+
select: commandOptions.select,
|
|
400
479
|
stderr: options.stderr,
|
|
401
480
|
stdout: options.stdout,
|
|
402
481
|
workspace: commandOptions.workspace,
|
|
@@ -411,7 +490,9 @@ function attachCommandActions(program, options) {
|
|
|
411
490
|
const exitCode = await runGoCommand({
|
|
412
491
|
branch,
|
|
413
492
|
cwd: options.cwd,
|
|
493
|
+
json: commandOptions.json,
|
|
414
494
|
print: commandOptions.print,
|
|
495
|
+
root: commandOptions.root,
|
|
415
496
|
stderr: options.stderr,
|
|
416
497
|
stdout: options.stdout,
|
|
417
498
|
});
|
|
@@ -560,7 +641,12 @@ function attachCommandActions(program, options) {
|
|
|
560
641
|
};
|
|
561
642
|
program.commands
|
|
562
643
|
.find((command) => command.name() === "remove")
|
|
563
|
-
?.action(
|
|
644
|
+
?.action(async (branch, commandOptions) => {
|
|
645
|
+
if (!commandOptions.json) {
|
|
646
|
+
options.stderr("gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n");
|
|
647
|
+
}
|
|
648
|
+
return runRemovalCommand(branch, commandOptions);
|
|
649
|
+
});
|
|
564
650
|
program.commands
|
|
565
651
|
.find((command) => command.name() === "run-hook")
|
|
566
652
|
?.action(async (hook) => {
|
|
@@ -576,14 +662,13 @@ function attachCommandActions(program, options) {
|
|
|
576
662
|
program.commands
|
|
577
663
|
.find((command) => command.name() === "warp")
|
|
578
664
|
?.action(async (branch, commandOptions) => {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
665
|
+
if (!commandOptions.json) {
|
|
666
|
+
options.stderr("gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n");
|
|
667
|
+
}
|
|
582
668
|
const exitCode = await runWarpCommand({
|
|
583
|
-
branch
|
|
669
|
+
branch,
|
|
584
670
|
cwd: options.cwd,
|
|
585
671
|
json: commandOptions.json,
|
|
586
|
-
newWorktree,
|
|
587
672
|
stderr: options.stderr,
|
|
588
673
|
stdout: options.stdout,
|
|
589
674
|
});
|
|
@@ -621,6 +706,7 @@ function attachCommandActions(program, options) {
|
|
|
621
706
|
action: "set",
|
|
622
707
|
cwd: options.cwd,
|
|
623
708
|
key,
|
|
709
|
+
stderr: options.stderr,
|
|
624
710
|
stdout: options.stdout,
|
|
625
711
|
value,
|
|
626
712
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CloneFailureStore {
|
|
2
|
+
isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
|
|
3
|
+
cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
|
|
4
|
+
clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare class FileCloneFailureStore implements CloneFailureStore {
|
|
7
|
+
private updateQueue;
|
|
8
|
+
isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
|
|
9
|
+
cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
|
|
10
|
+
clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
|
|
11
|
+
private update;
|
|
12
|
+
private withStateLock;
|
|
13
|
+
private readState;
|
|
14
|
+
private writeState;
|
|
15
|
+
private stateFilePath;
|
|
16
|
+
}
|
|
17
|
+
export declare const defaultCloneFailureStore: CloneFailureStore;
|
|
18
|
+
export declare function cloneFailureScope(source: string, destination: string): Promise<string>;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rename, rm, rmdir, stat, unlink, utimes, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
|
|
6
|
+
const STATE_FILE_NAME = "state.json";
|
|
7
|
+
const STATE_LOCK_SUFFIX = ".lock";
|
|
8
|
+
const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const STATE_LOCK_TTL_MS = 30 * 1000;
|
|
10
|
+
export class FileCloneFailureStore {
|
|
11
|
+
updateQueue = Promise.resolve();
|
|
12
|
+
async isCached(repoRoot, directory, scope) {
|
|
13
|
+
const state = await this.readState();
|
|
14
|
+
const repoState = state.syncDirs?.[repoRoot];
|
|
15
|
+
const key = failureKey(directory, scope);
|
|
16
|
+
if (!repoState || !Object.hasOwn(repoState, key))
|
|
17
|
+
return false;
|
|
18
|
+
const failure = repoState[key];
|
|
19
|
+
return (isPlainObject(failure) &&
|
|
20
|
+
typeof failure.failedAt === "number" &&
|
|
21
|
+
Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS);
|
|
22
|
+
}
|
|
23
|
+
async cache(repoRoot, directory, reason, scope) {
|
|
24
|
+
await this.update(async () => {
|
|
25
|
+
const state = await this.readState();
|
|
26
|
+
const syncDirs = state.syncDirs ?? {};
|
|
27
|
+
const repoState = syncDirs[repoRoot] ?? {};
|
|
28
|
+
const key = failureKey(directory, scope);
|
|
29
|
+
syncDirs[repoRoot] = {
|
|
30
|
+
...repoState,
|
|
31
|
+
[key]: { failedAt: Date.now(), reason },
|
|
32
|
+
};
|
|
33
|
+
await this.writeState({ ...state, syncDirs });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async clear(repoRoot, directory, scope) {
|
|
37
|
+
await this.update(async () => {
|
|
38
|
+
const state = await this.readState();
|
|
39
|
+
const repoState = state.syncDirs?.[repoRoot];
|
|
40
|
+
const key = failureKey(directory, scope);
|
|
41
|
+
if (!repoState || !Object.hasOwn(repoState, key))
|
|
42
|
+
return;
|
|
43
|
+
const nextRepoState = { ...repoState };
|
|
44
|
+
delete nextRepoState[key];
|
|
45
|
+
const syncDirs = { ...state.syncDirs };
|
|
46
|
+
if (Object.keys(nextRepoState).length === 0)
|
|
47
|
+
delete syncDirs[repoRoot];
|
|
48
|
+
else
|
|
49
|
+
syncDirs[repoRoot] = nextRepoState;
|
|
50
|
+
await this.writeState({ ...state, syncDirs });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async update(operation) {
|
|
54
|
+
const next = this.updateQueue.then(() => this.withStateLock(operation), () => this.withStateLock(operation));
|
|
55
|
+
this.updateQueue = next.then(() => undefined, () => undefined);
|
|
56
|
+
await next;
|
|
57
|
+
}
|
|
58
|
+
async withStateLock(operation) {
|
|
59
|
+
const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`;
|
|
60
|
+
const lockToken = await acquireStateLock(lockPath);
|
|
61
|
+
if (lockToken === undefined)
|
|
62
|
+
return;
|
|
63
|
+
const stopHeartbeat = startStateLockHeartbeat(lockPath, lockToken);
|
|
64
|
+
try {
|
|
65
|
+
await operation();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
stopHeartbeat();
|
|
69
|
+
await releaseStateLock(lockPath, lockToken);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async readState() {
|
|
73
|
+
try {
|
|
74
|
+
const raw = await readFile(this.stateFilePath(), "utf8");
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
if (!isPlainObject(parsed))
|
|
77
|
+
return {};
|
|
78
|
+
return isPlainObject(parsed.syncDirs)
|
|
79
|
+
? parsed
|
|
80
|
+
: { ...parsed, syncDirs: {} };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async writeState(state) {
|
|
87
|
+
try {
|
|
88
|
+
const path = this.stateFilePath();
|
|
89
|
+
const directory = dirname(path);
|
|
90
|
+
await mkdir(directory, { recursive: true });
|
|
91
|
+
const temporaryDirectory = await mkdtemp(join(directory, `.gji-state-${randomUUID()}-`));
|
|
92
|
+
const temporaryPath = join(temporaryDirectory, STATE_FILE_NAME);
|
|
93
|
+
try {
|
|
94
|
+
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
95
|
+
await rename(temporaryPath, path);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await rm(temporaryDirectory, { force: true, recursive: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// The cache is advisory and must never block worktree creation.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
stateFilePath(home = homedir()) {
|
|
106
|
+
const configuredDirectory = process.env.GJI_CONFIG_DIR;
|
|
107
|
+
const directory = configuredDirectory
|
|
108
|
+
? resolve(configuredDirectory)
|
|
109
|
+
: join(home, GLOBAL_CONFIG_DIRECTORY);
|
|
110
|
+
return join(directory, STATE_FILE_NAME);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function acquireStateLock(lockPath) {
|
|
114
|
+
const lockToken = randomUUID();
|
|
115
|
+
await mkdir(dirname(lockPath), { recursive: true }).catch(() => undefined);
|
|
116
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
117
|
+
try {
|
|
118
|
+
await mkdir(lockPath);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (!isErrorCode(error, "EEXIST"))
|
|
122
|
+
return undefined;
|
|
123
|
+
try {
|
|
124
|
+
const freshnessPath = await lockFreshnessPath(lockPath);
|
|
125
|
+
const lockStats = await stat(freshnessPath);
|
|
126
|
+
if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) {
|
|
127
|
+
const stalePath = `${lockPath}.stale-${randomUUID()}`;
|
|
128
|
+
try {
|
|
129
|
+
await rename(lockPath, stalePath);
|
|
130
|
+
}
|
|
131
|
+
catch (renameError) {
|
|
132
|
+
if (isErrorCode(renameError, "ENOENT"))
|
|
133
|
+
continue;
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
await rm(stalePath, { force: true, recursive: true });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (lockError) {
|
|
141
|
+
if (!isErrorCode(lockError, "ENOENT"))
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
|
|
149
|
+
flag: "wx",
|
|
150
|
+
});
|
|
151
|
+
return lockToken;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
await rm(lockPath, { force: true, recursive: true }).catch(() => undefined);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
async function lockFreshnessPath(lockPath) {
|
|
161
|
+
try {
|
|
162
|
+
const owner = (await readdir(lockPath)).find((entry) => entry.startsWith("owner-"));
|
|
163
|
+
return owner ? join(lockPath, owner) : lockPath;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (isErrorCode(error, "ENOENT"))
|
|
167
|
+
throw error;
|
|
168
|
+
return lockPath;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function startStateLockHeartbeat(lockPath, lockToken) {
|
|
172
|
+
const timer = setInterval(() => {
|
|
173
|
+
void refreshStateLock(lockPath, lockToken);
|
|
174
|
+
}, STATE_LOCK_TTL_MS / 3);
|
|
175
|
+
timer.unref?.();
|
|
176
|
+
return () => clearInterval(timer);
|
|
177
|
+
}
|
|
178
|
+
async function refreshStateLock(lockPath, lockToken) {
|
|
179
|
+
try {
|
|
180
|
+
const ownerPath = join(lockPath, ownerFileName(lockToken));
|
|
181
|
+
await readFile(ownerPath, "utf8");
|
|
182
|
+
const now = new Date();
|
|
183
|
+
await utimes(ownerPath, now, now);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// The cache is advisory; a failed heartbeat must not block worktree creation.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function releaseStateLock(lockPath, lockToken) {
|
|
190
|
+
try {
|
|
191
|
+
const ownerPath = join(lockPath, ownerFileName(lockToken));
|
|
192
|
+
await unlink(ownerPath);
|
|
193
|
+
await rmdir(lockPath);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!isErrorCode(error, "ENOENT") && !isErrorCode(error, "ENOTEMPTY")) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function ownerFileName(lockToken) {
|
|
202
|
+
return `owner-${lockToken}`;
|
|
203
|
+
}
|
|
204
|
+
export const defaultCloneFailureStore = new FileCloneFailureStore();
|
|
205
|
+
export async function cloneFailureScope(source, destination) {
|
|
206
|
+
const sourcePath = resolve(source);
|
|
207
|
+
const destinationParent = resolve(dirname(destination));
|
|
208
|
+
const [sourceDevice, destinationDevice] = await Promise.all([
|
|
209
|
+
readDevice(sourcePath),
|
|
210
|
+
readDevice(destinationParent),
|
|
211
|
+
]);
|
|
212
|
+
return JSON.stringify([sourcePath, sourceDevice, destinationDevice]);
|
|
213
|
+
}
|
|
214
|
+
async function readDevice(path) {
|
|
215
|
+
try {
|
|
216
|
+
return (await stat(path)).dev;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function failureKey(directory, scope) {
|
|
223
|
+
return scope === undefined ? directory : JSON.stringify([scope, directory]);
|
|
224
|
+
}
|
|
225
|
+
function isPlainObject(value) {
|
|
226
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
227
|
+
}
|
|
228
|
+
function isErrorCode(error, code) {
|
|
229
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
230
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type CommandRunner = (command: string, cwd: string, stderr: (chunk: string) => void, stdout?: (chunk: string) => void, options?: CommandRunnerOptions) => Promise<void>;
|
|
2
|
+
export interface CommandRunnerOptions {
|
|
3
|
+
env?: NodeJS.ProcessEnv;
|
|
4
|
+
shell?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const runCommand: CommandRunner;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export const runCommand = async (command, cwd, stderr, stdout = (chunk) => process.stdout.write(chunk), options) => {
|
|
3
|
+
await new Promise((resolve, reject) => {
|
|
4
|
+
const shell = options?.shell ?? true;
|
|
5
|
+
const [executable, args] = shell ? [command, []] : splitCommand(command);
|
|
6
|
+
const child = spawn(executable, args, {
|
|
7
|
+
cwd,
|
|
8
|
+
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
|
9
|
+
shell,
|
|
10
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
11
|
+
});
|
|
12
|
+
child.stdout.on("data", (chunk) => {
|
|
13
|
+
stdout(chunk.toString());
|
|
14
|
+
});
|
|
15
|
+
child.stderr.on("data", (chunk) => {
|
|
16
|
+
stderr(chunk.toString());
|
|
17
|
+
});
|
|
18
|
+
child.on("close", (code) => {
|
|
19
|
+
if (code !== 0) {
|
|
20
|
+
reject(new Error(`exited with code ${code}`));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
resolve();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
child.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
function splitCommand(command) {
|
|
30
|
+
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/gu) ?? [];
|
|
31
|
+
const [first, ...rest] = tokens;
|
|
32
|
+
if (!first)
|
|
33
|
+
throw new Error("command must not be empty");
|
|
34
|
+
return [
|
|
35
|
+
first.replace(/^(["'])(.*)\1$/u, "$2"),
|
|
36
|
+
rest.map((token) => token.replace(/^(["'])(.*)\1$/u, "$2")),
|
|
37
|
+
];
|
|
38
|
+
}
|
package/dist/completion.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { resolveSupportedShell } from "./shell.js";
|
|
2
2
|
import { renderShellCompletion } from "./shell-completion.js";
|
|
3
3
|
export async function runCompletionCommand(options) {
|
|
4
|
-
const shell =
|
|
4
|
+
const shell = options.shell
|
|
5
|
+
? resolveSupportedShell(options.shell, undefined)
|
|
6
|
+
: resolveSupportedShell(undefined, process.env.SHELL);
|
|
5
7
|
if (!shell) {
|
|
6
|
-
|
|
8
|
+
const message = options.shell
|
|
9
|
+
? `Unsupported shell "${options.shell}". Supported shells: bash, fish, or zsh.`
|
|
10
|
+
: "Unable to detect a supported shell. Specify one explicitly: bash, fish, or zsh.";
|
|
11
|
+
options.stderr?.(`${message}\n`);
|
|
7
12
|
return 1;
|
|
8
13
|
}
|
|
9
14
|
options.stdout(renderShellCompletion(shell));
|
package/dist/config-command.d.ts
CHANGED
package/dist/config-command.js
CHANGED
|
@@ -13,7 +13,13 @@ export async function runConfigCommand(options) {
|
|
|
13
13
|
}
|
|
14
14
|
case "set":
|
|
15
15
|
if (options.key && options.value !== undefined) {
|
|
16
|
-
|
|
16
|
+
try {
|
|
17
|
+
await updateGlobalConfigKey(options.key, parseConfigValue(options.value));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
options.stderr?.(`gji config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
17
23
|
return 0;
|
|
18
24
|
}
|
|
19
25
|
break;
|
package/dist/config.d.ts
CHANGED
|
@@ -4,14 +4,35 @@ export declare const GLOBAL_CONFIG_NAME = "config.json";
|
|
|
4
4
|
export declare const KNOWN_CONFIG_KEYS: ReadonlySet<string>;
|
|
5
5
|
export declare const KNOWN_GLOBAL_CONFIG_KEYS: ReadonlySet<string>;
|
|
6
6
|
export type GjiConfig = Record<string, unknown>;
|
|
7
|
+
export type DependencyBootstrapMode = "off" | "cow-then-repair" | "install-only";
|
|
8
|
+
export interface EffectiveGjiConfig extends GjiConfig {
|
|
9
|
+
branchPrefix?: string;
|
|
10
|
+
dependencyBuildCommand?: string;
|
|
11
|
+
dependencyBootstrap?: DependencyBootstrapMode;
|
|
12
|
+
editor?: string;
|
|
13
|
+
hooks?: Record<string, unknown>;
|
|
14
|
+
installSaveTarget?: string;
|
|
15
|
+
shellIntegration?: string;
|
|
16
|
+
skipInstallPrompt?: boolean;
|
|
17
|
+
syncDirs?: readonly string[];
|
|
18
|
+
syncFiles?: readonly string[];
|
|
19
|
+
syncDefaultBranch?: string;
|
|
20
|
+
syncRemote?: string;
|
|
21
|
+
worktreePath?: string;
|
|
22
|
+
}
|
|
7
23
|
export interface LoadedConfig {
|
|
8
24
|
config: GjiConfig;
|
|
9
25
|
exists: boolean;
|
|
10
26
|
path: string;
|
|
11
27
|
}
|
|
28
|
+
export interface EffectiveConfigResult {
|
|
29
|
+
config: EffectiveGjiConfig;
|
|
30
|
+
dependencyBootstrapExplicit: boolean;
|
|
31
|
+
}
|
|
12
32
|
export declare const DEFAULT_CONFIG: GjiConfig;
|
|
13
33
|
export declare function loadConfig(root: string): Promise<LoadedConfig>;
|
|
14
|
-
export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<
|
|
34
|
+
export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<EffectiveGjiConfig>;
|
|
35
|
+
export declare function loadEffectiveConfigResult(root: string, home?: string, onWarning?: (message: string) => void): Promise<EffectiveConfigResult>;
|
|
15
36
|
export declare function loadGlobalConfig(home?: string): Promise<LoadedConfig>;
|
|
16
37
|
export declare function saveLocalConfig(root: string, config: GjiConfig): Promise<string>;
|
|
17
38
|
export declare function updateLocalConfigKey(root: string, key: string, value: unknown): Promise<GjiConfig>;
|
|
@@ -22,3 +43,7 @@ export declare function updateGlobalRepoConfigKey(repoRoot: string, key: string,
|
|
|
22
43
|
export declare function GLOBAL_CONFIG_FILE_PATH(home?: string): string;
|
|
23
44
|
export declare function parseConfigValue(value: string): unknown;
|
|
24
45
|
export declare function resolveConfigString(config: GjiConfig, key: string): string | undefined;
|
|
46
|
+
export declare function validateSyncDirsConfig(value: unknown): void;
|
|
47
|
+
export declare function normalizeDependencyBootstrap(value: unknown): DependencyBootstrapMode | undefined;
|
|
48
|
+
export declare function normalizeSyncDirsConfig(value: unknown): readonly string[] | undefined;
|
|
49
|
+
export declare function validateSyncDirPattern(pattern: string): string;
|