@solaqua/gji 0.14.0 → 0.16.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 +63 -120
- package/dist/cli/commands/back.d.ts +1 -1
- package/dist/cli/commands/back.js +1 -0
- package/dist/cli/commands/clean.d.ts +2 -1
- package/dist/cli/commands/clean.js +66 -18
- package/dist/cli/commands/done.js +3 -2
- package/dist/cli/commands/go.js +18 -5
- package/dist/cli/commands/init.js +0 -14
- package/dist/cli/commands/new.d.ts +2 -1
- package/dist/cli/commands/new.js +71 -63
- package/dist/cli/commands/open.js +1 -1
- package/dist/cli/commands/pr-open.js +2 -2
- package/dist/cli/commands/pr.js +17 -6
- package/dist/cli/commands/run-hook.js +4 -0
- package/dist/cli/commands/undo.js +10 -74
- package/dist/cli/dependencies.d.ts +5 -1
- package/dist/cli/dependencies.js +7 -2
- package/dist/cli/program.js +6 -56
- package/dist/cli/runtime/headless.d.ts +3 -3
- package/dist/cli/runtime/headless.js +6 -4
- package/dist/domain/worktree/path-filter.d.ts +13 -0
- package/dist/domain/worktree/path-filter.js +72 -0
- package/dist/domain/worktree/policy.d.ts +1 -0
- package/dist/domain/worktree/policy.js +1 -1
- package/dist/gji-bundle.mjs +738 -969
- package/dist/infrastructure/filesystem/locked-file.d.ts +4 -0
- package/dist/infrastructure/filesystem/locked-file.js +53 -0
- package/dist/infrastructure/git/runner.d.ts +7 -0
- package/dist/infrastructure/git/runner.js +35 -1
- package/dist/infrastructure/persistence/history.js +12 -10
- package/dist/infrastructure/persistence/slots.js +24 -21
- package/dist/infrastructure/process/hooks.js +2 -0
- package/dist/infrastructure/repository/registry.js +11 -41
- package/dist/infrastructure/worktree/lifecycle.d.ts +25 -0
- package/dist/infrastructure/worktree/lifecycle.js +120 -19
- package/dist/ports/git.d.ts +1 -0
- package/dist/ports/hooks.d.ts +7 -0
- package/dist/presentation/shell/completion.js +14 -32
- package/dist/presentation/terminal/context-card.js +1 -1
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +4 -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 +1 -1
- package/man/man1/gji-go.1 +1 -1
- 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 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +3 -3
- 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 +1 -1
- package/man/man1/gji-undo.1 +1 -1
- package/man/man1/gji.1 +2 -12
- package/package.json +3 -2
- package/dist/cli/commands/remove.d.ts +0 -21
- package/dist/cli/commands/remove.js +0 -189
- package/dist/cli/commands/warp.d.ts +0 -28
- package/dist/cli/commands/warp.js +0 -123
- package/man/man1/gji-remove.1 +0 -19
- package/man/man1/gji-warp.1 +0 -16
package/dist/cli/commands/new.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { access, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
1
|
+
import { access, mkdir, rmdir } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
3
|
import { confirm, isCancel, text } from "@clack/prompts";
|
|
4
4
|
import { resolveWorktreePath, validateBranchName, } from "../../domain/worktree/policy.js";
|
|
5
5
|
import { createBootstrapReporter } from "../../presentation/bootstrap/output.js";
|
|
@@ -56,7 +56,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
56
56
|
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
57
57
|
}
|
|
58
58
|
else {
|
|
59
|
-
options.stderr(`gji new: ${message} in non-interactive mode
|
|
59
|
+
options.stderr(`gji new: ${message} in non-interactive mode\n`);
|
|
60
60
|
}
|
|
61
61
|
return 1;
|
|
62
62
|
}
|
|
@@ -103,7 +103,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
103
103
|
if (options.force) {
|
|
104
104
|
if (!options.dryRun) {
|
|
105
105
|
try {
|
|
106
|
-
await runtime.git.
|
|
106
|
+
await runtime.git.runGitRetryingContention(repository.repoRoot, [
|
|
107
107
|
"worktree",
|
|
108
108
|
"remove",
|
|
109
109
|
"--force",
|
|
@@ -111,6 +111,8 @@ export function createNewCommand(dependencies = {}) {
|
|
|
111
111
|
]);
|
|
112
112
|
}
|
|
113
113
|
catch (err) {
|
|
114
|
+
// The worktree claims its path below, which an empty leftover would block.
|
|
115
|
+
await rmdir(worktreePath).catch(() => undefined);
|
|
114
116
|
if (!isNotRegisteredWorktreeError(err)) {
|
|
115
117
|
const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
|
|
116
118
|
if (options.json) {
|
|
@@ -141,7 +143,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
141
143
|
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
142
144
|
}
|
|
143
145
|
else {
|
|
144
|
-
options.stderr(`gji new: ${message} in non-interactive mode
|
|
146
|
+
options.stderr(`gji new: ${message} in non-interactive mode\n`);
|
|
145
147
|
options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
|
|
146
148
|
options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
|
|
147
149
|
}
|
|
@@ -229,13 +231,14 @@ export function createNewCommand(dependencies = {}) {
|
|
|
229
231
|
if (options.take && submoduleFiles.length > 0)
|
|
230
232
|
options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
|
|
231
233
|
const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName, runtime);
|
|
232
|
-
const
|
|
233
|
-
!options.detached &&
|
|
234
|
-
!options.fromCurrent &&
|
|
235
|
-
!options.take &&
|
|
234
|
+
const createsBranch = !options.detached &&
|
|
236
235
|
options.mode !== "track" &&
|
|
237
236
|
options.mode !== "checkout" &&
|
|
238
237
|
!existingLocalBranch;
|
|
238
|
+
const shouldRefreshBase = createsBranch &&
|
|
239
|
+
!options.noFetch &&
|
|
240
|
+
!options.fromCurrent &&
|
|
241
|
+
!options.take;
|
|
239
242
|
const freshBaseRef = shouldRefreshBase
|
|
240
243
|
? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure, runtime)
|
|
241
244
|
: undefined;
|
|
@@ -253,42 +256,21 @@ export function createNewCommand(dependencies = {}) {
|
|
|
253
256
|
return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
|
|
254
257
|
}
|
|
255
258
|
}
|
|
256
|
-
const
|
|
257
|
-
? [
|
|
258
|
-
"worktree",
|
|
259
|
-
"add",
|
|
260
|
-
"--detach",
|
|
261
|
-
worktreePath,
|
|
262
|
-
...(takeStartPoint ? [takeStartPoint] : []),
|
|
263
|
-
]
|
|
264
|
-
: options.mode === "track"
|
|
265
|
-
? [
|
|
266
|
-
"worktree",
|
|
267
|
-
"add",
|
|
268
|
-
"--track",
|
|
269
|
-
"-b",
|
|
270
|
-
worktreeName,
|
|
271
|
-
worktreePath,
|
|
272
|
-
`${options.remote ?? "origin"}/${worktreeName}`,
|
|
273
|
-
]
|
|
274
|
-
: options.mode === "checkout" || existingLocalBranch
|
|
275
|
-
? ["worktree", "add", worktreePath, worktreeName]
|
|
276
|
-
: [
|
|
277
|
-
"worktree",
|
|
278
|
-
"add",
|
|
279
|
-
"-b",
|
|
280
|
-
worktreeName,
|
|
281
|
-
worktreePath,
|
|
282
|
-
...(takeStartPoint
|
|
283
|
-
? [takeStartPoint]
|
|
284
|
-
: startPoint
|
|
285
|
-
? [startPoint]
|
|
286
|
-
: freshBaseRef
|
|
287
|
-
? [freshBaseRef]
|
|
288
|
-
: []),
|
|
289
|
-
];
|
|
259
|
+
const addsNewBranch = createsBranch || (!options.detached && options.mode === "track");
|
|
290
260
|
try {
|
|
291
|
-
await runtime.git.
|
|
261
|
+
await runtime.git.addWorktree(repository.repoRoot, worktreePath, options.detached
|
|
262
|
+
? { detachedAt: takeStartPoint }
|
|
263
|
+
: addsNewBranch
|
|
264
|
+
? {
|
|
265
|
+
branch: worktreeName,
|
|
266
|
+
create: {
|
|
267
|
+
startPoint: options.mode === "track"
|
|
268
|
+
? `${options.remote ?? "origin"}/${worktreeName}`
|
|
269
|
+
: (takeStartPoint ?? startPoint ?? freshBaseRef),
|
|
270
|
+
track: options.mode === "track",
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: { branch: worktreeName });
|
|
292
274
|
}
|
|
293
275
|
catch (error) {
|
|
294
276
|
if (stashSha)
|
|
@@ -297,17 +279,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
297
279
|
}
|
|
298
280
|
if (stashSha &&
|
|
299
281
|
!(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr, runtime.git.runGit))) {
|
|
300
|
-
|
|
301
|
-
await runtime.git.runGit(repository.repoRoot, [
|
|
302
|
-
"worktree",
|
|
303
|
-
"remove",
|
|
304
|
-
"--force",
|
|
305
|
-
worktreePath,
|
|
306
|
-
]);
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
/* rollback best effort */
|
|
310
|
-
}
|
|
282
|
+
await runtime.git.removeCreatedWorktree(repository.repoRoot, worktreePath, addsNewBranch ? worktreeName : undefined);
|
|
311
283
|
return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
|
|
312
284
|
}
|
|
313
285
|
if (options.task !== undefined) {
|
|
@@ -338,6 +310,17 @@ export function createNewCommand(dependencies = {}) {
|
|
|
338
310
|
syncFiles: bootstrap.syncFileFailures,
|
|
339
311
|
});
|
|
340
312
|
}
|
|
313
|
+
if (createsBranch) {
|
|
314
|
+
await runNewHooks({
|
|
315
|
+
branch: worktreeName,
|
|
316
|
+
config,
|
|
317
|
+
hookNames: ["after-branch-create"],
|
|
318
|
+
options,
|
|
319
|
+
path: worktreePath,
|
|
320
|
+
repository,
|
|
321
|
+
runtime,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
341
324
|
if (options.json) {
|
|
342
325
|
const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
|
|
343
326
|
const output = options.take
|
|
@@ -438,7 +421,7 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
|
|
|
438
421
|
try {
|
|
439
422
|
let startPoint = baseBranch;
|
|
440
423
|
if (!options.noFetch) {
|
|
441
|
-
await runtime.git.
|
|
424
|
+
await runtime.git.runGitRetryingContention(repository.repoRoot, [
|
|
442
425
|
"fetch",
|
|
443
426
|
"--prune",
|
|
444
427
|
remote,
|
|
@@ -446,16 +429,29 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
|
|
|
446
429
|
]);
|
|
447
430
|
startPoint = `${remote}/${baseBranch}`;
|
|
448
431
|
}
|
|
449
|
-
await runtime.git.
|
|
450
|
-
"switch",
|
|
451
|
-
"-c",
|
|
452
|
-
branchName,
|
|
432
|
+
await runtime.git.createBranch(repository.repoRoot, branchName, {
|
|
453
433
|
startPoint,
|
|
454
|
-
|
|
434
|
+
});
|
|
435
|
+
try {
|
|
436
|
+
await runtime.git.runGit(options.cwd, ["switch", branchName]);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
await runtime.git.deleteCreatedBranch(repository.repoRoot, branchName);
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
455
442
|
}
|
|
456
443
|
catch (error) {
|
|
457
444
|
return emitNewError(options, `failed to create branch in the current worktree: ${toExecMessage(error)}`);
|
|
458
445
|
}
|
|
446
|
+
await runNewHooks({
|
|
447
|
+
branch: branchName,
|
|
448
|
+
config,
|
|
449
|
+
hookNames: ["after-branch-create", "after-switch"],
|
|
450
|
+
options,
|
|
451
|
+
path: repository.currentRoot,
|
|
452
|
+
repository,
|
|
453
|
+
runtime,
|
|
454
|
+
});
|
|
459
455
|
if (options.json) {
|
|
460
456
|
const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), repository.currentRoot, branchName);
|
|
461
457
|
options.stdout(`${JSON.stringify({ ...navigation, baseBranch, branchOnly: true }, null, 2)}\n`);
|
|
@@ -467,6 +463,18 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
|
|
|
467
463
|
}
|
|
468
464
|
return 0;
|
|
469
465
|
}
|
|
466
|
+
async function runNewHooks({ branch, config, hookNames, options, path, repository, runtime, }) {
|
|
467
|
+
const hooks = runtime.hooks.extractHooks(config);
|
|
468
|
+
const context = {
|
|
469
|
+
branch,
|
|
470
|
+
path,
|
|
471
|
+
repo: basename(repository.repoRoot),
|
|
472
|
+
slot: await runtime.slots.getWorktreeSlot(path),
|
|
473
|
+
};
|
|
474
|
+
for (const hookName of hookNames) {
|
|
475
|
+
await runtime.hooks.runHook(hooks[hookName], path, context, options.json ? () => undefined : options.stderr, options.json ? () => undefined : options.stdout);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
470
478
|
export const runNewCommand = createNewCommand();
|
|
471
479
|
async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure, runtime) {
|
|
472
480
|
const remote = runtime.config.resolveConfigString(config, "syncRemote") ?? "origin";
|
|
@@ -480,7 +488,7 @@ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFail
|
|
|
480
488
|
throw new Error("the remote default branch is unknown");
|
|
481
489
|
}
|
|
482
490
|
baseBranch = remoteBase.branch;
|
|
483
|
-
await runtime.git.
|
|
491
|
+
await runtime.git.runGitRetryingContention(repoRoot, [
|
|
484
492
|
"fetch",
|
|
485
493
|
"--prune",
|
|
486
494
|
remote,
|
|
@@ -640,7 +648,7 @@ function generateBranchPlaceholderSuffix(random) {
|
|
|
640
648
|
}
|
|
641
649
|
return suffix;
|
|
642
650
|
}
|
|
643
|
-
function applyConfiguredBranchPrefix(branch, branchPrefix) {
|
|
651
|
+
export function applyConfiguredBranchPrefix(branch, branchPrefix) {
|
|
644
652
|
if (typeof branchPrefix !== "string" || branchPrefix.length === 0) {
|
|
645
653
|
return branch;
|
|
646
654
|
}
|
|
@@ -26,7 +26,7 @@ export function createOpenCommand(dependencies = {}) {
|
|
|
26
26
|
return 1;
|
|
27
27
|
}
|
|
28
28
|
if (options.select && isHeadless()) {
|
|
29
|
-
options.stderr("gji open --select: selector is unavailable in non-interactive mode
|
|
29
|
+
options.stderr("gji open --select: selector is unavailable in non-interactive mode; pass a branch instead\n");
|
|
30
30
|
return 1;
|
|
31
31
|
}
|
|
32
32
|
const [worktrees, repository] = await Promise.all([
|
|
@@ -23,7 +23,7 @@ export function createPrOpenCommand(dependencies = {}) {
|
|
|
23
23
|
return 1;
|
|
24
24
|
}
|
|
25
25
|
if (options.select && isHeadless()) {
|
|
26
|
-
options.stderr("gji pr open --select: selector is unavailable in non-interactive mode
|
|
26
|
+
options.stderr("gji pr open --select: selector is unavailable in non-interactive mode; pass a branch or PR number instead\n");
|
|
27
27
|
return 1;
|
|
28
28
|
}
|
|
29
29
|
const repository = await detectRepository(options.cwd).catch((error) => {
|
|
@@ -236,7 +236,7 @@ async function openPullRequests(pullRequests, options, promptForPullRequest, ope
|
|
|
236
236
|
return 1;
|
|
237
237
|
}
|
|
238
238
|
if (pullRequests.length > 1 && isHeadless()) {
|
|
239
|
-
options.stderr("gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode
|
|
239
|
+
options.stderr("gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode\n");
|
|
240
240
|
return 1;
|
|
241
241
|
}
|
|
242
242
|
const selected = pullRequests.length === 1
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -54,7 +54,7 @@ export function createPrCommand(dependencies = {}) {
|
|
|
54
54
|
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
55
55
|
}
|
|
56
56
|
else {
|
|
57
|
-
options.stderr(`gji pr: ${message} in non-interactive mode
|
|
57
|
+
options.stderr(`gji pr: ${message} in non-interactive mode\n`);
|
|
58
58
|
options.stderr(`Hint: Use 'gji done pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
|
|
59
59
|
}
|
|
60
60
|
return 1;
|
|
@@ -108,10 +108,21 @@ export function createPrCommand(dependencies = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
await mkdir(dirname(worktreePath), { recursive: true });
|
|
110
110
|
const branchAlreadyExists = await localBranchExists(repository.repoRoot, branchName, runtime);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
try {
|
|
112
|
+
await runtime.git.addWorktree(repository.repoRoot, worktreePath, branchAlreadyExists
|
|
113
|
+
? { branch: branchName }
|
|
114
|
+
: { branch: branchName, create: { startPoint: remoteRef } });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const message = `failed to create worktree: ${error instanceof Error ? error.message : String(error)}`;
|
|
118
|
+
if (options.json) {
|
|
119
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
options.stderr(`gji pr: ${message}\n`);
|
|
123
|
+
}
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
115
126
|
const bootstrap = await runtime.bootstrap.bootstrapWorktree({
|
|
116
127
|
branch: branchName,
|
|
117
128
|
config,
|
|
@@ -173,7 +184,7 @@ export const runPrCommand = createPrCommand();
|
|
|
173
184
|
async function fetchPullRequestRef(repoRoot, input, prNumber, remoteRef, runtime) {
|
|
174
185
|
for (const sourceRef of listPullRequestSourceRefs(input, prNumber)) {
|
|
175
186
|
try {
|
|
176
|
-
await runtime.git.
|
|
187
|
+
await runtime.git.runGitRetryingContention(repoRoot, [
|
|
177
188
|
"fetch",
|
|
178
189
|
"origin",
|
|
179
190
|
`${sourceRef}:${remoteRef}`,
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { defaultCliDependencies } from "../dependencies.js";
|
|
2
2
|
const VALID_HOOKS = [
|
|
3
|
+
"after-branch-create",
|
|
3
4
|
"after-create",
|
|
4
5
|
"after-enter",
|
|
6
|
+
"after-switch",
|
|
5
7
|
"before-remove",
|
|
6
8
|
];
|
|
7
9
|
const CAMEL_ALIASES = {
|
|
10
|
+
afterBranchCreate: "after-branch-create",
|
|
8
11
|
afterCreate: "after-create",
|
|
9
12
|
afterEnter: "after-enter",
|
|
13
|
+
afterSwitch: "after-switch",
|
|
10
14
|
beforeRemove: "before-remove",
|
|
11
15
|
};
|
|
12
16
|
function isValidHook(hook) {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import { access,
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { confirm, isCancel } from "@clack/prompts";
|
|
5
|
+
import { withFileLock, writeJsonAtomic, } from "../../infrastructure/filesystem/locked-file.js";
|
|
5
6
|
import { defaultCliDependencies, } from "../dependencies.js";
|
|
6
7
|
import { isHeadless } from "../runtime/headless.js";
|
|
7
8
|
const MAX_UNDO_RECORDS = 20;
|
|
8
|
-
const UNDO_LOCK_STALE_AFTER_MS = 60_000;
|
|
9
|
-
const UNDO_LOCK_RETRY_DELAY_MS = 5;
|
|
10
|
-
const UNDO_LOCK_WAIT_TIMEOUT_MS = UNDO_LOCK_STALE_AFTER_MS + UNDO_LOCK_RETRY_DELAY_MS;
|
|
11
|
-
const undoLogQueues = new Map();
|
|
12
9
|
export function undoLogPath(home = homedir(), globalConfigDirectory = defaultCliDependencies.configStore
|
|
13
10
|
.GLOBAL_CONFIG_DIRECTORY) {
|
|
14
11
|
const configured = process.env.GJI_CONFIG_DIR;
|
|
@@ -65,16 +62,16 @@ export async function recordUndoOperation(op, repoRoot, worktrees, home = homedi
|
|
|
65
62
|
timestamp,
|
|
66
63
|
entries,
|
|
67
64
|
};
|
|
68
|
-
return
|
|
65
|
+
return withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
|
|
69
66
|
const existing = await loadUndoRecords(home, globalConfigDirectory);
|
|
70
67
|
await writeUndoRecords(home, [record, ...existing].slice(0, MAX_UNDO_RECORDS), globalConfigDirectory);
|
|
71
68
|
return record;
|
|
72
|
-
}
|
|
69
|
+
});
|
|
73
70
|
}
|
|
74
71
|
export async function finalizeUndoOperation(record, removedWorktrees, home = homedir(), globalConfigDirectory = defaultCliDependencies.configStore
|
|
75
72
|
.GLOBAL_CONFIG_DIRECTORY) {
|
|
76
73
|
const removedPaths = new Set(removedWorktrees.map((worktree) => worktree.path));
|
|
77
|
-
await
|
|
74
|
+
await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
|
|
78
75
|
const records = await loadUndoRecords(home, globalConfigDirectory);
|
|
79
76
|
const remaining = records.flatMap((candidate) => {
|
|
80
77
|
if (candidate.id !== record.id)
|
|
@@ -83,7 +80,7 @@ export async function finalizeUndoOperation(record, removedWorktrees, home = hom
|
|
|
83
80
|
return entries.length === 0 ? [] : [{ ...candidate, entries }];
|
|
84
81
|
});
|
|
85
82
|
await writeUndoRecords(home, remaining, globalConfigDirectory);
|
|
86
|
-
}
|
|
83
|
+
});
|
|
87
84
|
}
|
|
88
85
|
export async function restoreUndoRecord(record, home = homedir(), runtime = defaultCliDependencies) {
|
|
89
86
|
const globalConfigDirectory = runtime.configStore.GLOBAL_CONFIG_DIRECTORY;
|
|
@@ -104,7 +101,7 @@ export async function restoreUndoRecord(record, home = homedir(), runtime = defa
|
|
|
104
101
|
unresolved.push(entry);
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
|
-
await
|
|
104
|
+
await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
|
|
108
105
|
const records = await loadUndoRecords(home, globalConfigDirectory);
|
|
109
106
|
const remaining = records.flatMap((candidate) => {
|
|
110
107
|
if (candidate.id !== record.id)
|
|
@@ -114,7 +111,7 @@ export async function restoreUndoRecord(record, home = homedir(), runtime = defa
|
|
|
114
111
|
return [{ ...candidate, entries: unresolved }];
|
|
115
112
|
});
|
|
116
113
|
await writeUndoRecords(home, remaining, globalConfigDirectory);
|
|
117
|
-
}
|
|
114
|
+
});
|
|
118
115
|
return { restored, failed };
|
|
119
116
|
}
|
|
120
117
|
export async function runUndoCommand(options) {
|
|
@@ -292,70 +289,9 @@ async function assertCommitExists(repoRoot, headSha, runGit) {
|
|
|
292
289
|
throw new Error("commit no longer exists (gc'd) — cannot restore");
|
|
293
290
|
}
|
|
294
291
|
}
|
|
295
|
-
async function enqueueUndoLogWrite(home, operation, globalConfigDirectory = defaultCliDependencies.configStore
|
|
296
|
-
.GLOBAL_CONFIG_DIRECTORY) {
|
|
297
|
-
const path = undoLogPath(home, globalConfigDirectory);
|
|
298
|
-
await mkdir(dirname(path), { recursive: true });
|
|
299
|
-
const previous = undoLogQueues.get(path) ?? Promise.resolve();
|
|
300
|
-
const runLocked = async () => {
|
|
301
|
-
const release = await acquireUndoLogLock(path);
|
|
302
|
-
try {
|
|
303
|
-
return await operation();
|
|
304
|
-
}
|
|
305
|
-
finally {
|
|
306
|
-
await release();
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
const current = previous.then(runLocked, runLocked);
|
|
310
|
-
const settled = current.then(() => undefined, () => undefined);
|
|
311
|
-
undoLogQueues.set(path, settled);
|
|
312
|
-
try {
|
|
313
|
-
return await current;
|
|
314
|
-
}
|
|
315
|
-
finally {
|
|
316
|
-
if (undoLogQueues.get(path) === settled)
|
|
317
|
-
undoLogQueues.delete(path);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
async function acquireUndoLogLock(path) {
|
|
321
|
-
const lockPath = `${path}.lock`;
|
|
322
|
-
const deadline = Date.now() + UNDO_LOCK_WAIT_TIMEOUT_MS;
|
|
323
|
-
while (Date.now() < deadline) {
|
|
324
|
-
try {
|
|
325
|
-
const handle = await open(lockPath, "wx");
|
|
326
|
-
return async () => {
|
|
327
|
-
await handle.close();
|
|
328
|
-
await unlink(lockPath).catch(() => undefined);
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
catch (error) {
|
|
332
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
333
|
-
throw error;
|
|
334
|
-
const lockStat = await stat(lockPath).catch(() => null);
|
|
335
|
-
if (lockStat && Date.now() - lockStat.mtimeMs > UNDO_LOCK_STALE_AFTER_MS)
|
|
336
|
-
await unlink(lockPath).catch(() => undefined);
|
|
337
|
-
const remainingMs = deadline - Date.now();
|
|
338
|
-
if (remainingMs > 0)
|
|
339
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(UNDO_LOCK_RETRY_DELAY_MS, remainingMs)));
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
throw new Error("timed out waiting for the undo journal lock");
|
|
343
|
-
}
|
|
344
292
|
async function writeUndoRecords(home, records, globalConfigDirectory = defaultCliDependencies.configStore
|
|
345
293
|
.GLOBAL_CONFIG_DIRECTORY) {
|
|
346
|
-
|
|
347
|
-
await mkdir(dirname(path), { recursive: true });
|
|
348
|
-
const temporaryPath = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
349
|
-
try {
|
|
350
|
-
await writeFile(temporaryPath, `${JSON.stringify(records, null, 2)}\n`, {
|
|
351
|
-
encoding: "utf8",
|
|
352
|
-
flag: "wx",
|
|
353
|
-
});
|
|
354
|
-
await rename(temporaryPath, path);
|
|
355
|
-
}
|
|
356
|
-
finally {
|
|
357
|
-
await unlink(temporaryPath).catch(() => undefined);
|
|
358
|
-
}
|
|
294
|
+
await writeJsonAtomic(undoLogPath(home, globalConfigDirectory), records);
|
|
359
295
|
}
|
|
360
296
|
function isUndoRecord(value) {
|
|
361
297
|
if (!value || typeof value !== "object")
|
|
@@ -12,7 +12,7 @@ import { clearTask, readTask, writeTask } from "../infrastructure/persistence/ta
|
|
|
12
12
|
import { extractHooks, runHook } from "../infrastructure/process/hooks.js";
|
|
13
13
|
import { loadRegistry, REGISTRY_FILE_PATH, registerRepo, removeMissingRegistryEntries } from "../infrastructure/repository/registry.js";
|
|
14
14
|
import { readWorktreeInfo, readWorktreeInfos, serializeWorktreeInfo } from "../infrastructure/worktree/info.js";
|
|
15
|
-
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree } from "../infrastructure/worktree/lifecycle.js";
|
|
15
|
+
import { addWorktree, createBranch, deleteBranch, deleteCreatedBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeCreatedWorktree, removeWorktree } from "../infrastructure/worktree/lifecycle.js";
|
|
16
16
|
import type { DependencyBootstrapPort, WorktreeBootstrapPort } from "../ports/bootstrap.js";
|
|
17
17
|
import type { ConfigPort } from "../ports/config.js";
|
|
18
18
|
import type { GitCommandPort } from "../ports/git.js";
|
|
@@ -42,6 +42,10 @@ export interface CliDependencies {
|
|
|
42
42
|
runHook: typeof runHook;
|
|
43
43
|
};
|
|
44
44
|
git: GitCommandPort & {
|
|
45
|
+
addWorktree: typeof addWorktree;
|
|
46
|
+
createBranch: typeof createBranch;
|
|
47
|
+
deleteCreatedBranch: typeof deleteCreatedBranch;
|
|
48
|
+
removeCreatedWorktree: typeof removeCreatedWorktree;
|
|
45
49
|
isDirtyWorktree: typeof isDirtyWorktree;
|
|
46
50
|
readWorktreeHealth: typeof readWorktreeHealth;
|
|
47
51
|
isBranchMergedInto: typeof isBranchMergedInto;
|
package/dist/cli/dependencies.js
CHANGED
|
@@ -2,7 +2,7 @@ import { prepareDependencyBootstrap, previewDependencyBootstrap, resolveDependen
|
|
|
2
2
|
import { validateSyncFilePattern } from "../infrastructure/filesystem/file-sync.js";
|
|
3
3
|
import { isDirtyWorktree, readWorktreeHealth, } from "../infrastructure/git/health.js";
|
|
4
4
|
import { isBranchMergedInto, readBranchLastCommitTimestamp, resolveRemoteBase, resolveRemoteDefaultBranch, } from "../infrastructure/git/refs.js";
|
|
5
|
-
import { runGit, runGitRaw } from "../infrastructure/git/runner.js";
|
|
5
|
+
import { runGit, runGitRaw, runGitRetryingContention, } from "../infrastructure/git/runner.js";
|
|
6
6
|
import { openBrowser } from "../infrastructure/integrations/browser.js";
|
|
7
7
|
import { defaultSpawnEditor, EDITORS, } from "../infrastructure/integrations/editor.js";
|
|
8
8
|
import { createPullRequestQuery } from "../infrastructure/integrations/pull-requests.js";
|
|
@@ -15,7 +15,7 @@ import { configPort, repositoryContextPort, repositoryRefPort, repositoryRegistr
|
|
|
15
15
|
import { loadRegistry, REGISTRY_FILE_PATH, registerRepo, removeMissingRegistryEntries, } from "../infrastructure/repository/registry.js";
|
|
16
16
|
import { bootstrapWorktree } from "../infrastructure/worktree/bootstrap.js";
|
|
17
17
|
import { readWorktreeInfo, readWorktreeInfos, serializeWorktreeInfo, } from "../infrastructure/worktree/info.js";
|
|
18
|
-
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree, } from "../infrastructure/worktree/lifecycle.js";
|
|
18
|
+
import { addWorktree, createBranch, deleteBranch, deleteCreatedBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeCreatedWorktree, removeWorktree, } from "../infrastructure/worktree/lifecycle.js";
|
|
19
19
|
import { formatLastCommit, formatRelativeAge, formatUpstreamState, } from "../presentation/worktree/format.js";
|
|
20
20
|
export function createCliDependencies() {
|
|
21
21
|
const pullRequests = createPullRequestQuery();
|
|
@@ -49,6 +49,11 @@ export function createCliDependencies() {
|
|
|
49
49
|
git: {
|
|
50
50
|
runGit,
|
|
51
51
|
runGitRaw,
|
|
52
|
+
runGitRetryingContention,
|
|
53
|
+
addWorktree,
|
|
54
|
+
createBranch,
|
|
55
|
+
deleteCreatedBranch,
|
|
56
|
+
removeCreatedWorktree,
|
|
52
57
|
isDirtyWorktree,
|
|
53
58
|
readWorktreeHealth,
|
|
54
59
|
isBranchMergedInto,
|
package/dist/cli/program.js
CHANGED
|
@@ -16,7 +16,6 @@ import { runNewCommand } from "./commands/new.js";
|
|
|
16
16
|
import { runOpenCommand } from "./commands/open.js";
|
|
17
17
|
import { runPrCommand } from "./commands/pr.js";
|
|
18
18
|
import { createPrOpenCommand, } from "./commands/pr-open.js";
|
|
19
|
-
import { runRemoveCommand } from "./commands/remove.js";
|
|
20
19
|
import { runRootCommand } from "./commands/root.js";
|
|
21
20
|
import { runHookCommand } from "./commands/run-hook.js";
|
|
22
21
|
import { runStatusCommand } from "./commands/status.js";
|
|
@@ -24,7 +23,6 @@ import { runSyncCommand } from "./commands/sync.js";
|
|
|
24
23
|
import { runSyncFilesCommand } from "./commands/sync-files-command.js";
|
|
25
24
|
import { runTaskCommand } from "./commands/task-command.js";
|
|
26
25
|
import { runUndoCommand } from "./commands/undo.js";
|
|
27
|
-
import { runWarpCommand } from "./commands/warp.js";
|
|
28
26
|
import { createCliDependencies } from "./dependencies.js";
|
|
29
27
|
import { isHeadless } from "./runtime/headless.js";
|
|
30
28
|
export function createProgram() {
|
|
@@ -295,30 +293,15 @@ function registerCommands(program) {
|
|
|
295
293
|
.description("interactively prune linked worktrees")
|
|
296
294
|
.option("-f, --force", "bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches")
|
|
297
295
|
.option("--stale", "only target clean worktrees whose upstream is gone and branch is merged into the default branch")
|
|
296
|
+
.option("--path <pattern>", "only target worktrees under a directory or matching a path glob (repeatable)", collectOption)
|
|
298
297
|
.option("--dry-run", "show what would be deleted without removing anything")
|
|
299
298
|
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
300
299
|
.action(notImplemented("clean"));
|
|
301
|
-
program
|
|
302
|
-
.command("remove [branch]")
|
|
303
|
-
.alias("rm")
|
|
304
|
-
.description("deprecated: use gji done for one worktree or gji clean for bulk cleanup")
|
|
305
|
-
.option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
|
|
306
|
-
.option("--dry-run", "show what would be deleted without removing anything")
|
|
307
|
-
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
308
|
-
.action(notImplemented("remove"));
|
|
309
300
|
program
|
|
310
301
|
.command("run-hook <hook>")
|
|
311
302
|
.alias("trigger-hook")
|
|
312
|
-
.description("run a named hook (after-create, after-enter, before-remove) in the current worktree")
|
|
303
|
+
.description("run a named hook (after-branch-create, after-create, after-enter, after-switch, before-remove) in the current worktree")
|
|
313
304
|
.action(notImplemented("run-hook"));
|
|
314
|
-
program
|
|
315
|
-
.command("warp [branch]")
|
|
316
|
-
.description("deprecated: use gji go (press Tab for all known repos)")
|
|
317
|
-
// --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
|
|
318
|
-
// The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
|
|
319
|
-
.option("--print", "print the resolved worktree path without changing directory")
|
|
320
|
-
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
321
|
-
.action(notImplemented("warp"));
|
|
322
305
|
const configCommand = program
|
|
323
306
|
.command("config")
|
|
324
307
|
.description("manage global config defaults")
|
|
@@ -638,6 +621,7 @@ function attachCommandActions(program, options) {
|
|
|
638
621
|
dryRun: commandOptions.dryRun,
|
|
639
622
|
force: commandOptions.force,
|
|
640
623
|
json: commandOptions.json,
|
|
624
|
+
paths: commandOptions.path,
|
|
641
625
|
stale: commandOptions.stale,
|
|
642
626
|
runtime: options.dependencies,
|
|
643
627
|
stderr: options.stderr,
|
|
@@ -647,27 +631,6 @@ function attachCommandActions(program, options) {
|
|
|
647
631
|
throw commanderExit(exitCode);
|
|
648
632
|
}
|
|
649
633
|
});
|
|
650
|
-
const runRemovalCommand = async (branch, commandOptions = {}) => {
|
|
651
|
-
const exitCode = await runRemoveCommand({
|
|
652
|
-
branch,
|
|
653
|
-
cwd: options.cwd,
|
|
654
|
-
dryRun: commandOptions.dryRun,
|
|
655
|
-
force: commandOptions.force,
|
|
656
|
-
json: commandOptions.json,
|
|
657
|
-
runtime: options.dependencies,
|
|
658
|
-
stderr: options.stderr,
|
|
659
|
-
stdout: options.stdout,
|
|
660
|
-
});
|
|
661
|
-
if (exitCode !== 0) {
|
|
662
|
-
throw commanderExit(exitCode);
|
|
663
|
-
}
|
|
664
|
-
};
|
|
665
|
-
requireCommand(program, "remove").action(async (branch, commandOptions) => {
|
|
666
|
-
if (!commandOptions.json) {
|
|
667
|
-
options.stderr("gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n");
|
|
668
|
-
}
|
|
669
|
-
return runRemovalCommand(branch, commandOptions);
|
|
670
|
-
});
|
|
671
634
|
requireCommand(program, "run-hook").action(async (hook) => {
|
|
672
635
|
const exitCode = await runHookCommand({
|
|
673
636
|
cwd: options.cwd,
|
|
@@ -679,22 +642,6 @@ function attachCommandActions(program, options) {
|
|
|
679
642
|
throw commanderExit(exitCode);
|
|
680
643
|
}
|
|
681
644
|
});
|
|
682
|
-
requireCommand(program, "warp").action(async (branch, commandOptions) => {
|
|
683
|
-
if (!commandOptions.json) {
|
|
684
|
-
options.stderr("gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n");
|
|
685
|
-
}
|
|
686
|
-
const exitCode = await runWarpCommand({
|
|
687
|
-
branch,
|
|
688
|
-
cwd: options.cwd,
|
|
689
|
-
json: commandOptions.json,
|
|
690
|
-
runtime: options.dependencies,
|
|
691
|
-
stderr: options.stderr,
|
|
692
|
-
stdout: options.stdout,
|
|
693
|
-
});
|
|
694
|
-
if (exitCode !== 0) {
|
|
695
|
-
throw commanderExit(exitCode);
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
645
|
const configCommand = requireCommand(program, "config");
|
|
699
646
|
configCommand.action(async () => {
|
|
700
647
|
const exitCode = await runConfigCommand({
|
|
@@ -752,6 +699,9 @@ function requireCommand(parent, name) {
|
|
|
752
699
|
}
|
|
753
700
|
return command;
|
|
754
701
|
}
|
|
702
|
+
function collectOption(value, previous = []) {
|
|
703
|
+
return [...previous, value];
|
|
704
|
+
}
|
|
755
705
|
function notImplemented(commandName) {
|
|
756
706
|
return () => {
|
|
757
707
|
throw new Error(`'${commandName}' is not implemented yet.`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Returns true when
|
|
3
|
-
*
|
|
4
|
-
* Commands that would otherwise
|
|
2
|
+
* Returns true when no one can answer a prompt: GJI_NO_TUI=1 is set, or stdin
|
|
3
|
+
* or stdout is not a terminal.
|
|
4
|
+
* Commands that would otherwise wait for input must fail fast instead.
|
|
5
5
|
*/
|
|
6
6
|
export declare function isHeadless(): boolean;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Returns true when
|
|
3
|
-
*
|
|
4
|
-
* Commands that would otherwise
|
|
2
|
+
* Returns true when no one can answer a prompt: GJI_NO_TUI=1 is set, or stdin
|
|
3
|
+
* or stdout is not a terminal.
|
|
4
|
+
* Commands that would otherwise wait for input must fail fast instead.
|
|
5
5
|
*/
|
|
6
6
|
export function isHeadless() {
|
|
7
|
-
return process.env.GJI_NO_TUI === "1"
|
|
7
|
+
return (process.env.GJI_NO_TUI === "1" ||
|
|
8
|
+
process.stdin.isTTY !== true ||
|
|
9
|
+
process.stdout.isTTY !== true);
|
|
8
10
|
}
|