@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/pr.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import {
|
|
5
|
+
import { createBootstrapReporter } from "./bootstrap-output.js";
|
|
6
|
+
import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
|
|
7
|
+
import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
|
|
6
8
|
import { pathExists, promptForPathConflict, } from "./conflict.js";
|
|
7
|
-
import {
|
|
9
|
+
import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
|
|
10
|
+
import { formatBytes } from "./format-bytes.js";
|
|
8
11
|
import { isHeadless } from "./headless.js";
|
|
9
12
|
import { recordWorktreeUsage } from "./history.js";
|
|
10
|
-
import {
|
|
11
|
-
import { maybeRunInstallPrompt, } from "./install-prompt.js";
|
|
13
|
+
import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
|
|
12
14
|
import { detectRepository, resolveWorktreePath } from "./repo.js";
|
|
13
15
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
16
|
+
import { estimateSyncDirectories } from "./sync-plan.js";
|
|
17
|
+
import { bootstrapWorktree } from "./worktree-bootstrap.js";
|
|
14
18
|
const execFileAsync = promisify(execFile);
|
|
15
19
|
const PR_OUTPUT_FILE_ENV = "GJI_PR_OUTPUT_FILE";
|
|
16
20
|
export function parsePrInput(input) {
|
|
@@ -39,7 +43,23 @@ export function createPrCommand(dependencies = {}) {
|
|
|
39
43
|
return 1;
|
|
40
44
|
}
|
|
41
45
|
const repository = await detectRepository(options.cwd);
|
|
42
|
-
|
|
46
|
+
let config;
|
|
47
|
+
let dependencyBootstrapExplicit;
|
|
48
|
+
try {
|
|
49
|
+
const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
|
|
50
|
+
config = loaded.config;
|
|
51
|
+
dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
if (options.json) {
|
|
56
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
options.stderr(`gji pr: ${message}\n`);
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
43
63
|
const branchName = `pr/${prNumber}`;
|
|
44
64
|
const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
|
|
45
65
|
const rawBasePath = resolveConfigString(config, "worktreePath");
|
|
@@ -55,25 +75,66 @@ export function createPrCommand(dependencies = {}) {
|
|
|
55
75
|
}
|
|
56
76
|
else {
|
|
57
77
|
options.stderr(`gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
58
|
-
options.stderr(`Hint: Use 'gji
|
|
78
|
+
options.stderr(`Hint: Use 'gji done pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
|
|
59
79
|
}
|
|
60
80
|
return 1;
|
|
61
81
|
}
|
|
62
82
|
const choice = await prompt(worktreePath);
|
|
63
83
|
if (choice === "reuse") {
|
|
64
84
|
await recordWorktreeUsage(worktreePath, branchName);
|
|
65
|
-
await writeOutput(worktreePath, options.stdout);
|
|
85
|
+
await writeOutput(worktreePath, options.stdout, options.outputEnv);
|
|
66
86
|
return 0;
|
|
67
87
|
}
|
|
68
88
|
options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
|
|
69
89
|
return 1;
|
|
70
90
|
}
|
|
91
|
+
let dependencyPolicy = {
|
|
92
|
+
mode: config.dependencyBootstrap ?? "off",
|
|
93
|
+
prompted: false,
|
|
94
|
+
source: dependencyBootstrapExplicit ? "explicit" : "default",
|
|
95
|
+
};
|
|
96
|
+
if (options.dryRun) {
|
|
97
|
+
dependencyPolicy = await resolveDependencyBootstrapPolicy({
|
|
98
|
+
currentRoot: repository.currentRoot,
|
|
99
|
+
repoRoot: repository.repoRoot,
|
|
100
|
+
worktreePath,
|
|
101
|
+
}, config, dependencyBootstrapExplicit, {
|
|
102
|
+
dependencies,
|
|
103
|
+
dryRun: true,
|
|
104
|
+
legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
|
|
105
|
+
nonInteractive: !!options.json,
|
|
106
|
+
stderr: options.stderr,
|
|
107
|
+
});
|
|
108
|
+
config = {
|
|
109
|
+
...config,
|
|
110
|
+
dependencyBootstrap: dependencyPolicy.mode,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const dryRunSyncDirs = options.dryRun
|
|
114
|
+
? await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? [])
|
|
115
|
+
: [];
|
|
116
|
+
const dryRunDependencyBootstrap = options.dryRun
|
|
117
|
+
? await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
|
|
118
|
+
currentRoot: repository.currentRoot,
|
|
119
|
+
repoRoot: repository.repoRoot,
|
|
120
|
+
cargoBuildCommand: config.dependencyBuildCommand,
|
|
121
|
+
worktreePath,
|
|
122
|
+
})
|
|
123
|
+
: undefined;
|
|
71
124
|
if (options.dryRun) {
|
|
72
125
|
if (options.json) {
|
|
73
|
-
|
|
126
|
+
const output = {
|
|
127
|
+
...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
|
|
128
|
+
dryRun: true,
|
|
129
|
+
};
|
|
130
|
+
if (dryRunSyncDirs.length > 0)
|
|
131
|
+
output.syncDirs = dryRunSyncDirs;
|
|
132
|
+
if (dryRunDependencyBootstrap?.targets.length)
|
|
133
|
+
output.dependencyBootstrap = dryRunDependencyBootstrap;
|
|
134
|
+
options.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
74
135
|
}
|
|
75
136
|
else {
|
|
76
|
-
options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n`);
|
|
137
|
+
options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
|
|
77
138
|
}
|
|
78
139
|
return 0;
|
|
79
140
|
}
|
|
@@ -97,31 +158,75 @@ export function createPrCommand(dependencies = {}) {
|
|
|
97
158
|
? ["worktree", "add", worktreePath, branchName]
|
|
98
159
|
: ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
|
|
99
160
|
await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot });
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
161
|
+
if (!dependencyBootstrapExplicit) {
|
|
162
|
+
dependencyPolicy = await resolveDependencyBootstrapPolicy({
|
|
163
|
+
currentRoot: repository.currentRoot,
|
|
164
|
+
detectionRoot: worktreePath,
|
|
165
|
+
repoRoot: repository.repoRoot,
|
|
166
|
+
worktreePath,
|
|
167
|
+
}, config, false, {
|
|
168
|
+
dependencies,
|
|
169
|
+
legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
|
|
170
|
+
nonInteractive: !!options.json,
|
|
171
|
+
stderr: options.stderr,
|
|
172
|
+
});
|
|
173
|
+
config = {
|
|
174
|
+
...config,
|
|
175
|
+
dependencyBootstrap: dependencyPolicy.mode,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const bootstrap = await bootstrapWorktree({
|
|
179
|
+
branch: branchName,
|
|
180
|
+
cloneDirectory: dependencies.cloneDir,
|
|
181
|
+
config,
|
|
182
|
+
currentRoot: repository.currentRoot,
|
|
183
|
+
dependencyDetectionRoot: worktreePath,
|
|
184
|
+
nonInteractive: !!options.json,
|
|
185
|
+
repoRoot: repository.repoRoot,
|
|
186
|
+
reporter: createBootstrapReporter(options.stderr, !!options.json),
|
|
187
|
+
runCommand: dependencies.runInstallCommand,
|
|
188
|
+
commandStdout: options.json ? () => undefined : options.stdout,
|
|
189
|
+
commandStderr: options.json ? () => undefined : options.stderr,
|
|
190
|
+
json: options.json,
|
|
191
|
+
worktreePath,
|
|
192
|
+
installDependencies: dependencies,
|
|
193
|
+
dependencyBootstrapPolicy: dependencyPolicy,
|
|
194
|
+
});
|
|
195
|
+
if (!bootstrap.ready) {
|
|
196
|
+
const details = {
|
|
197
|
+
dependencyBootstrap: bootstrap.dependencyBootstrap,
|
|
198
|
+
path: worktreePath,
|
|
199
|
+
skipped: bootstrap.skippedDirs,
|
|
200
|
+
syncFiles: bootstrap.syncFileFailures,
|
|
201
|
+
};
|
|
202
|
+
if (options.json) {
|
|
203
|
+
options.stderr(`${JSON.stringify({ error: "worktree bootstrap failed", ...details }, null, 2)}\n`);
|
|
107
204
|
}
|
|
108
|
-
|
|
109
|
-
options.stderr(`
|
|
205
|
+
else {
|
|
206
|
+
options.stderr(`gji pr: worktree bootstrap failed at ${worktreePath}\n`);
|
|
207
|
+
options.stderr(`Hint: inspect the worktree or remove it with 'gji done ${worktreePath}' before retrying\n`);
|
|
110
208
|
}
|
|
209
|
+
return 1;
|
|
111
210
|
}
|
|
112
|
-
await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
|
|
113
|
-
const hooks = extractHooks(config);
|
|
114
|
-
await runHook(hooks["after-create"], worktreePath, {
|
|
115
|
-
branch: branchName,
|
|
116
|
-
path: worktreePath,
|
|
117
|
-
repo: basename(repository.repoRoot),
|
|
118
|
-
}, options.stderr);
|
|
119
211
|
if (options.json) {
|
|
120
|
-
|
|
212
|
+
const output = {
|
|
213
|
+
...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
|
|
214
|
+
};
|
|
215
|
+
if (bootstrap.clonedDirs.length > 0) {
|
|
216
|
+
output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
|
|
217
|
+
dir,
|
|
218
|
+
ms,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
if (bootstrap.skippedDirs.length > 0)
|
|
222
|
+
output.skipped = bootstrap.skippedDirs;
|
|
223
|
+
if (bootstrap.dependencyBootstrap.mode !== "off")
|
|
224
|
+
output.dependencyBootstrap = bootstrap.dependencyBootstrap;
|
|
225
|
+
options.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
121
226
|
}
|
|
122
227
|
else {
|
|
123
228
|
await recordWorktreeUsage(worktreePath, branchName);
|
|
124
|
-
await writeOutput(worktreePath, options.stdout);
|
|
229
|
+
await writeOutput(worktreePath, options.stdout, options.outputEnv);
|
|
125
230
|
}
|
|
126
231
|
return 0;
|
|
127
232
|
};
|
|
@@ -185,6 +290,6 @@ function sourceRefForForge(forge, prNumber) {
|
|
|
185
290
|
return `refs/merge-requests/${prNumber}/head`;
|
|
186
291
|
}
|
|
187
292
|
}
|
|
188
|
-
async function writeOutput(worktreePath, stdout) {
|
|
189
|
-
await writeShellOutput(PR_OUTPUT_FILE_ENV, worktreePath, stdout);
|
|
293
|
+
async function writeOutput(worktreePath, stdout, outputEnv) {
|
|
294
|
+
await writeShellOutput(outputEnv ?? PR_OUTPUT_FILE_ENV, worktreePath, stdout);
|
|
190
295
|
}
|
package/dist/remove.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadEffectiveConfig } from "./config.js";
|
|
|
4
4
|
import { isHeadless } from "./headless.js";
|
|
5
5
|
import { extractHooks, runHook } from "./hooks.js";
|
|
6
6
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
7
|
+
import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
|
|
7
8
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
|
|
8
9
|
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
9
10
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
|
|
@@ -74,6 +75,20 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
74
75
|
}
|
|
75
76
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
76
77
|
const hooks = extractHooks(config);
|
|
78
|
+
let journal;
|
|
79
|
+
try {
|
|
80
|
+
journal = await recordUndoOperation("remove", repository.repoRoot, [
|
|
81
|
+
worktree,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
emitError(options, `could not write undo journal; no worktree was removed: ${toMessage(error)}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
if (!journal) {
|
|
89
|
+
emitError(options, "could not capture undo state; no worktree was removed");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
77
92
|
await runHook(hooks["before-remove"], worktree.path, {
|
|
78
93
|
branch: worktree.branch ?? undefined,
|
|
79
94
|
path: worktree.path,
|
|
@@ -84,10 +99,12 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
84
99
|
}
|
|
85
100
|
catch (error) {
|
|
86
101
|
if (!isWorktreeForceRemovalError(error)) {
|
|
102
|
+
await finalizeUndoOperation(journal, []);
|
|
87
103
|
throw error;
|
|
88
104
|
}
|
|
89
105
|
if (!options.force &&
|
|
90
106
|
!(await confirmForceRemoveWorktree(worktree.path))) {
|
|
107
|
+
await finalizeUndoOperation(journal, []);
|
|
91
108
|
options.stderr("Aborted\n");
|
|
92
109
|
return 1;
|
|
93
110
|
}
|
|
@@ -95,10 +112,12 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
95
112
|
await forceRemoveWorktree(repository.repoRoot, worktree.path);
|
|
96
113
|
}
|
|
97
114
|
catch (forceError) {
|
|
115
|
+
await finalizeUndoOperation(journal, []);
|
|
98
116
|
emitError(options, `Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}`);
|
|
99
117
|
return 1;
|
|
100
118
|
}
|
|
101
119
|
}
|
|
120
|
+
await finalizeUndoOperation(journal, [worktree]);
|
|
102
121
|
if (worktree.branch) {
|
|
103
122
|
try {
|
|
104
123
|
await deleteBranch(repository.repoRoot, worktree.branch);
|
|
@@ -125,6 +144,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
125
144
|
options.stdout(`${JSON.stringify({ branch: worktree.branch, path: worktree.path, deleted: true }, null, 2)}\n`);
|
|
126
145
|
}
|
|
127
146
|
else {
|
|
147
|
+
options.stderr("undo: gji undo\n");
|
|
128
148
|
await writeOutput(repository.repoRoot, options.stdout);
|
|
129
149
|
}
|
|
130
150
|
return 0;
|
package/dist/repo.d.ts
CHANGED
|
@@ -13,4 +13,7 @@ export interface WorktreeEntry {
|
|
|
13
13
|
export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
|
|
14
14
|
export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
|
|
15
15
|
export declare function validateBranchName(name: string): string | null;
|
|
16
|
+
export declare function hasLocalBranch(repoRoot: string, branch: string): Promise<boolean>;
|
|
17
|
+
export declare function hasRemoteBranch(repoRoot: string, remote: string, branch: string): Promise<boolean>;
|
|
18
|
+
export declare function getRepositoryRemoteUrl(repoRoot: string, remote: string): Promise<string | null>;
|
|
16
19
|
export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
|
package/dist/repo.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
4
|
import { runGit } from "./git.js";
|
|
@@ -65,6 +66,42 @@ export function validateBranchName(name) {
|
|
|
65
66
|
}
|
|
66
67
|
return null;
|
|
67
68
|
}
|
|
69
|
+
export async function hasLocalBranch(repoRoot, branch) {
|
|
70
|
+
try {
|
|
71
|
+
await runGit(repoRoot, [
|
|
72
|
+
"show-ref",
|
|
73
|
+
"--verify",
|
|
74
|
+
"--quiet",
|
|
75
|
+
`refs/heads/${branch}`,
|
|
76
|
+
]);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function hasRemoteBranch(repoRoot, remote, branch) {
|
|
84
|
+
try {
|
|
85
|
+
await runGit(repoRoot, [
|
|
86
|
+
"show-ref",
|
|
87
|
+
"--verify",
|
|
88
|
+
"--quiet",
|
|
89
|
+
`refs/remotes/${remote}/${branch}`,
|
|
90
|
+
]);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function getRepositoryRemoteUrl(repoRoot, remote) {
|
|
98
|
+
try {
|
|
99
|
+
return await runGit(repoRoot, ["remote", "get-url", remote]);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
68
105
|
function expandTildeInPath(p) {
|
|
69
106
|
if (p === "~")
|
|
70
107
|
return homedir();
|
|
@@ -78,15 +115,29 @@ export async function listWorktrees(cwd) {
|
|
|
78
115
|
runGit(cwd, ["rev-parse", "--show-toplevel"]),
|
|
79
116
|
]);
|
|
80
117
|
const entries = output.split("\n\n").filter(Boolean);
|
|
81
|
-
|
|
118
|
+
const worktrees = entries.flatMap((entry) => {
|
|
119
|
+
if (findOptionalPorcelainValue(entry, "prunable") !== null)
|
|
120
|
+
return [];
|
|
82
121
|
const path = findPorcelainValue(entry, "worktree");
|
|
83
122
|
const branchRef = findOptionalPorcelainValue(entry, "branch");
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
branch: branchRef ? branchRef.replace("refs/heads/", "") : null,
|
|
126
|
+
isCurrent: path === currentRoot,
|
|
127
|
+
path,
|
|
128
|
+
},
|
|
129
|
+
];
|
|
89
130
|
});
|
|
131
|
+
const accessible = await Promise.all(worktrees.map(async (worktree) => {
|
|
132
|
+
try {
|
|
133
|
+
await access(worktree.path);
|
|
134
|
+
return worktree;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
return accessible.filter((worktree) => worktree !== null);
|
|
90
141
|
}
|
|
91
142
|
function findPorcelainValue(block, key) {
|
|
92
143
|
const value = findOptionalPorcelainValue(block, key);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type DestinationInspection = {
|
|
2
|
+
kind: "missing";
|
|
3
|
+
} | {
|
|
4
|
+
kind: "exists";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "unsafe";
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
export interface OpenDestinationDirectory {
|
|
10
|
+
path: string;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function openDestinationDirectory(root: string, path: string): Promise<OpenDestinationDirectory>;
|
|
14
|
+
export declare function inspectDestination(root: string, path: string): Promise<DestinationInspection>;
|
|
15
|
+
export declare function ensureDestinationDirectory(root: string, path: string): Promise<void>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { isAlreadyExistsError, isNotDirectoryError, isNotFoundError, } from "./fs-utils.js";
|
|
5
|
+
export async function openDestinationDirectory(root, path) {
|
|
6
|
+
const segments = destinationSegments(root, path);
|
|
7
|
+
if (process.platform !== "linux") {
|
|
8
|
+
await ensureDestinationDirectory(root, path);
|
|
9
|
+
return { path: resolve(path), close: async () => undefined };
|
|
10
|
+
}
|
|
11
|
+
let handle = await open(root, destinationDirectoryFlags());
|
|
12
|
+
try {
|
|
13
|
+
for (const segment of segments) {
|
|
14
|
+
const childPath = join(fileDescriptorPath(handle.fd), segment);
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(childPath);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!isAlreadyExistsError(error))
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const childHandle = await open(childPath, destinationDirectoryFlags());
|
|
23
|
+
await handle.close();
|
|
24
|
+
handle = childHandle;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
path: fileDescriptorPath(handle.fd),
|
|
28
|
+
close: async () => handle.close(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await handle.close().catch(() => undefined);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function inspectDestination(root, path) {
|
|
37
|
+
const resolvedPath = resolve(path);
|
|
38
|
+
const resolvedRoot = resolve(root);
|
|
39
|
+
const distance = relative(resolvedRoot, resolvedPath);
|
|
40
|
+
if (isAbsolute(distance) ||
|
|
41
|
+
distance === ".." ||
|
|
42
|
+
distance.startsWith(`..${sep}`)) {
|
|
43
|
+
return { kind: "unsafe", reason: "destination escapes the worktree" };
|
|
44
|
+
}
|
|
45
|
+
let current = resolvedRoot;
|
|
46
|
+
try {
|
|
47
|
+
const rootStats = await lstat(current);
|
|
48
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
49
|
+
return { kind: "unsafe", reason: "worktree root is not a directory" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (isNotFoundError(error))
|
|
54
|
+
return { kind: "missing" };
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const segments = distance.split(sep).filter(Boolean);
|
|
58
|
+
for (const [index, segment] of segments.entries()) {
|
|
59
|
+
current = resolve(current, segment);
|
|
60
|
+
try {
|
|
61
|
+
const stats = await lstat(current);
|
|
62
|
+
if (stats.isSymbolicLink()) {
|
|
63
|
+
return {
|
|
64
|
+
kind: "unsafe",
|
|
65
|
+
reason: `destination has a symbolic-link component: ${current}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (index < segments.length - 1 && !stats.isDirectory()) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "unsafe",
|
|
71
|
+
reason: `destination has a non-directory ancestor: ${current}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (index === segments.length - 1) {
|
|
75
|
+
return stats.isDirectory()
|
|
76
|
+
? { kind: "exists" }
|
|
77
|
+
: {
|
|
78
|
+
kind: "unsafe",
|
|
79
|
+
reason: `destination is not a directory: ${current}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (isNotFoundError(error))
|
|
85
|
+
return { kind: "missing" };
|
|
86
|
+
if (isNotDirectoryError(error)) {
|
|
87
|
+
return {
|
|
88
|
+
kind: "unsafe",
|
|
89
|
+
reason: `destination has a non-directory ancestor: ${current}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const stats = await lstat(current);
|
|
97
|
+
return stats.isDirectory()
|
|
98
|
+
? { kind: "exists" }
|
|
99
|
+
: { kind: "unsafe", reason: "destination is not a directory" };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (isNotFoundError(error))
|
|
103
|
+
return { kind: "missing" };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function destinationSegments(root, path) {
|
|
108
|
+
const resolvedRoot = resolve(root);
|
|
109
|
+
const resolvedPath = resolve(path);
|
|
110
|
+
const distance = relative(resolvedRoot, resolvedPath);
|
|
111
|
+
if (isAbsolute(distance) ||
|
|
112
|
+
distance === ".." ||
|
|
113
|
+
distance.startsWith(`..${sep}`)) {
|
|
114
|
+
throw new Error("destination escapes the worktree");
|
|
115
|
+
}
|
|
116
|
+
return distance.split(sep).filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
function destinationDirectoryFlags() {
|
|
119
|
+
return constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
120
|
+
}
|
|
121
|
+
function fileDescriptorPath(fd) {
|
|
122
|
+
if (process.platform === "linux")
|
|
123
|
+
return `/proc/self/fd/${fd}`;
|
|
124
|
+
if (process.platform === "darwin")
|
|
125
|
+
return `/dev/fd/${fd}`;
|
|
126
|
+
throw new Error(`safe destination handles are unsupported on ${process.platform}`);
|
|
127
|
+
}
|
|
128
|
+
export async function ensureDestinationDirectory(root, path) {
|
|
129
|
+
const resolvedRoot = resolve(root);
|
|
130
|
+
const resolvedPath = resolve(path);
|
|
131
|
+
const distance = relative(resolvedRoot, resolvedPath);
|
|
132
|
+
if (isAbsolute(distance) ||
|
|
133
|
+
distance === ".." ||
|
|
134
|
+
distance.startsWith(`..${sep}`)) {
|
|
135
|
+
throw new Error("destination escapes the worktree");
|
|
136
|
+
}
|
|
137
|
+
let current = resolvedRoot;
|
|
138
|
+
const rootStats = await lstat(current);
|
|
139
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
140
|
+
throw new Error("worktree root is not a directory");
|
|
141
|
+
}
|
|
142
|
+
for (const segment of distance.split(sep).filter(Boolean)) {
|
|
143
|
+
current = resolve(current, segment);
|
|
144
|
+
try {
|
|
145
|
+
const stats = await lstat(current);
|
|
146
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
147
|
+
throw new Error(`destination has an unsafe component: ${current}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (!isNotFoundError(error))
|
|
152
|
+
throw error;
|
|
153
|
+
try {
|
|
154
|
+
await mkdir(current);
|
|
155
|
+
}
|
|
156
|
+
catch (mkdirError) {
|
|
157
|
+
if (!isAlreadyExistsError(mkdirError))
|
|
158
|
+
throw mkdirError;
|
|
159
|
+
}
|
|
160
|
+
const stats = await lstat(current);
|
|
161
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
162
|
+
throw new Error(`destination has an unsafe component: ${current}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|