@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/new.js
CHANGED
|
@@ -1,28 +1,57 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import {
|
|
2
|
+
import { access, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import { isCancel, text } from "@clack/prompts";
|
|
6
|
-
import {
|
|
5
|
+
import { confirm, isCancel, text } from "@clack/prompts";
|
|
6
|
+
import { createBootstrapReporter } from "./bootstrap-output.js";
|
|
7
|
+
import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
|
|
8
|
+
import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
|
|
7
9
|
import { pathExists, promptForPathConflict, } from "./conflict.js";
|
|
10
|
+
import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
|
|
11
|
+
import { cloneDir } from "./dir-clone.js";
|
|
8
12
|
import { defaultSpawnEditor, EDITORS } from "./editor.js";
|
|
9
|
-
import {
|
|
13
|
+
import { formatBytes } from "./format-bytes.js";
|
|
14
|
+
import { resolveRemoteBase, runGit } from "./git.js";
|
|
10
15
|
import { isHeadless } from "./headless.js";
|
|
11
16
|
import { recordWorktreeUsage } from "./history.js";
|
|
12
|
-
import {
|
|
13
|
-
import { maybeRunInstallPrompt, } from "./install-prompt.js";
|
|
17
|
+
import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
|
|
14
18
|
import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js";
|
|
15
19
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
20
|
+
import { estimateSyncDirectories } from "./sync-plan.js";
|
|
21
|
+
import { bootstrapWorktree } from "./worktree-bootstrap.js";
|
|
16
22
|
const execFileAsync = promisify(execFile);
|
|
17
23
|
const NEW_OUTPUT_FILE_ENV = "GJI_NEW_OUTPUT_FILE";
|
|
18
24
|
export function createNewCommand(dependencies = {}) {
|
|
19
25
|
const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
|
|
26
|
+
const cloneDirectory = dependencies.cloneDir ?? cloneDir;
|
|
20
27
|
const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
|
|
28
|
+
const promptForFetchFailure = dependencies.promptForFetchFailure ?? defaultPromptForFetchFailure;
|
|
21
29
|
const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
|
|
22
30
|
const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
|
|
23
31
|
return async function runNewCommand(options) {
|
|
32
|
+
if (options.copy && !options.take)
|
|
33
|
+
return emitNewError(options, "--copy requires --take");
|
|
34
|
+
if (options.detached && options.fromCurrent) {
|
|
35
|
+
const message = "--from-current cannot be used with --detached";
|
|
36
|
+
if (options.json) {
|
|
37
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
options.stderr(`gji new: ${message}\n`);
|
|
41
|
+
}
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
24
44
|
const repository = await detectRepository(options.cwd);
|
|
25
|
-
|
|
45
|
+
let config;
|
|
46
|
+
let dependencyBootstrapExplicit;
|
|
47
|
+
try {
|
|
48
|
+
const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
|
|
49
|
+
config = loaded.config;
|
|
50
|
+
dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return emitNewError(options, error instanceof Error ? error.message : String(error));
|
|
54
|
+
}
|
|
26
55
|
const usesGeneratedDetachedName = options.detached && options.branch === undefined;
|
|
27
56
|
if (options.editor && !options.open) {
|
|
28
57
|
options.stderr("gji new: --editor has no effect without --open\n");
|
|
@@ -67,9 +96,11 @@ export function createNewCommand(dependencies = {}) {
|
|
|
67
96
|
const configuredBasePath = rawBasePath?.startsWith("/") || rawBasePath?.startsWith("~")
|
|
68
97
|
? rawBasePath
|
|
69
98
|
: undefined;
|
|
70
|
-
const worktreeName = options.
|
|
99
|
+
const worktreeName = options.mode
|
|
71
100
|
? rawBranch
|
|
72
|
-
:
|
|
101
|
+
: options.detached
|
|
102
|
+
? rawBranch
|
|
103
|
+
: applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
|
|
73
104
|
const worktreePath = usesGeneratedDetachedName
|
|
74
105
|
? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
|
|
75
106
|
: resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
|
|
@@ -109,7 +140,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
109
140
|
}
|
|
110
141
|
else {
|
|
111
142
|
options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
112
|
-
options.stderr(`Hint: Use 'gji
|
|
143
|
+
options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
|
|
113
144
|
options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
|
|
114
145
|
}
|
|
115
146
|
return 1;
|
|
@@ -118,16 +149,64 @@ export function createNewCommand(dependencies = {}) {
|
|
|
118
149
|
const choice = await prompt(worktreePath);
|
|
119
150
|
if (choice === "reuse") {
|
|
120
151
|
await recordWorktreeUsage(worktreePath, worktreeName);
|
|
121
|
-
await writeOutput(worktreePath, options.stdout);
|
|
152
|
+
await writeOutput(worktreePath, options.stdout, options.outputEnv);
|
|
122
153
|
return 0;
|
|
123
154
|
}
|
|
124
155
|
options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
|
|
125
156
|
return 1;
|
|
126
157
|
}
|
|
127
158
|
}
|
|
159
|
+
const dependencyPolicy = await resolveDependencyBootstrapPolicy({
|
|
160
|
+
currentRoot: repository.currentRoot,
|
|
161
|
+
repoRoot: repository.repoRoot,
|
|
162
|
+
worktreePath,
|
|
163
|
+
}, config, dependencyBootstrapExplicit, {
|
|
164
|
+
dependencies,
|
|
165
|
+
dryRun: options.dryRun,
|
|
166
|
+
legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
|
|
167
|
+
nonInteractive: !!options.json,
|
|
168
|
+
stderr: options.stderr,
|
|
169
|
+
});
|
|
170
|
+
config = {
|
|
171
|
+
...config,
|
|
172
|
+
dependencyBootstrap: dependencyPolicy.mode,
|
|
173
|
+
};
|
|
128
174
|
if (options.dryRun) {
|
|
175
|
+
if (options.take) {
|
|
176
|
+
const changedFiles = await listTakeFiles(options.cwd);
|
|
177
|
+
const submoduleFiles = await listSubmoduleFiles(options.cwd, changedFiles);
|
|
178
|
+
const transferableFiles = changedFiles.filter((file) => !submoduleFiles.includes(file));
|
|
179
|
+
const ignoredFiles = await listIgnoredFiles(options.cwd);
|
|
180
|
+
if (transferableFiles.length === 0)
|
|
181
|
+
return emitNewError(options, submoduleFiles.length > 0
|
|
182
|
+
? "nothing to take: submodule changes are not supported"
|
|
183
|
+
: ignoredFiles.length > 0
|
|
184
|
+
? "nothing to take: ignored files are not moved"
|
|
185
|
+
: "nothing to take: working tree is clean");
|
|
186
|
+
if (options.json)
|
|
187
|
+
options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true, take: transferableFiles, ignored: ignoredFiles, submodules: submoduleFiles }, null, 2)}\n`);
|
|
188
|
+
else {
|
|
189
|
+
options.stdout(`Would take ${transferableFiles.length} changed file${transferableFiles.length === 1 ? "" : "s"} into ${worktreeName}${ignoredFiles.length > 0 ? ` (ignored files not moved: ${ignoredFiles.length})` : ""}${submoduleFiles.length > 0 ? ` (submodules not moved: ${submoduleFiles.length})` : ""}\n${transferableFiles.map((file) => ` ${file}\n`).join("")}`);
|
|
190
|
+
}
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const dryRunSyncDirs = await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? []);
|
|
194
|
+
const dryRunDependencyBootstrap = await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
|
|
195
|
+
currentRoot: repository.currentRoot,
|
|
196
|
+
repoRoot: repository.repoRoot,
|
|
197
|
+
cargoBuildCommand: config.dependencyBuildCommand,
|
|
198
|
+
worktreePath,
|
|
199
|
+
});
|
|
129
200
|
if (options.json) {
|
|
130
|
-
|
|
201
|
+
const output = {
|
|
202
|
+
...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName),
|
|
203
|
+
dryRun: true,
|
|
204
|
+
};
|
|
205
|
+
if (dryRunSyncDirs.length > 0)
|
|
206
|
+
output.syncDirs = dryRunSyncDirs;
|
|
207
|
+
if (dryRunDependencyBootstrap.targets.length > 0)
|
|
208
|
+
output.dependencyBootstrap = dryRunDependencyBootstrap;
|
|
209
|
+
options.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
131
210
|
}
|
|
132
211
|
else {
|
|
133
212
|
const resolvedEditor = options.open
|
|
@@ -136,42 +215,163 @@ export function createNewCommand(dependencies = {}) {
|
|
|
136
215
|
const openNote = resolvedEditor
|
|
137
216
|
? `, then open in ${resolvedEditor}`
|
|
138
217
|
: "";
|
|
139
|
-
options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n`);
|
|
218
|
+
options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
|
|
140
219
|
}
|
|
141
220
|
return 0;
|
|
142
221
|
}
|
|
143
222
|
await mkdir(dirname(worktreePath), { recursive: true });
|
|
144
|
-
const
|
|
145
|
-
?
|
|
146
|
-
:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
? config.syncFiles.filter((p) => typeof p === "string")
|
|
223
|
+
const startPoint = options.fromCurrent && !options.detached
|
|
224
|
+
? await resolveCurrentWorktreeHead(options.cwd)
|
|
225
|
+
: undefined;
|
|
226
|
+
const takeStartPoint = options.take
|
|
227
|
+
? await resolveCurrentWorktreeHead(options.cwd)
|
|
228
|
+
: undefined;
|
|
229
|
+
const changedTakeFiles = options.take
|
|
230
|
+
? await listTakeFiles(options.cwd)
|
|
153
231
|
: [];
|
|
154
|
-
|
|
232
|
+
const submoduleFiles = options.take
|
|
233
|
+
? await listSubmoduleFiles(options.cwd, changedTakeFiles)
|
|
234
|
+
: [];
|
|
235
|
+
const takeFiles = changedTakeFiles.filter((file) => !submoduleFiles.includes(file));
|
|
236
|
+
const takeUntracked = options.take
|
|
237
|
+
? await countUntrackedFiles(options.cwd)
|
|
238
|
+
: 0;
|
|
239
|
+
if (options.take && takeFiles.length === 0)
|
|
240
|
+
return emitNewError(options, submoduleFiles.length > 0
|
|
241
|
+
? "nothing to take: submodule changes are not supported"
|
|
242
|
+
: "nothing to take: working tree is clean");
|
|
243
|
+
if (options.take && submoduleFiles.length > 0)
|
|
244
|
+
options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
|
|
245
|
+
const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName);
|
|
246
|
+
const shouldRefreshBase = !options.noFetch &&
|
|
247
|
+
!options.detached &&
|
|
248
|
+
!options.fromCurrent &&
|
|
249
|
+
!options.take &&
|
|
250
|
+
options.mode !== "track" &&
|
|
251
|
+
options.mode !== "checkout" &&
|
|
252
|
+
!existingLocalBranch;
|
|
253
|
+
const freshBaseRef = shouldRefreshBase
|
|
254
|
+
? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure)
|
|
255
|
+
: undefined;
|
|
256
|
+
if (freshBaseRef === null)
|
|
257
|
+
return 1;
|
|
258
|
+
let stashSha = null;
|
|
259
|
+
if (options.take) {
|
|
155
260
|
try {
|
|
156
|
-
await
|
|
261
|
+
const inProgressState = await detectInProgressGitState(options.cwd);
|
|
262
|
+
if (inProgressState)
|
|
263
|
+
return emitNewError(options, `cannot take changes while Git is in progress (${inProgressState})`);
|
|
264
|
+
stashSha = await createTakeStash(options.cwd, worktreeName);
|
|
157
265
|
}
|
|
158
266
|
catch (error) {
|
|
159
|
-
options
|
|
267
|
+
return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const gitArgs = options.detached
|
|
271
|
+
? [
|
|
272
|
+
"worktree",
|
|
273
|
+
"add",
|
|
274
|
+
"--detach",
|
|
275
|
+
worktreePath,
|
|
276
|
+
...(takeStartPoint ? [takeStartPoint] : []),
|
|
277
|
+
]
|
|
278
|
+
: options.mode === "track"
|
|
279
|
+
? [
|
|
280
|
+
"worktree",
|
|
281
|
+
"add",
|
|
282
|
+
"--track",
|
|
283
|
+
"-b",
|
|
284
|
+
worktreeName,
|
|
285
|
+
worktreePath,
|
|
286
|
+
`${options.remote ?? "origin"}/${worktreeName}`,
|
|
287
|
+
]
|
|
288
|
+
: options.mode === "checkout" || existingLocalBranch
|
|
289
|
+
? ["worktree", "add", worktreePath, worktreeName]
|
|
290
|
+
: [
|
|
291
|
+
"worktree",
|
|
292
|
+
"add",
|
|
293
|
+
"-b",
|
|
294
|
+
worktreeName,
|
|
295
|
+
worktreePath,
|
|
296
|
+
...(takeStartPoint
|
|
297
|
+
? [takeStartPoint]
|
|
298
|
+
: startPoint
|
|
299
|
+
? [startPoint]
|
|
300
|
+
: freshBaseRef
|
|
301
|
+
? [freshBaseRef]
|
|
302
|
+
: []),
|
|
303
|
+
];
|
|
304
|
+
try {
|
|
305
|
+
await execFileAsync("git", gitArgs, { cwd: repository.repoRoot });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (stashSha)
|
|
309
|
+
await restoreTakeStash(options.cwd, stashSha, options.stderr);
|
|
310
|
+
return emitNewError(options, `failed to create worktree: ${toExecMessage(error)}`);
|
|
311
|
+
}
|
|
312
|
+
if (stashSha &&
|
|
313
|
+
!(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr))) {
|
|
314
|
+
try {
|
|
315
|
+
await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repository.repoRoot });
|
|
160
316
|
}
|
|
317
|
+
catch {
|
|
318
|
+
/* rollback best effort */
|
|
319
|
+
}
|
|
320
|
+
return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
|
|
161
321
|
}
|
|
162
|
-
|
|
163
|
-
const hooks = extractHooks(config);
|
|
164
|
-
await runHook(hooks["after-create"], worktreePath, {
|
|
322
|
+
const bootstrap = await bootstrapWorktree({
|
|
165
323
|
branch: worktreeName,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
324
|
+
cloneDirectory,
|
|
325
|
+
config,
|
|
326
|
+
currentRoot: repository.currentRoot,
|
|
327
|
+
nonInteractive: !!options.json,
|
|
328
|
+
repoRoot: repository.repoRoot,
|
|
329
|
+
reporter: createBootstrapReporter(options.stderr, !!options.json),
|
|
330
|
+
runCommand: dependencies.runInstallCommand,
|
|
331
|
+
commandStdout: options.json ? () => undefined : options.stdout,
|
|
332
|
+
commandStderr: options.json ? () => undefined : options.stderr,
|
|
333
|
+
json: options.json,
|
|
334
|
+
worktreePath,
|
|
335
|
+
installDependencies: dependencies,
|
|
336
|
+
dependencyBootstrapPolicy: dependencyPolicy,
|
|
337
|
+
});
|
|
338
|
+
if (!bootstrap.ready) {
|
|
339
|
+
return emitNewError(options, "worktree bootstrap failed", {
|
|
340
|
+
dependencyBootstrap: bootstrap.dependencyBootstrap,
|
|
341
|
+
path: worktreePath,
|
|
342
|
+
skipped: bootstrap.skippedDirs,
|
|
343
|
+
syncFiles: bootstrap.syncFileFailures,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
169
346
|
if (options.json) {
|
|
170
|
-
|
|
347
|
+
const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
|
|
348
|
+
const output = options.take
|
|
349
|
+
? {
|
|
350
|
+
...navigation,
|
|
351
|
+
taken: {
|
|
352
|
+
files: takeFiles.length,
|
|
353
|
+
untracked: takeUntracked,
|
|
354
|
+
submodules: submoduleFiles,
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
: { ...navigation };
|
|
358
|
+
if (bootstrap.clonedDirs.length > 0) {
|
|
359
|
+
output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
|
|
360
|
+
dir,
|
|
361
|
+
ms,
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
if (bootstrap.skippedDirs.length > 0)
|
|
365
|
+
output.skipped = bootstrap.skippedDirs;
|
|
366
|
+
if (bootstrap.dependencyBootstrap.mode !== "off")
|
|
367
|
+
output.dependencyBootstrap = bootstrap.dependencyBootstrap;
|
|
368
|
+
options.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
171
369
|
}
|
|
172
370
|
else {
|
|
371
|
+
if (options.take)
|
|
372
|
+
options.stderr(`✓ took ${takeFiles.length} changed file${takeFiles.length === 1 ? "" : "s"} (${takeUntracked} untracked) → ${worktreeName}\n`);
|
|
173
373
|
await recordWorktreeUsage(worktreePath, worktreeName);
|
|
174
|
-
await writeOutput(worktreePath, options.stdout);
|
|
374
|
+
await writeOutput(worktreePath, options.stdout, options.outputEnv);
|
|
175
375
|
}
|
|
176
376
|
if (options.open) {
|
|
177
377
|
const resolvedEditor = options.editor ?? resolveConfigString(config, "editor");
|
|
@@ -181,6 +381,60 @@ export function createNewCommand(dependencies = {}) {
|
|
|
181
381
|
};
|
|
182
382
|
}
|
|
183
383
|
export const runNewCommand = createNewCommand();
|
|
384
|
+
async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure) {
|
|
385
|
+
const remote = resolveConfigString(config, "syncRemote") ?? "origin";
|
|
386
|
+
const configuredRemote = resolveConfigString(config, "syncRemote");
|
|
387
|
+
let baseBranch = resolveConfigString(config, "syncDefaultBranch");
|
|
388
|
+
try {
|
|
389
|
+
const remoteBase = await resolveRemoteBase(repoRoot, remote, baseBranch);
|
|
390
|
+
if (!remoteBase) {
|
|
391
|
+
if (!configuredRemote)
|
|
392
|
+
return "HEAD";
|
|
393
|
+
throw new Error("the remote default branch is unknown");
|
|
394
|
+
}
|
|
395
|
+
baseBranch = remoteBase.branch;
|
|
396
|
+
await runGit(repoRoot, ["fetch", "--prune", remote, remoteBase.branch]);
|
|
397
|
+
return remoteBase.ref;
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
401
|
+
const message = `Could not refresh ${remote}: ${detail}`;
|
|
402
|
+
if (options.json || isHeadless()) {
|
|
403
|
+
emitNewError(options, `${message}. Use --no-fetch to create from the local repository HEAD.`);
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const continueWithoutRefresh = await promptForFetchFailure(message);
|
|
407
|
+
if (!continueWithoutRefresh) {
|
|
408
|
+
options.stderr("Aborted\n");
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (baseBranch &&
|
|
412
|
+
(await localRefExists(repoRoot, `refs/remotes/${remote}/${baseBranch}`))) {
|
|
413
|
+
options.stderr(`Continuing with the cached ${remote}/${baseBranch}.\n`);
|
|
414
|
+
return `${remote}/${baseBranch}`;
|
|
415
|
+
}
|
|
416
|
+
options.stderr("Continuing from the local repository HEAD.\n");
|
|
417
|
+
return "HEAD";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function defaultPromptForFetchFailure(message) {
|
|
421
|
+
const choice = await confirm({
|
|
422
|
+
active: "Continue",
|
|
423
|
+
inactive: "Abort",
|
|
424
|
+
initialValue: false,
|
|
425
|
+
message: `${message}. Continue without refreshing the remote base?`,
|
|
426
|
+
});
|
|
427
|
+
return !isCancel(choice) && choice;
|
|
428
|
+
}
|
|
429
|
+
async function localRefExists(repoRoot, ref) {
|
|
430
|
+
try {
|
|
431
|
+
await runGit(repoRoot, ["rev-parse", "--verify", "--quiet", ref]);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
184
438
|
export function generateBranchPlaceholder(random = Math.random) {
|
|
185
439
|
const roots = [
|
|
186
440
|
"socrates",
|
|
@@ -333,8 +587,44 @@ async function localBranchExists(repoRoot, branchName) {
|
|
|
333
587
|
return false;
|
|
334
588
|
}
|
|
335
589
|
}
|
|
336
|
-
async function
|
|
337
|
-
|
|
590
|
+
async function resolveCurrentWorktreeHead(cwd) {
|
|
591
|
+
return execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd }).then(({ stdout }) => stdout.trim());
|
|
592
|
+
}
|
|
593
|
+
async function createTakeStash(cwd, worktreeName) {
|
|
594
|
+
const message = `gji-take: ${worktreeName}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
595
|
+
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
|
596
|
+
const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H%x00%s"], { cwd });
|
|
597
|
+
const line = stdout
|
|
598
|
+
.split("\n")
|
|
599
|
+
.map((candidate) => candidate.split("\0"))
|
|
600
|
+
.find(([, subject]) => subject?.endsWith(message));
|
|
601
|
+
if (!line?.[0])
|
|
602
|
+
throw new Error("could not identify the stash created for --take");
|
|
603
|
+
return line[0];
|
|
604
|
+
}
|
|
605
|
+
async function detectInProgressGitState(cwd) {
|
|
606
|
+
const markers = [
|
|
607
|
+
["MERGE_HEAD", "merge"],
|
|
608
|
+
["rebase-merge", "rebase"],
|
|
609
|
+
["rebase-apply", "rebase"],
|
|
610
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
611
|
+
["REVERT_HEAD", "revert"],
|
|
612
|
+
["BISECT_LOG", "bisect"],
|
|
613
|
+
];
|
|
614
|
+
for (const [marker, name] of markers) {
|
|
615
|
+
try {
|
|
616
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", marker], { cwd });
|
|
617
|
+
await access(resolve(cwd, stdout.trim()));
|
|
618
|
+
return name;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
/* marker is absent */
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
async function writeOutput(worktreePath, stdout, outputEnv) {
|
|
627
|
+
await writeShellOutput(outputEnv ?? NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
|
|
338
628
|
}
|
|
339
629
|
function isNotRegisteredWorktreeError(error) {
|
|
340
630
|
const stderr = hasExecStderr(error) ? error.stderr : String(error);
|
|
@@ -368,3 +658,111 @@ async function openWorktree(worktreePath, editorCli, spawnFn, stderr) {
|
|
|
368
658
|
stderr(`gji new: failed to open editor: ${message}\n`);
|
|
369
659
|
}
|
|
370
660
|
}
|
|
661
|
+
async function listTakeFiles(cwd) {
|
|
662
|
+
try {
|
|
663
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { cwd });
|
|
664
|
+
return stdout
|
|
665
|
+
.split("\n")
|
|
666
|
+
.filter((line) => line.length > 3)
|
|
667
|
+
.map((line) => line.slice(3).replace(/^"|"$/g, ""));
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function listIgnoredFiles(cwd) {
|
|
674
|
+
try {
|
|
675
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--ignored", "--untracked-files=all"], { cwd });
|
|
676
|
+
return stdout
|
|
677
|
+
.split("\n")
|
|
678
|
+
.filter((line) => line.startsWith("!! "))
|
|
679
|
+
.map((line) => line.slice(3).replace(/^"|"$/g, ""));
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async function listSubmoduleFiles(cwd, changedFiles) {
|
|
686
|
+
if (changedFiles.length === 0)
|
|
687
|
+
return [];
|
|
688
|
+
try {
|
|
689
|
+
const { stdout } = await execFileAsync("git", ["ls-files", "--stage"], {
|
|
690
|
+
cwd,
|
|
691
|
+
});
|
|
692
|
+
const submodules = new Set(stdout
|
|
693
|
+
.split("\n")
|
|
694
|
+
.filter((line) => line.startsWith("160000 "))
|
|
695
|
+
.map((line) => line.slice(line.indexOf("\t") + 1)));
|
|
696
|
+
return changedFiles.filter((file) => submodules.has(file));
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return [];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function countUntrackedFiles(cwd) {
|
|
703
|
+
try {
|
|
704
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd });
|
|
705
|
+
return stdout.split("\n").filter((line) => line.startsWith("?? ")).length;
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function applyTakeStash(worktreePath, sourcePath, stashSha, copy, stderr) {
|
|
712
|
+
try {
|
|
713
|
+
await execFileAsync("git", ["stash", "apply", stashSha], {
|
|
714
|
+
cwd: worktreePath,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
stderr(`Warning: stash apply failed: ${toExecMessage(error)}\n`);
|
|
719
|
+
await restoreTakeStash(sourcePath, stashSha, stderr);
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
if (copy) {
|
|
723
|
+
try {
|
|
724
|
+
await execFileAsync("git", ["stash", "apply", stashSha], {
|
|
725
|
+
cwd: sourcePath,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
stderr(`Warning: could not restore copied changes to the source: ${toExecMessage(error)}\n`);
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H %gd"], { cwd: sourcePath });
|
|
735
|
+
const line = stdout
|
|
736
|
+
.split("\n")
|
|
737
|
+
.find((candidate) => candidate.startsWith(stashSha));
|
|
738
|
+
const ref = line?.trim().split(/\s+/).at(1);
|
|
739
|
+
if (ref)
|
|
740
|
+
await execFileAsync("git", ["stash", "drop", ref], { cwd: sourcePath });
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
/* preserving the stash is safer */
|
|
744
|
+
}
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
async function restoreTakeStash(sourcePath, stashSha, stderr) {
|
|
748
|
+
try {
|
|
749
|
+
await execFileAsync("git", ["stash", "apply", stashSha], {
|
|
750
|
+
cwd: sourcePath,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
stderr(`Your changes are safe in stash: ${stashSha} — run "git stash apply ${stashSha}" to restore\n`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function emitNewError(options, message, details) {
|
|
758
|
+
if (options.json)
|
|
759
|
+
options.stderr(`${JSON.stringify({ error: message, ...details }, null, 2)}\n`);
|
|
760
|
+
else {
|
|
761
|
+
const path = typeof details?.path === "string" ? details.path : undefined;
|
|
762
|
+
options.stderr(`gji new: ${message}${path ? ` at ${path}` : ""}\n`);
|
|
763
|
+
if (path) {
|
|
764
|
+
options.stderr(`Hint: inspect the worktree or remove it with 'gji done ${path}' before retrying\n`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return 1;
|
|
768
|
+
}
|
package/dist/open.d.ts
CHANGED
package/dist/open.js
CHANGED
|
@@ -16,6 +16,14 @@ export function createOpenCommand(dependencies = {}) {
|
|
|
16
16
|
const promptForWorktree = dependencies.promptForWorktree ?? defaultPromptForWorktree;
|
|
17
17
|
const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
|
|
18
18
|
return async function runOpenCommand(options) {
|
|
19
|
+
if (options.select && options.branch !== undefined) {
|
|
20
|
+
options.stderr("gji open: --select cannot be used with a branch\n");
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
if (options.select && isHeadless()) {
|
|
24
|
+
options.stderr("gji open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n");
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
19
27
|
const [worktrees, repository] = await Promise.all([
|
|
20
28
|
listWorktrees(options.cwd),
|
|
21
29
|
detectRepository(options.cwd),
|
|
@@ -36,9 +44,13 @@ export function createOpenCommand(dependencies = {}) {
|
|
|
36
44
|
targetPath = match.worktree.path;
|
|
37
45
|
targetWorktree = match.worktree;
|
|
38
46
|
}
|
|
39
|
-
else if (
|
|
47
|
+
else if (!options.select) {
|
|
40
48
|
targetWorktree = worktrees.find((w) => w.isCurrent);
|
|
41
|
-
|
|
49
|
+
if (!targetWorktree) {
|
|
50
|
+
options.stderr("gji open: unable to identify the current worktree\n");
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
targetPath = targetWorktree.path;
|
|
42
54
|
}
|
|
43
55
|
else {
|
|
44
56
|
const entries = await buildWorktreePromptEntries(worktrees.map((worktree) => ({
|
package/dist/pr.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { type PathConflictChoice } from "./conflict.js";
|
|
2
|
-
import { type
|
|
2
|
+
import { type DependencyBootstrapPromptDependencies } from "./dependency-bootstrap-prompt.js";
|
|
3
|
+
import type { CloneDirectory } from "./dir-clone.js";
|
|
4
|
+
import type { InstallPromptDependencies } from "./install-prompt.js";
|
|
3
5
|
export type { PathConflictChoice };
|
|
4
6
|
export interface PrCommandOptions {
|
|
5
7
|
cwd: string;
|
|
6
8
|
dryRun?: boolean;
|
|
7
9
|
json?: boolean;
|
|
8
10
|
number: string;
|
|
11
|
+
outputEnv?: string;
|
|
9
12
|
stderr: (chunk: string) => void;
|
|
10
13
|
stdout: (chunk: string) => void;
|
|
11
14
|
}
|
|
12
|
-
export interface PrCommandDependencies extends InstallPromptDependencies {
|
|
15
|
+
export interface PrCommandDependencies extends InstallPromptDependencies, DependencyBootstrapPromptDependencies {
|
|
16
|
+
cloneDir?: CloneDirectory;
|
|
13
17
|
promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
|
|
14
18
|
}
|
|
15
19
|
export declare function parsePrInput(input: string): string | null;
|