worktree-add 1.2.0 → 1.3.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 (45) hide show
  1. package/README.md +59 -23
  2. package/dist/app/get-unsafe-app-name-reason.d.ts +11 -0
  3. package/dist/app/get-unsafe-app-name-reason.js +21 -0
  4. package/dist/app/resolve-apps.d.ts +6 -0
  5. package/dist/app/resolve-apps.js +31 -0
  6. package/dist/cli/cleanup-worktree.d.ts +2 -0
  7. package/dist/cli/cleanup-worktree.js +13 -0
  8. package/dist/cli/open-worktree-apps.d.ts +5 -0
  9. package/dist/cli/open-worktree-apps.js +33 -0
  10. package/dist/cli/register-sigint-handler.d.ts +8 -0
  11. package/dist/cli/register-sigint-handler.js +19 -0
  12. package/dist/cli/resolve-worktree-context.d.ts +6 -0
  13. package/dist/cli/resolve-worktree-context.js +22 -0
  14. package/dist/cli/run-worktree-add.d.ts +9 -0
  15. package/dist/cli/run-worktree-add.js +100 -0
  16. package/dist/cli.d.ts +0 -6
  17. package/dist/cli.js +38 -70
  18. package/dist/git/create-worktree.d.ts +9 -0
  19. package/dist/git/create-worktree.js +39 -0
  20. package/dist/git/extract-diagnostic-line.d.ts +1 -0
  21. package/dist/git/extract-diagnostic-line.js +13 -0
  22. package/dist/git/fetch-remote-branch.d.ts +9 -0
  23. package/dist/git/fetch-remote-branch.js +102 -0
  24. package/dist/git/git.d.ts +22 -0
  25. package/dist/git/git.js +63 -9
  26. package/dist/git/remove-worktree.d.ts +1 -0
  27. package/dist/git/remove-worktree.js +4 -0
  28. package/dist/output/create-status-logger.d.ts +13 -0
  29. package/dist/output/create-status-logger.js +32 -0
  30. package/dist/output/get-status-logger.d.ts +2 -0
  31. package/dist/output/get-status-logger.js +12 -0
  32. package/dist/project/install-commands.js +2 -2
  33. package/dist/project/next.d.ts +4 -1
  34. package/dist/project/next.js +2 -2
  35. package/dist/project/package-manager.d.ts +7 -1
  36. package/dist/project/package-manager.js +12 -3
  37. package/dist/project/setup.d.ts +5 -1
  38. package/dist/project/setup.js +15 -7
  39. package/dist/worktree/destination-directory.d.ts +10 -3
  40. package/dist/worktree/destination-directory.js +94 -16
  41. package/dist/worktree/untracked-file-copy.d.ts +5 -1
  42. package/dist/worktree/untracked-file-copy.js +51 -9
  43. package/package.json +24 -31
  44. package/dist/git/worktree-creation.d.ts +0 -13
  45. package/dist/git/worktree-creation.js +0 -38
