worktree-add 1.2.0 → 1.2.1

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 CHANGED
@@ -8,18 +8,19 @@ Running `worktree-add <branch>` from inside a repo:
8
8
 
9
9
  1. Normalizes `<branch>` (supports `origin/foo`, `refs/heads/foo`, etc.).
10
10
  2. Refuses if that branch is already checked out in any worktree.
11
- 3. Picks a destination next to your current repo: `../<repo>-<safe-branch>`.
12
- 4. If the destination exists, asks before moving it to the system trash.
13
- 5. Fetches `origin/<branch>` when needed and creates a git worktree:
11
+ 3. Warns if local and `origin/<branch>` heads differ and asks how to resolve.
12
+ 4. Picks a destination next to your current repo: `../<repo>-<safe-branch>`.
13
+ 5. If the destination exists, asks before moving it to the system trash.
14
+ 6. Fetches `origin/<branch>` when needed and creates a git worktree:
14
15
  - reuses an existing local branch
15
16
  - or creates a tracking branch from `origin/<branch>`
16
17
  - or creates a new branch from the current `HEAD`
17
- 6. Copies untracked / ignored files into the new worktree, skipping heavy stuff
18
+ 7. Copies untracked / ignored files into the new worktree, skipping heavy stuff
18
19
  (`node_modules`, `dist`, `.next`, caches, virtualenvs, etc.).
19
- 7. Detects your package manager and installs dependencies with lockfile‑safe flags
20
+ 8. Detects your package manager and installs dependencies with lockfile‑safe flags
20
21
  (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, etc.).
21
- 8. If the project uses Next.js and supports it, runs `next typegen`.
22
- 9. Opens the new worktree in your editor.
22
+ 9. If the project uses Next.js and supports it, runs `next typegen`.
23
+ 10. Opens the new worktree in your editor.
23
24
 
24
25
  Your original checkout is left untouched.
25
26
 
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import packageJson from "../package.json" with { type: "json" };
13
13
  import { git, getRepositoryName, normalizeBranchName, toSafePathSegment, findWorktreeByBranchName, exitWithMessage, } from "./git/git.js";
14
14
  import { copyUntrackedFiles } from "./worktree/untracked-file-copy.js";
15
15
  import { fetchRemoteBranch, createWorktree } from "./git/worktree-creation.js";
16
+ import { resolveBranchHeadMismatch } from "./git/resolve-branch-head-mismatch.js";
16
17
  import { handleExistingDirectory } from "./worktree/destination-directory.js";
17
18
  import { setupProject } from "./project/setup.js";
18
19
  export function resolveEditor({ optionEditor, environmentEditor, } = {}) {
@@ -40,6 +41,11 @@ async function main(branchRaw, options) {
40
41
  "You cannot add another worktree for the same branch.\n" +
41
42
  "Open that worktree instead or remove it before retrying.");
42
43
  }
44
+ const shouldContinue = await resolveBranchHeadMismatch(branch);
45
+ if (!shouldContinue) {
46
+ process.exitCode = 1;
47
+ return;
48
+ }
43
49
  // Determine destination directory for the new worktree
44
50
  const repoRoot = git("rev-parse", "--show-toplevel");
45
51
  const repoName = getRepositoryName();
package/dist/git/git.d.ts CHANGED
@@ -23,6 +23,18 @@ export declare function localBranchExists(branch: string): boolean;
23
23
  * Check if a remote branch exists on origin.
24
24
  */
25
25
  export declare function remoteBranchExists(branch: string): boolean;
26
+ /**
27
+ * Fetch a branch from origin.
28
+ */
29
+ export declare function fetchOriginBranch(branch: string): void;
30
+ /**
31
+ * Get the current head commit for a local branch.
32
+ */
33
+ export declare function getLocalBranchHead(branch: string): string | undefined;
34
+ /**
35
+ * Get the current head commit for a remote-tracking branch.
36
+ */
37
+ export declare function getRemoteBranchHead(branch: string): string | undefined;
26
38
  /**
27
39
  * Convert an arbitrary string to a filesystem-safe single path segment.
28
40
  *
@@ -38,6 +50,10 @@ export declare function fileExists(p: string): Promise<boolean>;
38
50
  * Print an error message and exit the process with status 1.
39
51
  */
40
52
  export declare function exitWithMessage(message: string): never;
