@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.
Files changed (100) hide show
  1. package/README.md +93 -20
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +25 -3
  9. package/dist/cli.js +105 -19
  10. package/dist/clone-failure-store.d.ts +18 -0
  11. package/dist/clone-failure-store.js +230 -0
  12. package/dist/command-runner.d.ts +6 -0
  13. package/dist/command-runner.js +38 -0
  14. package/dist/completion.js +7 -2
  15. package/dist/config-command.d.ts +1 -0
  16. package/dist/config-command.js +7 -1
  17. package/dist/config.d.ts +26 -1
  18. package/dist/config.js +110 -6
  19. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  20. package/dist/dependency-bootstrap-prompt.js +103 -0
  21. package/dist/dependency-bootstrap.d.ts +129 -0
  22. package/dist/dependency-bootstrap.js +566 -0
  23. package/dist/dir-clone.d.ts +33 -0
  24. package/dist/dir-clone.js +505 -0
  25. package/dist/done.d.ts +10 -0
  26. package/dist/done.js +208 -0
  27. package/dist/file-sync.js +71 -22
  28. package/dist/format-bytes.d.ts +1 -0
  29. package/dist/format-bytes.js +14 -0
  30. package/dist/fs-utils.d.ts +4 -0
  31. package/dist/fs-utils.js +26 -0
  32. package/dist/git.d.ts +5 -0
  33. package/dist/git.js +50 -0
  34. package/dist/gji-bundle.mjs +5489 -1206
  35. package/dist/go-resolver.d.ts +35 -0
  36. package/dist/go-resolver.js +185 -0
  37. package/dist/go.d.ts +5 -2
  38. package/dist/go.js +262 -62
  39. package/dist/hooks.d.ts +1 -1
  40. package/dist/hooks.js +13 -7
  41. package/dist/init.js +30 -22
  42. package/dist/install-prompt.d.ts +1 -0
  43. package/dist/install-prompt.js +3 -25
  44. package/dist/navigation-output.d.ts +11 -0
  45. package/dist/navigation-output.js +6 -0
  46. package/dist/new.d.ts +14 -2
  47. package/dist/new.js +434 -36
  48. package/dist/open.d.ts +1 -0
  49. package/dist/open.js +14 -2
  50. package/dist/pr.d.ts +6 -2
  51. package/dist/pr.js +135 -30
  52. package/dist/remove.js +20 -0
  53. package/dist/repo.d.ts +3 -0
  54. package/dist/repo.js +57 -6
  55. package/dist/safe-destination.d.ts +15 -0
  56. package/dist/safe-destination.js +166 -0
  57. package/dist/shell-completion.js +47 -14
  58. package/dist/sync-directories.d.ts +33 -0
  59. package/dist/sync-directories.js +97 -0
  60. package/dist/sync-plan.d.ts +16 -0
  61. package/dist/sync-plan.js +128 -0
  62. package/dist/sync.js +7 -13
  63. package/dist/undo.d.ts +36 -0
  64. package/dist/undo.js +392 -0
  65. package/dist/warp.d.ts +3 -1
  66. package/dist/warp.js +32 -125
  67. package/dist/worktree-bootstrap.d.ts +35 -0
  68. package/dist/worktree-bootstrap.js +93 -0
  69. package/dist/worktree-management.d.ts +1 -0
  70. package/dist/worktree-management.js +23 -4
  71. package/dist/worktree-picker.d.ts +21 -8
  72. package/dist/worktree-picker.js +107 -15
  73. package/dist/worktree-prompts.js +1 -1
  74. package/dist/worktree-source.d.ts +6 -0
  75. package/dist/worktree-source.js +1 -0
  76. package/dist/worktree-sources.d.ts +3 -0
  77. package/dist/worktree-sources.js +31 -0
  78. package/man/man1/gji-back.1 +1 -1
  79. package/man/man1/gji-clean.1 +1 -1
  80. package/man/man1/gji-completion.1 +1 -1
  81. package/man/man1/gji-config.1 +1 -1
  82. package/man/man1/gji-doctor.1 +1 -1
  83. package/man/man1/gji-done.1 +19 -0
  84. package/man/man1/gji-go.1 +9 -3
  85. package/man/man1/gji-history.1 +1 -1
  86. package/man/man1/gji-init.1 +1 -1
  87. package/man/man1/gji-ls.1 +1 -1
  88. package/man/man1/gji-new.1 +21 -3
  89. package/man/man1/gji-open.1 +4 -1
  90. package/man/man1/gji-pr.1 +7 -1
  91. package/man/man1/gji-remove.1 +3 -3
  92. package/man/man1/gji-root.1 +1 -1
  93. package/man/man1/gji-run-hook.1 +1 -1
  94. package/man/man1/gji-status.1 +1 -1
  95. package/man/man1/gji-sync-files.1 +1 -1
  96. package/man/man1/gji-sync.1 +1 -1
  97. package/man/man1/gji-undo.1 +16 -0
  98. package/man/man1/gji-warp.1 +3 -6
  99. package/man/man1/gji.1 +13 -5
  100. 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 { basename, dirname } from "node:path";
3
+ import { dirname } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { loadEffectiveConfig, resolveConfigString } from "./config.js";
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 { syncFiles } from "./file-sync.js";
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 { extractHooks, runHook } from "./hooks.js";
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
- const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
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 remove pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
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
- options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath, dryRun: true }, null, 2)}\n`);
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
- // Sync files from main worktree before afterCreate so synced files are available to install scripts.
101
- const syncPatterns = Array.isArray(config.syncFiles)
102
- ? config.syncFiles.filter((p) => typeof p === "string")
103
- : [];
104
- for (const pattern of syncPatterns) {
105
- try {
106
- await syncFiles(repository.repoRoot, worktreePath, [pattern]);
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
- catch (error) {
109
- options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
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
- options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
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
- return entries.map((entry) => {
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
- branch: branchRef ? branchRef.replace("refs/heads/", "") : null,
86
- isCurrent: path === currentRoot,
87
- path,
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
+ }