@@ -0,0 +1,13 @@
1
+ export function extractDiagnosticLine(error) {
2
+ const message = error instanceof Error ? error.message : String(error);
3
+ const trimmed = message.trim();
4
+ const nonEmptyLines = trimmed
5
+ .split("\n")
6
+ .map((line) => line.trim())
7
+ .filter(Boolean);
8
+ return (
9
+ // Best-effort: match English prefixes; localized messages fall back to the last non-empty line.
10
+ nonEmptyLines.find((line) => /(?:^|\s)(?:fatal:|error:)/iu.test(line)) ??
11
+ nonEmptyLines.at(-1) ??
12
+ trimmed);
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { StatusLogger } from "../output/create-status-logger.js";
2
+ export type RemoteBranchStatus = "exists" | "missing" | "unknown";
3
+ export declare function fetchRemoteBranch(branch: string, options?: {
4
+ dryRun?: boolean;
5
+ logger?: StatusLogger;
6
+ }): {
7
+ status: RemoteBranchStatus;
8
+ localExists: boolean;
9
+ };
@@ -0,0 +1,102 @@
1
+ import { getStatusLogger } from "../output/get-status-logger.js";
2
+ import { fetchOriginBranch, getAheadBehindCounts, getLocalBranchHead, getRemoteBranchHead, git, findWorktreeByBranchName, localBranchExists, normalizeBranchName, remoteBranchExists, } from "./git.js";
3
+ import { extractDiagnosticLine } from "./extract-diagnostic-line.js";
4
+ export function fetchRemoteBranch(branch, options) {
5
+ const logger = getStatusLogger(options?.logger);
6
+ const dryRun = options?.dryRun ?? false;
7
+ const normalized = normalizeBranchName(branch);
8
+ const localExists = localBranchExists(normalized);
9
+ if (localExists) {
10
+ let remoteExists;
11
+ try {
12
+ remoteExists = remoteBranchExists(normalized);
13
+ }
14
+ catch (error) {
15
+ const diagnostic = extractDiagnosticLine(error);
16
+ logger.warn(`Failed to query origin for '${normalized}': ${diagnostic}. Using existing local branch. (If you expected a remote branch, double-check your network connection and branch name.)`);
17
+ return { status: "unknown", localExists };
18
+ }
19
+ if (!remoteExists)
20
+ return { status: "missing", localExists };
21
+ try {
22
+ if (dryRun) {
23
+ logger.step(`Would fetch origin/${normalized}`);
24
+ logger.detail(`Dry run: local '${normalized}' may be fast-forwarded after fetch if it is behind origin/${normalized}.`);
25
+ return { status: "exists", localExists };
26
+ }
27
+ logger.step(`Fetching origin/${normalized} …`);
28
+ fetchOriginBranch(normalized);
29
+ }
30
+ catch (error) {
31
+ const diagnostic = extractDiagnosticLine(error);
32
+ logger.warn(`Failed to fetch origin/${normalized}: ${diagnostic}. Using existing local branch (origin status unknown).`);
33
+ return { status: "unknown", localExists };
34
+ }
35
+ const localHead = getLocalBranchHead(normalized);
36
+ const remoteHead = getRemoteBranchHead(normalized);
37
+ if (!localHead || !remoteHead || localHead === remoteHead) {
38
+ return { status: "exists", localExists };
39
+ }
40
+ let counts;
41
+ try {
42
+ counts = getAheadBehindCounts(localHead, remoteHead);
43
+ }
44
+ catch (error) {
45
+ const diagnostic = extractDiagnosticLine(error);
46
+ logger.warn(`Failed to compare local '${normalized}' with origin/${normalized}: ${diagnostic}. Using existing local branch as-is.`);
47
+ return { status: "exists", localExists };
48
+ }
49
+ const { ahead, behind } = counts;
50
+ if (ahead === 0 && behind > 0) {
51
+ // Note: dryRun returns before fetching, so this path only runs when dryRun is false.
52
+ // resolveWorktreeContext already blocks this, but keep as a defensive check.
53
+ const activeWorktree = findWorktreeByBranchName(normalized);
54
+ if (activeWorktree) {
55
+ logger.warn(`Local branch '${normalized}' is checked out in ${activeWorktree}; skipping fast-forward.`);
56
+ return { status: "exists", localExists };
57
+ }
58
+ logger.detail(`Fast-forwarding '${normalized}' from ${localHead} to ${remoteHead}.`);
59
+ logger.step(`Fast-forwarding local '${normalized}' to origin/${normalized} …`);
60
+ git("branch", "-f", "--", normalized, `origin/${normalized}`);
61
+ return { status: "exists", localExists };
62
+ }
63
+ if (ahead === 0 && behind === 0) {
64
+ // Defensive: rev-list should report differences when heads differ,
65
+ // but shallow/corrupted refs can yield 0/0 counts.
66
+ logger.warn(`Local branch '${normalized}' differs from origin/${normalized}, but Git reports no ahead/behind differences. Using existing local branch; if you encounter issues, try re-cloning the repository.`);
67
+ return { status: "exists", localExists };
68
+ }
69
+ const descriptors = [];
70
+ if (ahead > 0)
71
+ descriptors.push(`ahead by ${ahead}`);
72
+ if (behind > 0)
73
+ descriptors.push(`behind by ${behind}`);
74
+ const relationship = behind > 0 ? "has diverged from" : "is ahead of";
75
+ logger.warn(`Local branch '${normalized}' ${relationship} origin/${normalized} (${descriptors.join(" and ")}); using existing local branch as-is.`);
76
+ return { status: "exists", localExists };
77
+ }
78
+ let remoteExists;
79
+ try {
80
+ remoteExists = remoteBranchExists(normalized);
81
+ }
82
+ catch (error) {
83
+ const diagnostic = extractDiagnosticLine(error);
84
+ logger.warn(`Failed to reach origin to check whether '${normalized}' exists: ${diagnostic}. (If you expected a remote branch, double-check your network connection and branch name.)`);
85
+ return { status: "unknown", localExists };
86
+ }
87
+ if (!remoteExists)
88
+ return { status: "missing", localExists };
89
+ try {
90
+ if (dryRun) {
91
+ logger.step(`Would fetch origin/${normalized}`);
92
+ return { status: "exists", localExists };
93
+ }
94
+ logger.step(`Fetching origin/${normalized} …`);
95
+ fetchOriginBranch(normalized);
96
+ }
97
+ catch (error) {
98
+ const diagnostic = extractDiagnosticLine(error);
99
+ throw new Error(`Failed to fetch origin/${normalized}: ${diagnostic}. Cannot proceed without a local branch.`);
100
+ }
101
+ return { status: "exists", localExists };
102
+ }
package/dist/git/git.d.ts CHANGED
@@ -21,8 +21,30 @@ export declare function normalizeBranchName(name: string): string;
21
21
  export declare function localBranchExists(branch: string): boolean;
22
22
  /**
23
23
  * Check if a remote branch exists on origin.
24
+ *
25
+ * Note: this function may throw on network/auth failures or when the `origin`
26
+ * remote is missing/misconfigured. Callers that want a degraded-mode fallback
27
+ * should wrap it in try/catch.
24
28
  */
25
29
  export declare function remoteBranchExists(branch: string): boolean;
30
+ /**
31
+ * Fetch a branch from origin into the remote-tracking ref.
32
+ *
33
+ * This does not update any local branch heads.
34
+ */
35
+ export declare function fetchOriginBranch(branch: string): void;
36
+ /**
37
+ * Get the current head commit for a local branch.
38
+ */
39
+ export declare function getLocalBranchHead(branch: string): string | undefined;
40
+ /**
41
+ * Get the current head commit for a remote-tracking branch.
42
+ */
43
+ export declare function getRemoteBranchHead(branch: string): string | undefined;
44
+ export declare function getAheadBehindCounts(localHead: string, remoteHead: string): {
45
+ ahead: number;
46
+ behind: number;
47
+ };
26
48
  /**
27
49
  * Convert an arbitrary string to a filesystem-safe single path segment.
28
50
  *
package/dist/git/git.js CHANGED
@@ -24,6 +24,8 @@ export function git(...arguments_) {
24
24
  const result = spawnSync("git", gitArguments, {
25
25
  encoding: "utf8",
26
26
  maxBuffer: 100 * 1024 * 1024, // 100 MB to accommodate large outputs
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
27
29
  ...(cwd && { cwd }),
28
30
  });
29
31
  if (result.error)
@@ -63,16 +65,61 @@ export function localBranchExists(branch) {
63
65
  }
64
66
  /**
65
67
  * Check if a remote branch exists on origin.
68
+ *
69
+ * Note: this function may throw on network/auth failures or when the `origin`
70
+ * remote is missing/misconfigured. Callers that want a degraded-mode fallback
71
+ * should wrap it in try/catch.
66
72
  */
67
73
  export function remoteBranchExists(branch) {
74
+ const normalized = normalizeBranchName(branch);
75
+ // Use a fully-qualified ref to avoid option-parsing ambiguity for branch
76
+ // names starting with '-'.
77
+ return !!git("ls-remote", "--heads", "origin", `refs/heads/${normalized}`);
78
+ }
79
+ /**
80
+ * Fetch a branch from origin into the remote-tracking ref.
81
+ *
82
+ * This does not update any local branch heads.
83
+ */
84
+ export function fetchOriginBranch(branch) {
85
+ const normalized = normalizeBranchName(branch);
86
+ const refspec = `+refs/heads/${normalized}:refs/remotes/origin/${normalized}`;
87
+ git("fetch", "origin", "--", refspec);
88
+ }
89
+ /**
90
+ * Get the current head commit for a local branch.
91
+ */
92
+ export function getLocalBranchHead(branch) {
68
93
  try {
69
94
  const normalized = normalizeBranchName(branch);
70
- return !!git("ls-remote", "--heads", "origin", normalized);
95
+ return git("rev-parse", `refs/heads/${normalized}`);
71
96
  }
72
97
  catch {
73
- return false;
98
+ return undefined;
74
99
  }
75
100
  }
101
+ /**
102
+ * Get the current head commit for a remote-tracking branch.
103
+ */
104
+ export function getRemoteBranchHead(branch) {
105
+ try {
106
+ const normalized = normalizeBranchName(branch);
107
+ return git("rev-parse", `refs/remotes/origin/${normalized}`);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ export function getAheadBehindCounts(localHead, remoteHead) {
114
+ const output = git("rev-list", "--left-right", "--count", `${localHead}...${remoteHead}`);
115
+ const [aheadRaw, behindRaw] = output.split(/\s+/u);
116
+ const ahead = Number.parseInt(aheadRaw ?? "0");
117
+ const behind = Number.parseInt(behindRaw ?? "0");
118
+ return {
119
+ ahead: Number.isNaN(ahead) ? 0 : ahead,
120
+ behind: Number.isNaN(behind) ? 0 : behind,
121
+ };
122
+ }
76
123
  /**
77
124
  * Convert an arbitrary string to a filesystem-safe single path segment.
78
125
  *
@@ -108,22 +155,29 @@ export function exitWithMessage(message) {
108
155
  process.exit(1);
109
156
  }
110
157
  /**
111
- * Prompts user for yes/no confirmation
112
- * @param message The message to display to the user
113
- * @returns Promise<boolean> - true if user confirms, false otherwise
158
+ * Prompt for input and return the user's response.
114
159
  */
115
- export async function confirm(message) {
160
+ async function prompt(message) {
116
161
  const rl = readline.createInterface({
117
162
  input: process.stdin,
118
- output: process.stdout,
163
+ output: process.stderr,
119
164
  });
120
165
  try {
121
- const answer = await rl.question(`${message} [y/N] `);
122
- return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
166
+ return await rl.question(message);
123
167
  }
124
168
  finally {
125
169
  rl.close();
126
170
  }
127
171
  }
172
+ /**
173
+ * Prompts user for yes/no confirmation
174
+ * @param message The message to display to the user
175
+ * @returns Promise<boolean> - true if user confirms, false otherwise
176
+ */
177
+ export async function confirm(message) {
178
+ const answer = await prompt(`${message} [y/N] `);
179
+ const normalized = answer.trim().toLowerCase();
180
+ return normalized === "y" || normalized === "yes";
181
+ }
128
182
  // Re-export worktree parsing functions for backward compatibility
129
183
  export { getRepositoryName, findWorktreeByBranchName, } from "./worktree-discovery.js";
@@ -0,0 +1 @@
1
+ export declare function removeWorktree(destinationDirectory: string): void;
@@ -0,0 +1,4 @@
1
+ import { git } from "./git.js";
2
+ export function removeWorktree(destinationDirectory) {
3
+ git("worktree", "remove", "--force", "--", destinationDirectory);
4
+ }
@@ -0,0 +1,13 @@
1
+ export type StatusLogger = {
2
+ readonly step: (message: string) => void;
3
+ readonly success: (message: string) => void;
4
+ readonly detail: (message: string) => void;
5
+ readonly warn: (message: string) => void;
6
+ };
7
+ type StatusLoggerOptions = {
8
+ readonly verbose: boolean;
9
+ readonly dryRun: boolean;
10
+ readonly decorate: boolean;
11
+ };
12
+ export declare function createStatusLogger(options: StatusLoggerOptions): StatusLogger;
13
+ export {};
@@ -0,0 +1,32 @@
1
+ const emit = (message) => {
2
+ console.error(message);
3
+ };
4
+ export function createStatusLogger(options) {
5
+ const shouldLogSteps = options.verbose || options.dryRun;
6
+ const shouldLogDetails = options.verbose || options.dryRun;
7
+ const stepPrefix = options.decorate ? "➤ " : "";
8
+ const successPrefix = options.decorate ? "✓ " : "";
9
+ const warningPrefix = options.decorate ? "⚠ " : "Warning: ";
10
+ const detailPrefix = options.decorate ? " • " : " - ";
11
+ const dryRunPrefix = options.dryRun ? "DRY RUN: " : "";
12
+ return {
13
+ step(message) {
14
+ if (!shouldLogSteps)
15
+ return;
16
+ emit(`${dryRunPrefix}${stepPrefix}${message}`);
17
+ },
18
+ success(message) {
19
+ if (!options.verbose)
20
+ return;
21
+ emit(`${dryRunPrefix}${successPrefix}${message}`);
22
+ },
23
+ detail(message) {
24
+ if (!shouldLogDetails)
25
+ return;
26
+ emit(`${dryRunPrefix}${detailPrefix}${message}`);
27
+ },
28
+ warn(message) {
29
+ emit(`${warningPrefix}${message}`);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,2 @@
1
+ import type { StatusLogger } from "./create-status-logger.js";
2
+ export declare function getStatusLogger(logger: StatusLogger | undefined): StatusLogger;
@@ -0,0 +1,12 @@
1
+ // Fallback logger only emits warnings so important signals aren't dropped.
2
+ const fallbackLogger = {
3
+ step() { },
4
+ success() { },
5
+ detail() { },
6
+ warn(message) {
7
+ console.error(message);
8
+ },
9
+ };
10
+ export function getStatusLogger(logger) {
11
+ return logger ?? fallbackLogger;
12
+ }
@@ -40,7 +40,7 @@ export async function getInstallCommand(pm, cwd) {
40
40
  */
41
41
  export function getInstallMessage(cmd) {
42
42
  if (cmd.command === "npm" && cmd.args[0] === "ci") {
43
- return "➤ Running npm ci (using package-lock.json) …";
43
+ return "Running npm ci (using package-lock.json) …";
44
44
  }
45
- return `➤ Running ${formatCommand(cmd.command, cmd.args)} …`;
45
+ return `Running ${formatCommand(cmd.command, cmd.args)} …`;
46
46
  }
@@ -3,5 +3,8 @@
3
3
  *
4
4
  * Utilities for working with Next.js projects
5
5
  */
6
+ import type { StatusLogger } from "../output/create-status-logger.js";
6
7
  export declare const isNextProject: (directory: string) => Promise<boolean>;
7
- export declare const isNextTypegenSupported: (directory: string) => Promise<boolean>;
8
+ export declare const isNextTypegenSupported: (directory: string, options?: {
9
+ logger?: StatusLogger;
10
+ }) => Promise<boolean>;
@@ -26,9 +26,9 @@ export const isNextProject = async (directory) => {
26
26
  }
27
27
  return Boolean(packageJson.dependencies?.next ?? packageJson.devDependencies?.next);
28
28
  };
29
- export const isNextTypegenSupported = async (directory) => {
29
+ export const isNextTypegenSupported = async (directory, options = {}) => {
30
30
  try {
31
- const result = await runPackageManagerBinary(directory, "next", ["--help"], { stdio: "pipe" });
31
+ const result = await runPackageManagerBinary(directory, "next", ["--help"], { stdio: "pipe", logger: options.logger });
32
32
  if (!result) {
33
33
  return false;
34
34
  }
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Helpers for detecting and running package managers
5
5
  */
6
+ import type { StatusLogger } from "../output/create-status-logger.js";
6
7
  interface RunOptions {
7
8
  readonly stdio?: "inherit" | "pipe";
9
+ readonly dryRun?: boolean;
10
+ readonly logger?: StatusLogger;
8
11
  }
9
12
  interface RunBinaryResult {
10
13
  readonly stdout: string;
@@ -13,6 +16,9 @@ interface RunBinaryResult {
13
16
  /**
14
17
  * Install dependencies for a project using the detected package manager.
15
18
  */
16
- export declare function installDependencies(cwd: string): Promise<void>;
19
+ export declare function installDependencies(cwd: string, options?: {
20
+ dryRun?: boolean;
21
+ logger?: StatusLogger;
22
+ }): Promise<void>;
17
23
  export declare function runPackageManagerBinary(cwd: string, binary: string, arguments_?: string[], options?: RunOptions): Promise<RunBinaryResult | undefined>;
18
24
  export {};
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { detect } from "package-manager-detector/detect";
8
8
  import { formatCommand, getBinaryRunCommand, } from "./package-manager-commands.js";
9
9
  import { getInstallCommand, getInstallMessage } from "./install-commands.js";
10
+ import { getStatusLogger } from "../output/get-status-logger.js";
10
11
  const runOrThrow = (command, arguments_, cwd, options = {}) => {
11
12
  const result = spawnSync(command, arguments_, {
12
13
  cwd,
@@ -42,16 +43,24 @@ async function detectPackageManager(cwd) {
42
43
  /**
43
44
  * Install dependencies for a project using the detected package manager.
44
45
  */
45
- export async function installDependencies(cwd) {
46
+ export async function installDependencies(cwd, options = {}) {
47
+ const logger = getStatusLogger(options.logger);
48
+ const dryRun = options.dryRun ?? false;
46
49
  const pm = await detectPackageManager(cwd);
47
50
  const cmd = await getInstallCommand(pm, cwd);
48
- console.log(getInstallMessage(cmd));
51
+ logger.step(getInstallMessage(cmd));
52
+ if (dryRun)
53
+ return;
49
54
  runOrThrow(cmd.command, cmd.args, cwd);
50
55
  }
51
56
  export async function runPackageManagerBinary(cwd, binary, arguments_ = [], options = {}) {
57
+ const logger = getStatusLogger(options.logger);
58
+ const dryRun = options.dryRun ?? false;
52
59
  const pm = await detectPackageManager(cwd);
53
60
  const { command, args: commandArguments } = getBinaryRunCommand(pm, binary, arguments_);
54
- console.log(`➤ Running ${formatCommand(command, commandArguments)} …`);
61
+ logger.step(`Running ${formatCommand(command, commandArguments)} …`);
62
+ if (dryRun)
63
+ return undefined;
55
64
  const result = runOrThrow(command, commandArguments, cwd, options);
56
65
  if (options.stdio === "pipe") {
57
66
  return {
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * Utilities for setting up project dependencies and build artifacts
5
5
  */
6
+ import type { StatusLogger } from "../output/create-status-logger.js";
6
7
  /**
7
8
  * Install dependencies and run Next.js typegen if applicable
8
9
  */
9
- export declare function setupProject(destinationDirectory: string): Promise<void>;
10
+ export declare function setupProject(destinationDirectory: string, options?: {
11
+ dryRun?: boolean;
12
+ logger?: StatusLogger;
13
+ }): Promise<void>;
@@ -5,27 +5,35 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import { fileExists } from "../git/git.js";
8
+ import { getStatusLogger } from "../output/get-status-logger.js";
8
9
  import { installDependencies, runPackageManagerBinary, } from "./package-manager.js";
9
10
  import { isNextProject, isNextTypegenSupported } from "./next.js";
10
11
  /**
11
12
  * Install dependencies and run Next.js typegen if applicable
12
13
  */
13
- export async function setupProject(destinationDirectory) {
14
+ export async function setupProject(destinationDirectory, options = {}) {
15
+ const logger = getStatusLogger(options.logger);
16
+ const dryRun = options.dryRun ?? false;
14
17
  const packageJsonPath = path.join(destinationDirectory, "package.json");
15
18
  if (!(await fileExists(packageJsonPath))) {
16
19
  return;
17
20
  }
18
- await installDependencies(destinationDirectory);
21
+ await installDependencies(destinationDirectory, { dryRun, logger });
19
22
  // Run Next.js type generation if applicable
20
23
  if (await isNextProject(destinationDirectory)) {
21
- console.log("➤ Checking Next.js CLI typegen support …");
22
- const hasTypegenSupport = await isNextTypegenSupported(destinationDirectory);
24
+ if (dryRun) {
25
+ logger.step("Would run next typegen if supported");
26
+ return;
27
+ }
28
+ logger.step("Checking Next.js CLI typegen support …");
29
+ const hasTypegenSupport = await isNextTypegenSupported(destinationDirectory, { logger });
23
30
  if (hasTypegenSupport) {
24
- console.log("➤ Running next typegen …");
25
- await runPackageManagerBinary(destinationDirectory, "next", ["typegen"]);
31
+ await runPackageManagerBinary(destinationDirectory, "next", ["typegen"], {
32
+ logger,
33
+ });
26
34
  }
27
35
  else {
28
- console.warn("! Skipping next typegen: installed Next.js CLI does not support this command.");
36
+ logger.warn("Skipping next typegen: installed Next.js CLI does not support this command.");
29
37
  }
30
38
  }
31
39
  }
@@ -3,8 +3,15 @@
3
3
  *
4
4
  * Utilities for managing worktree directories
5
5
  */
6
+ import type { StatusLogger } from "../output/create-status-logger.js";
6
7
  /**
7
- * Check if directory exists and prompt for removal if needed
8
- * Returns true if the directory should be created
8
+ * Check if directory exists and handle removal if needed.
9
9
  */
10
- export declare function handleExistingDirectory(destinationDirectory: string): Promise<void>;
10
+ type HandleExistingDirectoryOptions = {
11
+ readonly dryRun?: boolean;
12
+ readonly assumeYes?: boolean;
13
+ readonly interactive?: boolean;
14
+ readonly logger?: StatusLogger;
15
+ };
16
+ export declare function handleExistingDirectory(destinationDirectory: string, options?: HandleExistingDirectoryOptions): Promise<boolean>;
17
+ export {};
@@ -15,31 +15,109 @@
15
15
  * may not be visible in a GUI. Files can be recovered by navigating to
16
16
  * the platform-specific trash location.
17
17
  */
18
- import trash from "trash";
19
18
  import path from "node:path";
20
- import { fileExists, confirm, exitWithMessage } from "../git/git.js";
21
- /**
22
- * Check if directory exists and prompt for removal if needed
23
- * Returns true if the directory should be created
24
- */
25
- export async function handleExistingDirectory(destinationDirectory) {
19
+ import trash from "trash";
20
+ import { fileExists, confirm, exitWithMessage, git } from "../git/git.js";
21
+ import { getStatusLogger } from "../output/get-status-logger.js";
22
+ export async function handleExistingDirectory(destinationDirectory, options = {}) {
26
23
  if (!(await fileExists(destinationDirectory))) {
27
- return;
24
+ return true;
25
+ }
26
+ const logger = getStatusLogger(options.logger);
27
+ const dryRun = options.dryRun ?? false;
28
+ const assumeYes = options.assumeYes ?? false;
29
+ const interactive = options.interactive ?? false;
30
+ const isTty = process.stdin.isTTY;
31
+ const ciValue = process.env.CI?.toLowerCase();
32
+ const isCi = ciValue === "true" || ciValue === "1";
33
+ const directoryName = path.basename(destinationDirectory);
34
+ if (dryRun) {
35
+ if (!assumeYes) {
36
+ // Explicit --interactive in CI without a TTY can't prompt; call it out.
37
+ if (isCi && interactive && !isTty) {
38
+ logger.warn(`Dry run: directory '${directoryName}' already exists, and CI mode is enabled. Interactive prompts are disabled in CI. Re-run with --yes to move the directory to trash, or remove it manually.`);
39
+ return false;
40
+ }
41
+ if (!interactive) {
42
+ const ciHint = isCi ? " (CI is enabled)" : "";
43
+ logger.warn(`Dry run: directory '${directoryName}' already exists${ciHint}. Refusing to prompt in non-interactive mode. Re-run with --interactive to confirm, or --yes to move it to trash.`);
44
+ return false;
45
+ }
46
+ if (!isTty) {
47
+ logger.warn(`Dry run: directory '${directoryName}' already exists, but stdin is not a TTY. Re-run with --yes to move it to trash, or remove it manually.`);
48
+ return false;
49
+ }
50
+ logger.step(`Would prompt to move existing directory '${directoryName}' to trash`);
51
+ return false;
52
+ }
53
+ logger.step(`Would move existing directory '${directoryName}' to trash`);
54
+ return true;
55
+ }
56
+ if (!assumeYes) {
57
+ // Explicit --interactive in CI without a TTY can't prompt; call it out.
58
+ if (isCi && interactive && !isTty) {
59
+ exitWithMessage(`Directory '${directoryName}' already exists, and CI mode is enabled.\n` +
60
+ "Interactive prompts are disabled in CI.\n" +
61
+ "Re-run with --yes to move the directory to trash, or remove it manually.");
62
+ }
63
+ if (!interactive) {
64
+ const ciHint = isCi ? " (CI is enabled)" : "";
65
+ exitWithMessage(`Directory '${directoryName}' already exists${ciHint}.\n` +
66
+ "Refusing to prompt in non-interactive mode.\n" +
67
+ "Re-run with --interactive to confirm, or --yes to move it to trash.");
68
+ }
69
+ if (!isTty) {
70
+ exitWithMessage(`Directory '${directoryName}' already exists, but stdin is not a TTY.\n` +
71
+ "Re-run with --yes to move it to trash, or remove it manually.");
72
+ }
73
+ const proceed = await confirm(`Directory '${directoryName}' already exists. Move to trash and recreate? (You can restore it from your system trash if needed)`);
74
+ if (!proceed) {
75
+ console.error("Operation cancelled.");
76
+ // eslint-disable-next-line unicorn/no-process-exit -- User requested cancellation
77
+ process.exit(0);
78
+ }
79
+ }
80
+ const resolvedDestination = path.resolve(destinationDirectory);
81
+ let shouldPruneWorktree = false;
82
+ try {
83
+ const worktreeList = git("worktree", "list", "--porcelain");
84
+ const lines = worktreeList.split(/\n/u);
85
+ for (const line of lines) {
86
+ if (!line.startsWith("worktree "))
87
+ continue;
88
+ const worktreePath = line.replace(/^worktree\s+/u, "").trim();
89
+ if (path.resolve(worktreePath) === resolvedDestination) {
90
+ shouldPruneWorktree = true;
91
+ break;
92
+ }
93
+ }
28
94
  }
29
- const proceed = await confirm(`Directory '${path.basename(destinationDirectory)}' already exists. Move to trash and recreate? (You can restore it from your system trash if needed)`);
30
- if (!proceed) {
31
- console.log("Operation cancelled.");
32
- // eslint-disable-next-line unicorn/no-process-exit -- User requested cancellation
33
- process.exit(0);
95
+ catch (error) {
96
+ const details = error instanceof Error ? (error.stack ?? error.message) : String(error);
97
+ logger.detail(`Error checking for existing worktree registration: ${details}`);
34
98
  }
35
99
  // Move the existing directory to trash
36
- console.log(`➤ Moving existing directory to trash...`);
100
+ logger.step(`Moving existing directory '${directoryName}' to trash...`);
37
101
  try {
38
102
  await trash(destinationDirectory);
39
- console.log("✓ Directory moved to trash successfully");
103
+ logger.success("Directory moved to trash successfully");
40
104
  }
41
105
  catch (error) {
42
- console.error("Error details:", error);
106
+ const details = error instanceof Error ? (error.stack ?? error.message) : String(error);
107
+ logger.detail(`Error details: ${details}`);
43
108
  exitWithMessage(`Failed to move existing directory to trash: ${error instanceof Error ? error.message : String(error)}`);
44
109
  }
110
+ if (!shouldPruneWorktree) {
111
+ return true;
112
+ }
113
+ logger.detail(`Pruning stale worktree registration for '${directoryName}'.`);
114
+ try {
115
+ git("worktree", "prune");
116
+ }
117
+ catch (error) {
118
+ const details = error instanceof Error ? (error.stack ?? error.message) : String(error);
119
+ logger.detail(`Error details: ${details}`);
120
+ exitWithMessage(`Failed to prune stale worktree registration for '${directoryName}'. Run 'git worktree prune' and retry.`);
121
+ }
122
+ return true;
45
123
  }
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * Utilities for copying untracked files to worktrees
5
5
  */
6
+ import type { StatusLogger } from "../output/create-status-logger.js";
6
7
  /**
7
8
  * Copy untracked files from source to destination, excluding ignored patterns
8
9
  */
9
- export declare function copyUntrackedFiles(repoRoot: string, destinationDirectory: string): Promise<void>;
10
+ export declare function copyUntrackedFiles(repoRoot: string, destinationDirectory: string, options?: {
11
+ dryRun?: boolean;
12
+ logger?: StatusLogger;
13
+ }): Promise<void>;