53
+ /**
54
+ * Prompt for input and return the user's response.
55
+ */
56
+ export declare function prompt(message: string): Promise<string>;
41
57
  /**
42
58
  * Prompts user for yes/no confirmation
43
59
  * @param message The message to display to the user
package/dist/git/git.js CHANGED
@@ -73,6 +73,45 @@ export function remoteBranchExists(branch) {
73
73
  return false;
74
74
  }
75
75
  }
76
+ /**
77
+ * Fetch a branch from origin.
78
+ */
79
+ export function fetchOriginBranch(branch) {
80
+ const normalized = normalizeBranchName(branch);
81
+ const refspec = `+refs/heads/${normalized}:refs/remotes/origin/${normalized}`;
82
+ const result = spawnSync("git", ["fetch", "origin", "--", refspec], {
83
+ stdio: "inherit",
84
+ });
85
+ if (result.error)
86
+ throw result.error;
87
+ if (result.status !== 0) {
88
+ throw new Error(`git fetch origin ${refspec} failed`);
89
+ }
90
+ }
91
+ /**
92
+ * Get the current head commit for a local branch.
93
+ */
94
+ export function getLocalBranchHead(branch) {
95
+ try {
96
+ const normalized = normalizeBranchName(branch);
97
+ return git("rev-parse", `refs/heads/${normalized}`);
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /**
104
+ * Get the current head commit for a remote-tracking branch.
105
+ */
106
+ export function getRemoteBranchHead(branch) {
107
+ try {
108
+ const normalized = normalizeBranchName(branch);
109
+ return git("rev-parse", `refs/remotes/origin/${normalized}`);
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ }
76
115
  /**
77
116
  * Convert an arbitrary string to a filesystem-safe single path segment.
78
117
  *
@@ -108,22 +147,29 @@ export function exitWithMessage(message) {
108
147
  process.exit(1);
109
148
  }
110
149
  /**
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
150
+ * Prompt for input and return the user's response.
114
151
  */
115
- export async function confirm(message) {
152
+ export async function prompt(message) {
116
153
  const rl = readline.createInterface({
117
154
  input: process.stdin,
118
- output: process.stdout,
155
+ output: process.stderr,
119
156
  });
120
157
  try {
121
- const answer = await rl.question(`${message} [y/N] `);
122
- return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
158
+ return await rl.question(message);
123
159
  }
124
160
  finally {
125
161
  rl.close();
126
162
  }
127
163
  }
164
+ /**
165
+ * Prompts user for yes/no confirmation
166
+ * @param message The message to display to the user
167
+ * @returns Promise<boolean> - true if user confirms, false otherwise
168
+ */
169
+ export async function confirm(message) {
170
+ const answer = await prompt(`${message} [y/N] `);
171
+ const normalized = answer.trim().toLowerCase();
172
+ return normalized === "y" || normalized === "yes";
173
+ }
128
174
  // Re-export worktree parsing functions for backward compatibility
129
175
  export { getRepositoryName, findWorktreeByBranchName, } from "./worktree-discovery.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * parse-branch-head-mismatch-choice.ts
3
+ *
4
+ * Pure helpers for parsing interactive choices in the branch-head mismatch flow.
5
+ */
6
+ export type ResolutionChoice = "keep-local" | "update-local" | "abort";
7
+ export declare function parseResolutionChoice(input: string, defaultChoice: ResolutionChoice): ResolutionChoice | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * parse-branch-head-mismatch-choice.ts
3
+ *
4
+ * Pure helpers for parsing interactive choices in the branch-head mismatch flow.
5
+ */
6
+ export function parseResolutionChoice(input, defaultChoice) {
7
+ const normalized = input.trim().toLowerCase();
8
+ if (normalized === "")
9
+ return defaultChoice;
10
+ if (normalized === "1" || normalized === "keep" || normalized === "local") {
11
+ return "keep-local";
12
+ }
13
+ if (normalized === "2" ||
14
+ normalized === "update" ||
15
+ normalized === "remote") {
16
+ return "update-local";
17
+ }
18
+ if (normalized === "3" ||
19
+ normalized === "abort" ||
20
+ normalized === "a" ||
21
+ normalized === "q" ||
22
+ normalized === "quit") {
23
+ return "abort";
24
+ }
25
+ return undefined;
26
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * resolve-branch-head-mismatch.ts
3
+ *
4
+ * Warn and resolve mismatched local/remote branch heads.
5
+ */
6
+ export declare function resolveBranchHeadMismatch(branch: string): Promise<boolean>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * resolve-branch-head-mismatch.ts
3
+ *
4
+ * Warn and resolve mismatched local/remote branch heads.
5
+ */
6
+ import { confirm, fetchOriginBranch, getLocalBranchHead, getRemoteBranchHead, git, localBranchExists, normalizeBranchName, prompt, remoteBranchExists, } from "./git.js";
7
+ import { parseResolutionChoice } from "./parse-branch-head-mismatch-choice.js";
8
+ const shortHash = (value) => value.slice(0, 7);
9
+ const getAheadBehindCounts = (localHead, remoteHead) => {
10
+ const output = git("rev-list", "--left-right", "--count", `${localHead}...${remoteHead}`);
11
+ const [aheadRaw, behindRaw] = output.split(/\s+/u);
12
+ const ahead = Number.parseInt(aheadRaw ?? "0");
13
+ const behind = Number.parseInt(behindRaw ?? "0");
14
+ return {
15
+ ahead: Number.isNaN(ahead) ? 0 : ahead,
16
+ behind: Number.isNaN(behind) ? 0 : behind,
17
+ };
18
+ };
19
+ const promptResolution = async (branch, localHead, remoteHead, ahead, behind) => {
20
+ const updateLabel = ahead > 0
21
+ ? `Update local branch to match origin (drops ${ahead} local commit${ahead === 1 ? "" : "s"})`
22
+ : "Update local branch to match origin (fast-forward)";
23
+ const defaultChoice = ahead === 0 && behind > 0 ? "update-local" : "keep-local";
24
+ const defaultIndex = defaultChoice === "update-local" ? "2" : "1";
25
+ console.warn(`⚠️ Local branch '${branch}' and origin/${branch} point to different commits.`);
26
+ console.warn(` local: ${shortHash(localHead)}`);
27
+ console.warn(` origin: ${shortHash(remoteHead)}`);
28
+ if (ahead > 0 || behind > 0) {
29
+ const descriptors = [];
30
+ if (ahead > 0)
31
+ descriptors.push(`ahead by ${ahead}`);
32
+ if (behind > 0)
33
+ descriptors.push(`behind by ${behind}`);
34
+ console.warn(` local is ${descriptors.join(" and ")} relative to origin`);
35
+ }
36
+ console.warn("Choose how to proceed:");
37
+ console.warn(" 1) Keep local branch (use local head for the worktree)");
38
+ console.warn(` 2) ${updateLabel}`);
39
+ console.warn(" 3) Abort");
40
+ for (;;) {
41
+ const answer = await prompt(`Select an option (default: ${defaultIndex}): `);
42
+ const choice = parseResolutionChoice(answer, defaultChoice);
43
+ if (choice)
44
+ return choice;
45
+ console.warn("Please enter 1, 2, or 3.");
46
+ }
47
+ };
48
+ export async function resolveBranchHeadMismatch(branch) {
49
+ const normalized = normalizeBranchName(branch);
50
+ if (!localBranchExists(normalized) || !remoteBranchExists(normalized)) {
51
+ return true;
52
+ }
53
+ console.warn(`➤ Fetching origin/${normalized} to check for divergence …`);
54
+ fetchOriginBranch(normalized);
55
+ const localHead = getLocalBranchHead(normalized);
56
+ const remoteHead = getRemoteBranchHead(normalized);
57
+ if (!localHead || !remoteHead || localHead === remoteHead) {
58
+ return true;
59
+ }
60
+ const { ahead, behind } = getAheadBehindCounts(localHead, remoteHead);
61
+ const resolution = await promptResolution(normalized, localHead, remoteHead, ahead, behind);
62
+ if (resolution === "abort") {
63
+ console.warn("Operation cancelled.");
64
+ return false;
65
+ }
66
+ if (resolution === "keep-local") {
67
+ return true;
68
+ }
69
+ if (ahead > 0) {
70
+ const confirmed = await confirm(`Updating local '${normalized}' will discard ${ahead} local commit${ahead === 1 ? "" : "s"} not on origin. Continue?`);
71
+ if (!confirmed) {
72
+ console.warn("Operation cancelled.");
73
+ return false;
74
+ }
75
+ }
76
+ console.warn(`➤ Updating local branch '${normalized}' to origin/${normalized} …`);
77
+ git("branch", "-f", "--", normalized, `origin/${normalized}`);
78
+ return true;
79
+ }
@@ -4,7 +4,7 @@
4
4
  * Utilities for creating Git worktrees
5
5
  */
6
6
  /**
7
- * Fetch a remote branch if it exists locally but not remotely
7
+ * Fetch a remote branch if it exists remotely but not locally.
8
8
  */
9
9
  export declare function fetchRemoteBranch(branch: string): void;
10
10
  /**
@@ -3,17 +3,14 @@
3
3
  *
4
4
  * Utilities for creating Git worktrees
5
5
  */
6
- import { spawnSync } from "node:child_process";
7
- import { git, localBranchExists, remoteBranchExists } from "./git.js";
6
+ import { fetchOriginBranch, git, localBranchExists, remoteBranchExists, } from "./git.js";
8
7
  /**
9
- * Fetch a remote branch if it exists locally but not remotely
8
+ * Fetch a remote branch if it exists remotely but not locally.
10
9
  */
11
10
  export function fetchRemoteBranch(branch) {
12
11
  if (!localBranchExists(branch) && remoteBranchExists(branch)) {
13
12
  console.log(`➤ Fetching origin/${branch} …`);
14
- spawnSync("git", ["fetch", "origin", branch], {
15
- stdio: "inherit",
16
- });
13
+ fetchOriginBranch(branch);
17
14
  }
18
15
  }
19
16
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "worktree-add",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "1.2.0",
5
+ "version": "1.2.1",
6
6
  "description": "Create a Git worktree next to your current repo for a branch, copy useful local files, install deps, and open it in your editor.",
7
7
  "repository": {
8
8
  "type": "git",
@@ -23,21 +23,21 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "scripts": {
26
- "start": "pnpm run rebuild && node bin/worktree-add",
26
+ "prepare": "git config core.hooksPath .githooks",
27
+ "prepublishOnly": "pnpm run rebuild",
27
28
  "build": "tsc -p tsconfig.app.json",
28
29
  "clean": "rm -rf dist *.tsbuildinfo",
29
- "rebuild": "pnpm run clean && pnpm run build",
30
- "prepare": "husky",
31
- "prepublishOnly": "pnpm run rebuild",
32
- "typecheck": "tsc -b --noEmit",
33
30
  "format": "prettier --write .",
34
31
  "format:check": "prettier --check .",
32
+ "fta": "fta-check",
33
+ "knip": "knip",
35
34
  "lint": "eslint",
35
+ "rebuild": "pnpm run clean && pnpm run build",
36
+ "start": "pnpm run rebuild && node bin/worktree-add",
36
37
  "test": "vitest run",
37
- "test:watch": "vitest",
38
38
  "test:coverage": "vitest run --coverage",
39
- "knip": "knip",
40
- "fta:check": "fta-check --threshold 55"
39
+ "test:watch": "vitest",
40
+ "typecheck": "tsc -b --noEmit"
41
41
  },
42
42
  "keywords": [
43
43
  "git",
@@ -47,36 +47,27 @@
47
47
  "git-worktree",
48
48
  "development"
49
49
  ],
50
- "packageManager": "pnpm@10.22.0",
50
+ "packageManager": "pnpm@10.25.0",
51
51
  "engines": {
52
52
  "node": ">=22.14.0"
53
53
  },
54
54
  "dependencies": {
55
55
  "commander": "^14.0.2",
56
- "package-manager-detector": "^1.5.0",
57
- "trash": "^10.0.0"
56
+ "package-manager-detector": "^1.6.0",
57
+ "trash": "^10.0.1"
58
58
  },
59
59
  "devDependencies": {
60
- "@commitlint/cli": "^20.1.0",
61
- "@commitlint/config-conventional": "^20.0.0",
62
- "@eslint/compat": "^1.3.2",
63
- "@eslint/js": "^9.31.0",
64
60
  "@total-typescript/ts-reset": "^0.6.1",
65
- "@types/node": "^24.0.15",
66
- "@vitest/coverage-v8": "^3.2.4",
67
- "@vitest/eslint-plugin": "^1.3.4",
68
- "eslint": "^9.31.0",
69
- "eslint-config-prettier": "^10.1.8",
70
- "eslint-plugin-unicorn": "^62.0.0",
71
- "fta-check": "^1.1.0",
61
+ "@types/node": "^25.0.10",
62
+ "@vitest/coverage-v8": "^4.0.18",
63
+ "eslint": "^9.39.2",
64
+ "eslint-config-axkit": "^1.1.0",
65
+ "fta-check": "^1.5.1",
72
66
  "fta-cli": "^3.0.0",
73
- "globals": "^16.5.0",
74
- "husky": "^9.1.7",
75
- "knip": "^5.66.4",
76
- "prettier": "3.6.2",
67
+ "knip": "^5.82.1",
68
+ "prettier": "3.7.4",
77
69
  "semantic-release": "^25.0.2",
78
- "typescript": "^5.8.3",
79
- "typescript-eslint": "^8.37.0",
80
- "vitest": "^3.2.4"
70
+ "typescript": "^5.9.3",
71
+ "vitest": "^4.0.18"
81
72
  }
82
73
  }