git-worktree-organize 1.0.12 → 1.1.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 (3) hide show
  1. package/README.md +109 -10
  2. package/dist/cli.js +644 -88
  3. package/package.json +6 -5
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # git-worktree-organize
2
2
 
3
+ > ⚠️ **Use at your own risk.** This tool works for me and passes all tested scenarios, but it modifies your git repository structure. **Make a full backup first** before running on any repository you care about.
4
+
3
5
  Convert any git repository into the canonical bare-hub worktree layout, so every branch lives in its own directory and you never need to stash or switch again.
4
6
 
5
7
  ## What it does
@@ -11,7 +13,7 @@ Takes an existing git repo (any type) and migrates it into this structure:
11
13
  ├── .bare/ ← bare git repo (the actual git database)
12
14
  ├── .git ← plain file: "gitdir: ./.bare"
13
15
  ├── main/ ← worktree for the main branch
14
- └── feature-x/ ← worktree for each other branch
16
+ └── feature-x/ ← worktree for each other branch
15
17
  ```
16
18
 
17
19
  Each branch directory is a fully functional working tree. Open them in separate terminals or IDE windows simultaneously — no stashing, no switching.
@@ -40,32 +42,60 @@ git-worktree-organize <source> [destination]
40
42
  | Argument | Description |
41
43
  |---------------|------------------------------------------------------------------|
42
44
  | `source` | Path to the existing git repository to migrate |
43
- | `destination` | Target hub directory (default: `<parent>/<repo-name>-bare`) |
45
+ | `destination` | Target hub directory (omit for in-place migration prompt) |
46
+
47
+ **Without a destination**, the tool prompts for in-place migration:
48
+ - Renames `<source>` to `<source>.old`
49
+ - Creates the hub at the original `<source>` path
50
+
51
+ **With a destination**, the tool migrates to the specified path.
44
52
 
45
53
  The tool shows a preview of what will be created and asks for confirmation before making any changes.
46
54
 
47
- ## Example
55
+ ## Examples
48
56
 
49
- Given a repo at `/projects/myrepo` with branches `main`, `feature-x`, and `hotfix`:
57
+ ### In-place migration (recommended)
50
58
 
51
59
  ```sh
52
- git-worktree-organize /projects/myrepo /projects/myrepo-bare
60
+ git-worktree-organize /projects/myrepo
53
61
  ```
54
62
 
55
- Result:
63
+ Prompts to reorganize in place, resulting in:
56
64
 
57
65
  ```
58
- /projects/myrepo-bare/
66
+ /projects/myrepo/ ← hub (was renamed from myrepo.old)
59
67
  ├── .bare/
60
68
  ├── .git
61
69
  ├── main/
70
+ └── feature-x/
71
+
72
+ /projects/myrepo.old/ ← backup of original
73
+ ```
74
+
75
+ ### Migrate to new location
76
+
77
+ ```sh
78
+ git-worktree-organize /projects/myrepo /projects/myrepo-organized
79
+ ```
80
+
81
+ Result:
82
+
83
+ ```
84
+ /projects/myrepo-organized/
85
+ ├── .bare/
86
+ ├── .git
87
+ ├── main/ ← original /projects/myrepo moved here
62
88
  ├── feature-x/
63
89
  └── hotfix/
64
90
  ```
65
91
 
66
- The original `/projects/myrepo` is moved to `/projects/myrepo-bare/main/`. No data is lost.
92
+ The original `/projects/myrepo` becomes the `main/` worktree. No data is lost.
67
93
 
68
- ## Supported repo types
94
+ ## Features
95
+
96
+ ### Repository Migration
97
+
98
+ Convert any git repository type to the bare-hub layout:
69
99
 
70
100
  - **Standard repos** — ordinary repos with a `.git` directory
71
101
  - **Bare-root** — bare repo with git internals at the root (`HEAD`, `refs/`, `objects/`)
@@ -73,12 +103,81 @@ The original `/projects/myrepo` is moved to `/projects/myrepo-bare/main/`. No da
73
103
  - **Bare-external** — repo where `.git` is a file pointing to a gitdir elsewhere
74
104
  - **Bare-hub** — already in the bare-hub layout (re-organizes worktrees into the canonical structure)
75
105
 
76
- Branch names with slashes (e.g. `feature/auth`) are mapped to hyphenated directory names (`feature-auth`).
106
+ ### Resume & Recovery
107
+
108
+ If a migration was interrupted or worktrees have moved, running the tool on the hub directory will:
109
+
110
+ 1. **Resume partial migrations** — Continue moving worktrees that weren't fully processed
111
+ 2. **Repair stale `.git` pointers** — Fix worktrees with broken connections to the bare repo
112
+ 3. **Search for missing worktrees** — Find worktrees that were moved outside the hub (searches up to 3 directory levels deep)
113
+ 4. **Fix parent directory renames** — Automatically detect and repair when a hub's parent directory was renamed
114
+
115
+ ### Safety Features
116
+
117
+ - **Interactive confirmation** — Preview all changes before execution
118
+ - **Branch name sanitization** — Names with slashes (e.g. `feature/auth`) become hyphenated directories (`feature-auth`)
119
+ - **Collision detection** — Warns if sanitized names would conflict
120
+ - **Zero runtime dependencies** — Only requires Node.js and git
121
+
122
+ ## Recovery Scenarios
123
+
124
+ ### Partial Migration
125
+
126
+ If migration was interrupted:
127
+
128
+ ```sh
129
+ git-worktree-organize /path/to/hub
130
+ ```
131
+
132
+ The tool detects the partial state, shows which worktrees still need to be moved, and offers to resume.
133
+
134
+ ### Moved Worktrees
135
+
136
+ If worktrees were manually moved outside the hub:
137
+
138
+ ```sh
139
+ git-worktree-organize /path/to/hub
140
+ ```
141
+
142
+ The tool searches for missing worktrees by branch name and offers to repair their `.git` pointers.
143
+
144
+ ### Parent Directory Rename
145
+
146
+ If you renamed a parent directory, worktree `.git` files will have stale paths. Run the tool on any worktree path inside the hub:
147
+
148
+ ```sh
149
+ git-worktree-organize /new/path/to/hub/some-worktree
150
+ ```
151
+
152
+ The tool detects the hub, navigates to it, and repairs all worktree connections.
77
153
 
78
154
  ## Why this layout
79
155
 
80
156
  Having every branch as a sibling directory means you can work on multiple branches simultaneously without stashing or switching. It is also easier to run branch-specific build artifacts side by side, and the `.git` file at the hub root ensures IDE and tooling compatibility without any special configuration.
81
157
 
158
+ ## Requirements
159
+
160
+ - Node.js 18+
161
+ - Git 2.5+ (for worktree support)
162
+
163
+ ## Development
164
+
165
+ ```sh
166
+ # Clone and install
167
+ git clone https://github.com/drmikecrowe/git-worktree-organize.git
168
+ cd git-worktree-organize
169
+ npm install
170
+
171
+ # Run tests
172
+ npm test
173
+
174
+ # Build
175
+ npm run build
176
+
177
+ # Test locally
178
+ node dist/cli.js /path/to/test/repo
179
+ ```
180
+
82
181
  ## License
83
182
 
84
183
  MIT — see [github.com/drmikecrowe/git-worktree-organize](https://github.com/drmikecrowe/git-worktree-organize).
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve3, join as join3, dirname as dirname2, basename as basename2 } from "node:path";
5
- import { existsSync as existsSync3 } from "node:fs";
4
+ import { resolve as resolve3, join as join5, dirname as dirname3, basename as basename3 } from "node:path";
5
+ import { existsSync as existsSync5, statSync as statSync5, readFileSync as readFileSync5 } from "node:fs";
6
6
 
7
7
  // src/run.ts
8
8
  import { spawnSync } from "node:child_process";
@@ -46,8 +46,7 @@ async function detect(repoPath) {
46
46
  }
47
47
  } else if (stat.isFile()) {
48
48
  const contents = readFileSync(gitEntryPath, "utf8");
49
- const firstLine = contents.split(`
50
- `)[0].trim();
49
+ const firstLine = contents.split("\n")[0].trim();
51
50
  const match = firstLine.match(/^gitdir:\s*(.+)$/);
52
51
  if (!match) {
53
52
  throw new Error(`not a git repository: ${repoPath}`);
@@ -80,10 +79,8 @@ function parsePorcelain(output) {
80
79
  const worktrees = [];
81
80
  const blocks = output.trim().split(/\n\n+/);
82
81
  for (const block of blocks) {
83
- if (!block.trim())
84
- continue;
85
- const lines = block.trim().split(`
86
- `);
82
+ if (!block.trim()) continue;
83
+ const lines = block.trim().split("\n");
87
84
  let path = "";
88
85
  let head = "";
89
86
  let branch = null;
@@ -113,80 +110,190 @@ async function listWorktrees(repoPath) {
113
110
  }
114
111
 
115
112
  // src/migrate.ts
116
- import { mkdirSync, writeFileSync, readFileSync as readFileSync2, existsSync as existsSync2, renameSync, statSync as statSync2 } from "node:fs";
117
- import { join as join2, dirname, basename, resolve as resolve2 } from "node:path";
113
+ import { mkdirSync, writeFileSync, readFileSync as readFileSync2, existsSync as existsSync3, statSync as statSync3, readdirSync, renameSync as renameSync2 } from "node:fs";
114
+ import { join as join2, dirname as dirname2, basename, resolve as resolve2 } from "node:path";
118
115
 
119
116
  // src/git.ts
120
117
  async function git(args, options) {
121
118
  const result = run("git", args, {
122
119
  cwd: options?.cwd,
123
- env: options?.env ? { ...process.env, ...options.env } : undefined
120
+ env: options?.env ? { ...process.env, ...options.env } : void 0
124
121
  });
125
122
  return result.stdout;
126
123
  }
127
124
  async function setGitConfig(key, value, options) {
128
125
  const env = {};
129
- if (options?.gitdir)
130
- env["GIT_DIR"] = options.gitdir;
126
+ if (options?.gitdir) env["GIT_DIR"] = options.gitdir;
131
127
  await git(["config", key, value], { cwd: options?.cwd, env });
132
128
  }
133
129
 
134
- // src/migrate.ts
135
- async function moveDir(src, dest) {
130
+ // src/fs.ts
131
+ import { statSync as statSync2, renameSync, existsSync as existsSync2 } from "node:fs";
132
+ import { dirname } from "node:path";
133
+ async function move(src, dest) {
136
134
  const destForStat = existsSync2(dest) ? dest : dirname(dest);
137
- if (statSync2(src).dev === statSync2(destForStat).dev) {
135
+ if (samefs(src, destForStat)) {
138
136
  renameSync(src, dest);
139
137
  } else {
140
138
  run("cp", ["-a", src, dest]);
141
139
  run("rm", ["-rf", src]);
142
140
  }
143
141
  }
142
+ function samefs(a, b) {
143
+ return statSync2(a).dev === statSync2(b).dev;
144
+ }
145
+
146
+ // src/migrate.ts
144
147
  function sanitizeBranch(branch) {
145
148
  return branch.replace(/\//g, "-");
146
149
  }
147
150
  function resolveWorktreePath(worktreePath, dest, sourceParent) {
148
- if (existsSync2(worktreePath))
149
- return worktreePath;
151
+ if (existsSync3(worktreePath)) return worktreePath;
150
152
  if (worktreePath.startsWith(dest + "/")) {
151
153
  const remapped = sourceParent + worktreePath.slice(dest.length);
152
- if (existsSync2(remapped))
153
- return remapped;
154
+ if (existsSync3(remapped)) return remapped;
154
155
  }
155
156
  return worktreePath;
156
157
  }
157
158
  function isPartialMigration(dest) {
158
159
  const gitFile = join2(dest, ".git");
159
- return existsSync2(join2(dest, ".bare")) && existsSync2(gitFile) && statSync2(gitFile).isFile();
160
+ return existsSync3(join2(dest, ".bare")) && existsSync3(gitFile) && statSync3(gitFile).isFile();
161
+ }
162
+ function findHub(startPath) {
163
+ let current = resolve2(startPath);
164
+ while (true) {
165
+ if (isPartialMigration(current)) return current;
166
+ const parent = dirname2(current);
167
+ if (parent === current) return null;
168
+ current = parent;
169
+ }
170
+ }
171
+ async function repairHub(dest, log = console.log) {
172
+ const adminBase = join2(dest, ".bare", "worktrees");
173
+ if (!existsSync3(adminBase)) return;
174
+ for (const adminName of readdirSync(adminBase)) {
175
+ const adminDir = join2(adminBase, adminName);
176
+ if (!statSync3(adminDir).isDirectory()) continue;
177
+ const gitdirFile = join2(adminDir, "gitdir");
178
+ if (!existsSync3(gitdirFile)) continue;
179
+ const registeredGitFile = readFileSync2(gitdirFile, "utf8").trim();
180
+ const worktreePath = dirname2(registeredGitFile);
181
+ if (!worktreePath.startsWith(dest + "/")) continue;
182
+ if (!existsSync3(registeredGitFile) || !statSync3(registeredGitFile).isFile()) continue;
183
+ const content = readFileSync2(registeredGitFile, "utf8");
184
+ const match = content.match(/^gitdir:\s*(.+)/m);
185
+ if (!match) continue;
186
+ if (match[1].trim() === adminDir) continue;
187
+ log(`Repairing .git for [${basename(worktreePath)}]`);
188
+ writeFileSync(registeredGitFile, `gitdir: ${adminDir}
189
+ `);
190
+ }
160
191
  }
161
- async function resumeMigrate(dest, log = console.log) {
192
+ async function resumeMigrate(dest, log = console.log, warn) {
162
193
  const destBare = join2(dest, ".bare");
163
194
  const hubWorktrees = await listWorktrees(dest);
164
- const pending = hubWorktrees.filter((wt) => !wt.isBare && !wt.path.startsWith(dest + "/"));
195
+ const pending = hubWorktrees.filter((wt) => {
196
+ if (wt.isBare) return false;
197
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
198
+ return wt.path !== join2(dest, sanitizeBranch(branch));
199
+ });
165
200
  if (pending.length === 0) {
166
- log("Nothing to resume all worktrees are already in place.");
167
- return dest;
201
+ log("Nothing to resume \u2014 all worktrees are already in place.");
202
+ } else {
203
+ for (const wt of pending) {
204
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
205
+ const expectedPath = join2(dest, sanitizeBranch(branch));
206
+ let wtPath = wt.path;
207
+ if (!existsSync3(wtPath)) {
208
+ if (existsSync3(expectedPath)) {
209
+ wtPath = expectedPath;
210
+ } else {
211
+ log(`warn: Skipping [${branch}] \u2014 path no longer exists: ${wt.path}`);
212
+ continue;
213
+ }
214
+ }
215
+ log(`Moving [${branch}] \u2192 ${expectedPath}`);
216
+ await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare, log, warn);
217
+ }
218
+ }
219
+ await repairHub(dest, log);
220
+ return dest;
221
+ }
222
+ async function migrateInPlace(source, log = console.log, warn) {
223
+ const resolvedSource = resolve2(source);
224
+ const oldPath = resolvedSource + ".old";
225
+ if (existsSync3(oldPath)) {
226
+ throw new Error(`'${oldPath}' already exists. Remove it and try again.`);
168
227
  }
169
- for (const wt of pending) {
228
+ log(`Renaming ${bold(resolvedSource)} to ${bold(oldPath)}`);
229
+ await move(resolvedSource, oldPath);
230
+ const allWorktrees = await listWorktrees(oldPath);
231
+ const worktrees = allWorktrees.filter((wt) => !wt.isBare);
232
+ if (worktrees.length === 0) {
233
+ throw new Error("No worktrees found in source repository");
234
+ }
235
+ const seen = /* @__PURE__ */ new Map();
236
+ for (const wt of worktrees) {
170
237
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
171
- if (!existsSync2(wt.path)) {
172
- log(`warn: Skipping [${branch}] — path no longer exists: ${wt.path}`);
173
- continue;
238
+ const safe = sanitizeBranch(branch);
239
+ if (seen.has(safe)) {
240
+ throw new Error(`branch name collision: '${seen.get(safe)}' and '${branch}' both map to '${safe}'`);
174
241
  }
175
- log(`Moving [${branch}] → ${join2(dest, sanitizeBranch(branch))}`);
176
- await processLinkedWorktree(wt, dest, destBare);
242
+ seen.set(safe, branch);
177
243
  }
178
- return dest;
244
+ const mainBranch = worktrees[0].branch;
245
+ const mainSafe = sanitizeBranch(mainBranch);
246
+ const destBare = join2(resolvedSource, ".bare");
247
+ const mainDest = join2(resolvedSource, mainSafe);
248
+ mkdirSync(destBare, { recursive: true });
249
+ const gitDir = join2(oldPath, ".git");
250
+ for (const entry of readdirSync(gitDir)) {
251
+ const srcPath = join2(gitDir, entry);
252
+ const destPath = join2(destBare, entry);
253
+ if (statSync3(srcPath).isDirectory()) {
254
+ run("cp", ["-a", srcPath + "/.", destPath + "/"]);
255
+ } else {
256
+ run("cp", ["-a", srcPath, destPath]);
257
+ }
258
+ }
259
+ await setGitConfig("core.bare", "true", { gitdir: destBare });
260
+ await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
261
+ writeFileSync(join2(resolvedSource, ".git"), "gitdir: ./.bare\n");
262
+ const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
263
+ log(`Creating main worktree at ${bold(mainDest)}`);
264
+ run("cp", ["-a", oldPath + "/.", mainDest + "/"]);
265
+ const mainAdminDir = join2(destBare, "worktrees", mainSafe);
266
+ mkdirSync(mainAdminDir, { recursive: true });
267
+ writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
268
+ writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
269
+ const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
270
+ writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
271
+ const bareIndex = join2(destBare, "index");
272
+ if (existsSync3(bareIndex)) {
273
+ renameSync2(bareIndex, join2(mainAdminDir, "index"));
274
+ }
275
+ run("rm", ["-rf", join2(mainDest, ".git")]);
276
+ writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
277
+ `);
278
+ for (let i = 1; i < worktrees.length; i++) {
279
+ await processLinkedWorktree(worktrees[i], resolvedSource, destBare, log, warn);
280
+ }
281
+ log(`Original repo backed up at: ${oldPath}`);
282
+ return resolvedSource;
283
+ }
284
+ function bold(s) {
285
+ return `\x1B[1m${s}\x1B[0m`;
179
286
  }
180
- async function migrate(config, options) {
287
+ async function migrate(config, options, log, warn) {
181
288
  const source = resolve2(options.source);
182
- const dest = options.dest ? resolve2(options.dest) : join2(dirname(source), basename(source) + "-bare");
289
+ const dest = options.dest ? resolve2(options.dest) : join2(dirname2(source), basename(source) + "-bare");
183
290
  const destBare = join2(dest, ".bare");
184
- if (existsSync2(destBare)) {
291
+ if (existsSync3(destBare)) {
185
292
  throw new Error(`'${destBare}' already exists`);
186
293
  }
187
294
  const allWorktrees = await listWorktrees(source);
188
295
  const worktrees = allWorktrees.filter((wt) => !wt.isBare);
189
- const seen = new Map;
296
+ const seen = /* @__PURE__ */ new Map();
190
297
  for (const wt of worktrees) {
191
298
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
192
299
  const safe = sanitizeBranch(branch);
@@ -199,53 +306,50 @@ async function migrate(config, options) {
199
306
  run("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
200
307
  await setGitConfig("core.bare", "true", { gitdir: destBare });
201
308
  await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
202
- writeFileSync(join2(dest, ".git"), `gitdir: ./.bare
203
- `);
204
- const sourceParent = dirname(source);
205
- const worktreesResolved = worktrees.map((wt, i) => i === 0 && config.type === "standard" ? wt : { ...wt, path: resolveWorktreePath(wt.path, dest, sourceParent) });
309
+ writeFileSync(join2(dest, ".git"), "gitdir: ./.bare\n");
310
+ const sourceParent = dirname2(source);
311
+ const worktreesResolved = worktrees.map(
312
+ (wt, i) => i === 0 && config.type === "standard" ? wt : { ...wt, path: resolveWorktreePath(wt.path, dest, sourceParent) }
313
+ );
206
314
  if (config.type === "standard") {
207
315
  const mainBranch = worktrees[0].branch;
208
316
  const mainSafe = sanitizeBranch(mainBranch);
209
317
  const mainDest = join2(dest, mainSafe);
210
318
  const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
211
319
  run("rm", ["-rf", join2(source, ".git")]);
212
- await moveDir(source, mainDest);
320
+ await move(source, mainDest);
213
321
  const mainAdminDir = join2(destBare, "worktrees", mainSafe);
214
322
  mkdirSync(mainAdminDir, { recursive: true });
215
- writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + `/.git
216
- `);
217
- writeFileSync(join2(mainAdminDir, "commondir"), `../../
218
- `);
219
- const headToWrite = mainHeadContent.endsWith(`
220
- `) ? mainHeadContent : mainHeadContent + `
221
- `;
323
+ writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
324
+ writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
325
+ const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
222
326
  writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
223
327
  const bareIndex = join2(destBare, "index");
224
- if (existsSync2(bareIndex)) {
225
- renameSync(bareIndex, join2(mainAdminDir, "index"));
328
+ if (existsSync3(bareIndex)) {
329
+ renameSync2(bareIndex, join2(mainAdminDir, "index"));
226
330
  }
227
331
  writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
228
332
  `);
229
- for (let i = 1;i < worktreesResolved.length; i++) {
230
- await processLinkedWorktree(worktreesResolved[i], dest, destBare);
333
+ for (let i = 1; i < worktreesResolved.length; i++) {
334
+ await processLinkedWorktree(worktreesResolved[i], dest, destBare, log, warn);
231
335
  }
232
336
  } else {
233
337
  for (const wt of worktreesResolved) {
234
- await processLinkedWorktree(wt, dest, destBare);
338
+ await processLinkedWorktree(wt, dest, destBare, log, warn);
235
339
  }
236
340
  }
237
341
  return dest;
238
342
  }
239
- async function processLinkedWorktree(wt, dest, destBare) {
343
+ async function processLinkedWorktree(wt, dest, destBare, log, warn) {
240
344
  const wtSrc = wt.path;
241
345
  const wtBranch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
242
346
  const wtSafe = sanitizeBranch(wtBranch);
243
347
  const wtDest = join2(dest, wtSafe);
244
- await moveDir(wtSrc, wtDest);
348
+ await move(wtSrc, wtDest);
245
349
  const gitFileContent = readFileSync2(join2(wtDest, ".git"), "utf8");
246
350
  const match = gitFileContent.match(/^gitdir:\s*(.+)/m);
247
351
  if (!match) {
248
- console.warn(`Could not parse .git file in ${wtDest}`);
352
+ warn?.(`Could not parse .git file in ${wtDest}`);
249
353
  return;
250
354
  }
251
355
  const oldPath = match[1].trim();
@@ -253,17 +357,100 @@ async function processLinkedWorktree(wt, dest, destBare) {
253
357
  const newAdmin = join2(destBare, "worktrees", adminName);
254
358
  writeFileSync(join2(wtDest, ".git"), `gitdir: ${newAdmin}
255
359
  `);
256
- if (existsSync2(newAdmin)) {
257
- writeFileSync(join2(newAdmin, "gitdir"), wtDest + `/.git
258
- `);
360
+ if (existsSync3(newAdmin)) {
361
+ writeFileSync(join2(newAdmin, "gitdir"), wtDest + "/.git\n");
259
362
  } else {
260
- console.warn(`Admin dir ${newAdmin} does not exist for worktree ${wtDest}`);
363
+ warn?.(`Admin dir ${newAdmin} does not exist for worktree ${wtDest}`);
364
+ }
365
+ }
366
+
367
+ // src/recover.ts
368
+ import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
369
+ import { join as join3, basename as basename2 } from "node:path";
370
+ async function searchForWorktree(branch, options, log) {
371
+ const sanitizedBranch = sanitizeBranch(branch);
372
+ const candidates = [];
373
+ for (const searchDir of options.searchDirs) {
374
+ searchAtDepth(searchDir, sanitizedBranch, 0, options.maxDepth, candidates, log);
375
+ }
376
+ const validCandidates = candidates.filter((c) => {
377
+ const gitPath = join3(c, ".git");
378
+ return existsSync4(gitPath) && statSync4(gitPath).isFile();
379
+ });
380
+ return {
381
+ branch,
382
+ sanitizedBranch,
383
+ foundPath: validCandidates.length === 1 ? validCandidates[0] : null,
384
+ candidates: validCandidates
385
+ };
386
+ }
387
+ function searchAtDepth(dir, targetName, currentDepth, maxDepth, candidates, log) {
388
+ if (currentDepth > maxDepth) return;
389
+ if (!existsSync4(dir)) return;
390
+ const entries = readdirSync2(dir);
391
+ for (const entry of entries) {
392
+ if (entry.startsWith(".") || entry === "node_modules") continue;
393
+ const fullPath = join3(dir, entry);
394
+ if (!statSync4(fullPath).isDirectory()) continue;
395
+ if (entry === targetName) {
396
+ log?.(`Found candidate: ${fullPath}`);
397
+ candidates.push(fullPath);
398
+ }
399
+ searchAtDepth(fullPath, targetName, currentDepth + 1, maxDepth, candidates, log);
400
+ }
401
+ }
402
+ async function findMissingWorktrees(hubPath, searchDirs, log) {
403
+ const worktrees = await listWorktrees(hubPath);
404
+ const results = [];
405
+ for (const wt of worktrees) {
406
+ if (wt.isBare) continue;
407
+ if (existsSync4(wt.path)) continue;
408
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
409
+ log?.(`Searching for missing worktree [${branch}]...`);
410
+ const result = await searchForWorktree(branch, { searchDirs, maxDepth: 3 }, log);
411
+ results.push(result);
412
+ }
413
+ return results;
414
+ }
415
+ async function repairWorktree(worktreePath, hubPath, log) {
416
+ const bareDir = join3(hubPath, ".bare");
417
+ const adminBase = join3(bareDir, "worktrees");
418
+ const gitFile = join3(worktreePath, ".git");
419
+ const content = readFileSync3(gitFile, "utf8");
420
+ const match = content.match(/^gitdir:\s*(.+)/m);
421
+ if (!match) {
422
+ throw new Error(`Cannot parse .git file in ${worktreePath}`);
423
+ }
424
+ const oldAdminPath = match[1].trim();
425
+ const adminName = basename2(oldAdminPath);
426
+ const newAdminPath = join3(adminBase, adminName);
427
+ log?.(`Repairing ${worktreePath} -> ${newAdminPath}`);
428
+ writeFileSync2(gitFile, `gitdir: ${newAdminPath}
429
+ `);
430
+ const gitdirFile = join3(newAdminPath, "gitdir");
431
+ if (existsSync4(gitdirFile)) {
432
+ writeFileSync2(gitdirFile, `${worktreePath}/.git
433
+ `);
261
434
  }
262
435
  }
263
436
 
437
+ // src/version.ts
438
+ import { readFileSync as readFileSync4 } from "node:fs";
439
+ import { join as join4 } from "node:path";
440
+ function readVersion() {
441
+ const pkgPath = join4(import.meta.dirname, "..", "package.json");
442
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
443
+ return pkg.version;
444
+ }
445
+ var VERSION = readVersion();
446
+ function getVersion(withPrefix = false) {
447
+ return withPrefix ? `v${VERSION}` : VERSION;
448
+ }
449
+
264
450
  // src/cli.ts
265
451
  var GREEN = "\x1B[32m";
266
452
  var YELLOW = "\x1B[33m";
453
+ var RED = "\x1B[31m";
267
454
  var BOLD = "\x1B[1m";
268
455
  var RESET = "\x1B[0m";
269
456
  function green(s) {
@@ -272,7 +459,10 @@ function green(s) {
272
459
  function yellow(s) {
273
460
  return `${YELLOW}${s}${RESET}`;
274
461
  }
275
- function bold(s) {
462
+ function red(s) {
463
+ return `${RED}${s}${RESET}`;
464
+ }
465
+ function bold2(s) {
276
466
  return `${BOLD}${s}${RESET}`;
277
467
  }
278
468
  function prompt() {
@@ -281,48 +471,303 @@ function prompt() {
281
471
  process.stdin.once("data", (chunk) => res(chunk.toString().trim()));
282
472
  });
283
473
  }
474
+ function isGitPointerValid(worktreePath, hubPath) {
475
+ const gitFile = join5(worktreePath, ".git");
476
+ if (!existsSync5(gitFile) || !statSync5(gitFile).isFile()) {
477
+ return false;
478
+ }
479
+ const content = readFileSync5(gitFile, "utf8");
480
+ const match = content.match(/^gitdir:\s*(.+)$/m);
481
+ if (!match) {
482
+ return false;
483
+ }
484
+ const gitdir = match[1].trim();
485
+ const bareDir = join5(hubPath, ".bare");
486
+ return gitdir.includes(bareDir) && gitdir.includes("/worktrees/");
487
+ }
488
+ async function runValidationMode(hubPath) {
489
+ const worktrees = await listWorktrees(hubPath);
490
+ const validated = [];
491
+ for (const wt of worktrees) {
492
+ if (wt.isBare) continue;
493
+ let status;
494
+ if (!existsSync5(wt.path)) {
495
+ status = "missing";
496
+ } else if (!isGitPointerValid(wt.path, hubPath)) {
497
+ status = "stale";
498
+ } else {
499
+ status = "healthy";
500
+ }
501
+ validated.push({ worktree: wt, status });
502
+ }
503
+ console.log();
504
+ console.log(bold2("Validation Report"));
505
+ console.log(`Hub: ${hubPath}`);
506
+ console.log();
507
+ if (validated.length === 0) {
508
+ console.log("No worktrees found.");
509
+ return;
510
+ }
511
+ const maxBranchLen = validated.reduce((m, v) => {
512
+ const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
513
+ return Math.max(m, branch.length);
514
+ }, 0);
515
+ const headerBranch = "Branch".padEnd(maxBranchLen);
516
+ const headerStatus = "Status";
517
+ const headerPath = "Path";
518
+ console.log(` ${bold2(headerBranch)} ${bold2(headerStatus.padEnd(7))} ${bold2(headerPath)}`);
519
+ const counts = { healthy: 0, missing: 0, stale: 0 };
520
+ for (const v of validated) {
521
+ const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
522
+ const branchCol = branch.padEnd(maxBranchLen);
523
+ let statusCol;
524
+ if (v.status === "healthy") {
525
+ statusCol = green("healthy");
526
+ counts.healthy++;
527
+ } else if (v.status === "missing") {
528
+ statusCol = red("missing");
529
+ counts.missing++;
530
+ } else {
531
+ statusCol = yellow("stale");
532
+ counts.stale++;
533
+ }
534
+ console.log(` ${branchCol} ${statusCol.padEnd(7 + (statusCol.length - v.status.length))} ${v.worktree.path}`);
535
+ }
536
+ console.log();
537
+ const summaryParts = [];
538
+ if (counts.healthy > 0) summaryParts.push(`${counts.healthy} healthy`);
539
+ if (counts.missing > 0) summaryParts.push(`${counts.missing} missing`);
540
+ if (counts.stale > 0) summaryParts.push(`${counts.stale} stale`);
541
+ console.log(`Summary: ${summaryParts.join(", ")}`);
542
+ const needsRepair = validated.filter((v) => v.status === "missing" || v.status === "stale");
543
+ if (needsRepair.length === 0) {
544
+ return;
545
+ }
546
+ console.log();
547
+ console.log(`${yellow("warn:")} ${needsRepair.length} worktree(s) need repair.`);
548
+ const searchDirs = [dirname3(hubPath)];
549
+ console.log(`${green("==>")} Searching for missing worktrees...`);
550
+ const results = await findMissingWorktrees(
551
+ hubPath,
552
+ searchDirs,
553
+ (msg) => console.log(` ${msg}`)
554
+ );
555
+ const found = results.filter((r) => r.candidates.length > 0);
556
+ const notFound = results.filter((r) => r.candidates.length === 0);
557
+ const multiple = results.filter((r) => r.candidates.length > 1);
558
+ if (notFound.length > 0) {
559
+ console.log(`
560
+ ${yellow("Not found:")}`);
561
+ for (const r of notFound) {
562
+ console.log(` [${r.branch}]`);
563
+ }
564
+ }
565
+ if (found.length === 0) {
566
+ console.log(`
567
+ No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
568
+ return;
569
+ }
570
+ const selections = /* @__PURE__ */ new Map();
571
+ for (const r of multiple) {
572
+ console.log(`
573
+ ${bold2(`[${r.branch}]`)} has multiple candidates:`);
574
+ for (let i = 0; i < r.candidates.length; i++) {
575
+ console.log(` ${i + 1}) ${r.candidates[i]}`);
576
+ }
577
+ process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
578
+ const sel = await prompt();
579
+ const idx = parseInt(sel) - 1;
580
+ if (idx >= 0 && idx < r.candidates.length) {
581
+ selections.set(r.branch, r.candidates[idx]);
582
+ }
583
+ }
584
+ console.log(`
585
+ ${green("Found:")}`);
586
+ const maxFoundBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
587
+ for (const r of found) {
588
+ const path = selections.get(r.branch) ?? r.candidates[0];
589
+ const branchCol = bold2(`[${r.branch}]`).padEnd(maxFoundBranchLen + 2 + BOLD.length + RESET.length);
590
+ console.log(` ${branchCol} ${path}`);
591
+ }
592
+ console.log();
593
+ process.stdout.write("Repair these worktrees? [y/N] ");
594
+ const repairAns = await prompt();
595
+ process.stdin.destroy();
596
+ if (!/^[Yy]$/.test(repairAns)) {
597
+ console.log("Aborted.");
598
+ return;
599
+ }
600
+ console.log();
601
+ for (const r of found) {
602
+ const path = selections.get(r.branch) ?? r.candidates[0];
603
+ await repairWorktree(path, hubPath, (msg) => console.log(`${green("==>")} ${msg}`));
604
+ }
605
+ console.log(`${green("==>")} Repaired ${found.length} worktree(s).`);
606
+ }
284
607
  function usage() {
285
608
  console.log(`Usage: git-worktree-organize <source> [destination]
286
609
 
287
610
  Convert a git repository into the canonical bare-hub worktree layout:
288
611
 
289
- <dest>/.bare/ bare git repo
290
- <dest>/.git plain file: "gitdir: ./.bare"
291
- <dest>/<branch>/ one directory per worktree
612
+ <dest>/.bare/ \u2190 bare git repo
613
+ <dest>/.git \u2190 plain file: "gitdir: ./.bare"
614
+ <dest>/<branch>/ \u2190 one directory per worktree
292
615
 
293
616
  Arguments:
294
617
  source Path to existing git repository
295
618
  destination Target hub directory (default: <parent>/<name>-bare)
296
619
 
297
620
  Options:
298
- -h, --help Show help`);
621
+ -h, --help Show help
622
+ -v, --version Show version`);
299
623
  }
300
624
  async function main() {
301
625
  const args = process.argv.slice(2);
626
+ if (args[0] === "-v" || args[0] === "--version") {
627
+ console.log(`git-worktree-organize ${getVersion(true)}`);
628
+ process.exit(0);
629
+ }
302
630
  if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
303
631
  usage();
304
632
  process.exit(0);
305
633
  }
634
+ console.log(`${bold2("git-worktree-organize")} ${getVersion(true)}
635
+ `);
306
636
  const sourcePath = args[0];
307
637
  const destArg = args[1];
308
638
  const source = resolve3(sourcePath);
309
- const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) : join3(dirname2(source), basename2(source) + "-bare");
639
+ const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) : join5(dirname3(source), basename3(source) + "-bare");
640
+ const config = await detect(source);
641
+ if (config.type === "bare-hub") {
642
+ await runValidationMode(source);
643
+ process.exit(0);
644
+ }
645
+ if (!isPartialMigration(source) && !destArg) {
646
+ const ancestorHub = findHub(dirname3(source));
647
+ if (ancestorHub) {
648
+ console.log(`
649
+ ${yellow("warn:")} ${bold2(source)} is inside an existing hub at ${bold2(ancestorHub)}`);
650
+ console.log(`
651
+ This looks like manually-placed worktrees with stale .git files.`);
652
+ console.log(`Running repair will fix all worktree .git connections in the hub.
653
+ `);
654
+ process.stdout.write(`Repair hub at ${ancestorHub}? [y/N] `);
655
+ const repairAns = await prompt();
656
+ process.stdin.destroy();
657
+ if (!/^[Yy]$/.test(repairAns)) {
658
+ console.log("Aborted.");
659
+ process.exit(0);
660
+ }
661
+ console.log();
662
+ await repairHub(ancestorHub, (msg) => console.log(`${green("==>")} ${msg}`));
663
+ console.log();
664
+ console.log(`${green("==>")} Verifying with git worktree list...`);
665
+ console.log(run("git", ["-C", ancestorHub, "worktree", "list"]).stdout);
666
+ console.log(`Done! Hub: ${ancestorHub}`);
667
+ process.exit(0);
668
+ }
669
+ }
310
670
  if (isPartialMigration(dest)) {
311
671
  const hubWorktrees = await listWorktrees(dest);
312
- const pending = hubWorktrees.filter((wt) => !wt.isBare && !wt.path.startsWith(dest + "/"));
672
+ const pending = hubWorktrees.filter((wt) => {
673
+ if (wt.isBare) return false;
674
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
675
+ return wt.path !== join5(dest, sanitizeBranch(branch));
676
+ });
677
+ const missing2 = hubWorktrees.filter((wt) => {
678
+ if (wt.isBare) return false;
679
+ return !existsSync5(wt.path);
680
+ });
313
681
  console.log(`
314
- ${yellow("warn:")} Partial migration detected at ${bold(dest)}`);
682
+ ${yellow("warn:")} Partial migration detected at ${bold2(dest)}`);
683
+ if (missing2.length > 0) {
684
+ console.log(`
685
+ The following worktree paths no longer exist:`);
686
+ for (const wt of missing2) {
687
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
688
+ console.log(` [${branch}] ${wt.path}`);
689
+ }
690
+ console.log();
691
+ const searchDirs = [dirname3(dest)];
692
+ console.log(`${green("==>")} Searching for missing worktrees...`);
693
+ const results = await findMissingWorktrees(
694
+ dest,
695
+ searchDirs,
696
+ (msg) => console.log(` ${msg}`)
697
+ );
698
+ const found = results.filter((r) => r.candidates.length > 0);
699
+ const notFound = results.filter((r) => r.candidates.length === 0);
700
+ const multiple = results.filter((r) => r.candidates.length > 1);
701
+ if (notFound.length > 0) {
702
+ console.log(`
703
+ ${yellow("Not found:")}`);
704
+ for (const r of notFound) {
705
+ console.log(` [${r.branch}]`);
706
+ }
707
+ }
708
+ if (found.length === 0) {
709
+ console.log(`
710
+ No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
711
+ process.exit(0);
712
+ }
713
+ const selections = /* @__PURE__ */ new Map();
714
+ for (const r of multiple) {
715
+ console.log(`
716
+ ${bold2(`[${r.branch}]`)} has multiple candidates:`);
717
+ for (let i = 0; i < r.candidates.length; i++) {
718
+ console.log(` ${i + 1}) ${r.candidates[i]}`);
719
+ }
720
+ process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
721
+ const sel = await prompt();
722
+ const idx = parseInt(sel) - 1;
723
+ if (idx >= 0 && idx < r.candidates.length) {
724
+ selections.set(r.branch, r.candidates[idx]);
725
+ }
726
+ }
727
+ console.log(`
728
+ ${green("Found:")}`);
729
+ const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
730
+ for (const r of found) {
731
+ const path = selections.get(r.branch) ?? r.candidates[0];
732
+ const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
733
+ console.log(` ${branchCol} ${path}`);
734
+ }
735
+ console.log();
736
+ process.stdout.write("Repair these worktrees? [y/N] ");
737
+ const repairAns = await prompt();
738
+ if (!/^[Yy]$/.test(repairAns)) {
739
+ console.log("Aborted.");
740
+ process.stdin.destroy();
741
+ process.exit(0);
742
+ }
743
+ console.log();
744
+ for (const r of found) {
745
+ const path = selections.get(r.branch) ?? r.candidates[0];
746
+ await repairWorktree(path, dest, (msg) => console.log(`${green("==>")} ${msg}`));
747
+ }
748
+ console.log(`${green("==>")} Repaired ${found.length} worktree(s).
749
+ `);
750
+ const refreshed = await listWorktrees(dest);
751
+ hubWorktrees.length = 0;
752
+ hubWorktrees.push(...refreshed);
753
+ pending.length = 0;
754
+ pending.push(...hubWorktrees.filter((wt) => {
755
+ if (wt.isBare) return false;
756
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
757
+ return wt.path !== join5(dest, sanitizeBranch(branch));
758
+ }));
759
+ }
315
760
  if (pending.length === 0) {
316
- console.log("All worktrees are already in place — nothing to resume.");
761
+ await runValidationMode(dest);
317
762
  process.exit(0);
318
763
  }
319
764
  console.log(`
320
765
  Worktrees still to move:`);
321
766
  for (const wt of pending) {
322
767
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
323
- const exists = existsSync3(wt.path);
768
+ const exists = existsSync5(wt.path);
324
769
  const status = exists ? "" : ` ${yellow("(path missing)")}`;
325
- console.log(` [${bold(branch)}] ${wt.path} ${join3(dest, sanitizeBranch(branch))}${status}`);
770
+ console.log(` [${bold2(branch)}] ${wt.path} \u2192 ${join5(dest, sanitizeBranch(branch))}${status}`);
326
771
  }
327
772
  console.log();
328
773
  process.stdout.write("Resume migration? [y/N] ");
@@ -332,7 +777,11 @@ Worktrees still to move:`);
332
777
  process.exit(0);
333
778
  }
334
779
  console.log();
335
- const hubPath2 = await resumeMigrate(dest, (msg) => console.log(`${green("==>")} ${msg}`));
780
+ const hubPath2 = await resumeMigrate(
781
+ dest,
782
+ (msg) => console.log(`${green("==>")} ${msg}`),
783
+ (msg) => console.log(`${yellow("warn:")} ${msg}`)
784
+ );
336
785
  console.log();
337
786
  console.log(`${green("==>")} Verifying with git worktree list...`);
338
787
  console.log(run("git", ["-C", hubPath2, "worktree", "list"]).stdout);
@@ -342,30 +791,132 @@ Worktrees still to move:`);
342
791
  console.log(`
343
792
  ${green("==>")} Reading worktrees from ${source}
344
793
  `);
345
- const config = await detect(source);
346
794
  const allWorktrees = await listWorktrees(source);
795
+ if (config.type === "standard" && !destArg) {
796
+ const repoName = basename3(source);
797
+ let mainBranch2 = "main";
798
+ if (allWorktrees.length > 0) {
799
+ mainBranch2 = allWorktrees[0].branch ?? "main";
800
+ }
801
+ console.log("Worktrees to migrate:");
802
+ const maxNameLen2 = allWorktrees.reduce((m, wt) => {
803
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
804
+ return Math.max(m, branch.length);
805
+ }, 0);
806
+ for (const wt of allWorktrees) {
807
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
808
+ const safe = sanitizeBranch(branch);
809
+ const isMain = branch === mainBranch2;
810
+ const destDir = join5(source, safe);
811
+ const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen2 + 2 + BOLD.length + RESET.length);
812
+ const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
813
+ console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
814
+ }
815
+ console.log();
816
+ console.log(`Hub destination: ${bold2(source)} (bare repo at ${source}/.bare)`);
817
+ console.log();
818
+ console.log(`No destination specified. Migrate in-place?`);
819
+ console.log(`This will rename '${bold2(repoName)}' to '${bold2(repoName + ".old")}' and create the hub here.`);
820
+ console.log();
821
+ process.stdout.write("Proceed with in-place migration? [y/N] ");
822
+ const inPlaceAns = await prompt();
823
+ process.stdin.destroy();
824
+ if (!/^[Yy]$/.test(inPlaceAns)) {
825
+ console.log("Aborted.");
826
+ console.log("Tip: Specify a destination directory to migrate to a new location.");
827
+ process.exit(0);
828
+ }
829
+ console.log();
830
+ const hubPath2 = await migrateInPlace(
831
+ source,
832
+ (msg) => console.log(`${green("==>")} ${msg}`),
833
+ (msg) => console.log(`${yellow("warn:")} ${msg}`)
834
+ );
835
+ console.log(`${green("==>")} Verifying with git worktree list...`);
836
+ const verifyOutput2 = run("git", ["-C", hubPath2, "worktree", "list"]).stdout;
837
+ console.log(verifyOutput2);
838
+ console.log(`Done! Hub: ${hubPath2}`);
839
+ console.log(`Backup: ${source}.old`);
840
+ console.log();
841
+ console.log("Useful commands:");
842
+ console.log(` git -C ${hubPath2} worktree list`);
843
+ console.log(` git -C ${hubPath2}/main log --oneline -5`);
844
+ process.exit(0);
845
+ }
347
846
  const missing = allWorktrees.filter((wt) => {
348
- if (wt.isBare)
349
- return false;
350
- const actual = resolveWorktreePath(wt.path, dest, dirname2(source));
351
- return !existsSync3(actual);
847
+ if (wt.isBare) return false;
848
+ const actual = resolveWorktreePath(wt.path, dest, dirname3(source));
849
+ return !existsSync5(actual);
352
850
  });
353
851
  if (missing.length > 0) {
354
- console.log(`${yellow("warn:")} The following worktree paths no longer exist:`);
852
+ console.log(`
853
+ ${yellow("warn:")} The following worktree paths no longer exist:`);
355
854
  for (const wt of missing) {
356
855
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
357
856
  console.log(` [${branch}] ${wt.path}`);
358
857
  }
359
858
  console.log();
360
- process.stdout.write("Remove them with `git worktree prune` and continue? [y/N] ");
361
- const pruneAns = await prompt();
362
- if (!/^[Yy]$/.test(pruneAns)) {
859
+ const searchDirs = [dirname3(source)];
860
+ if (dest !== source) {
861
+ searchDirs.push(dest);
862
+ }
863
+ console.log(`${green("==>")} Searching for missing worktrees...`);
864
+ const results = await findMissingWorktrees(
865
+ source,
866
+ searchDirs,
867
+ (msg) => console.log(` ${msg}`)
868
+ );
869
+ const found = results.filter((r) => r.candidates.length > 0);
870
+ const notFound = results.filter((r) => r.candidates.length === 0);
871
+ const multiple = results.filter((r) => r.candidates.length > 1);
872
+ if (notFound.length > 0) {
873
+ console.log(`
874
+ ${yellow("Not found:")}`);
875
+ for (const r of notFound) {
876
+ console.log(` [${r.branch}]`);
877
+ }
878
+ }
879
+ if (found.length === 0) {
880
+ console.log(`
881
+ No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
882
+ process.exit(0);
883
+ }
884
+ const selections = /* @__PURE__ */ new Map();
885
+ for (const r of multiple) {
886
+ console.log(`
887
+ ${bold2(`[${r.branch}]`)} has multiple candidates:`);
888
+ for (let i = 0; i < r.candidates.length; i++) {
889
+ console.log(` ${i + 1}) ${r.candidates[i]}`);
890
+ }
891
+ process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
892
+ const sel = await prompt();
893
+ const idx = parseInt(sel) - 1;
894
+ if (idx >= 0 && idx < r.candidates.length) {
895
+ selections.set(r.branch, r.candidates[idx]);
896
+ }
897
+ }
898
+ console.log(`
899
+ ${green("Found:")}`);
900
+ const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
901
+ for (const r of found) {
902
+ const path = selections.get(r.branch) ?? r.candidates[0];
903
+ const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
904
+ console.log(` ${branchCol} ${path}`);
905
+ }
906
+ console.log();
907
+ process.stdout.write("Repair these worktrees? [y/N] ");
908
+ const repairAns = await prompt();
909
+ if (!/^[Yy]$/.test(repairAns)) {
363
910
  console.log("Aborted.");
364
911
  process.stdin.destroy();
365
912
  process.exit(0);
366
913
  }
367
- run("git", ["-C", source, "worktree", "prune"]);
368
- console.log(`${green("==>")} Pruned stale worktrees.
914
+ console.log();
915
+ for (const r of found) {
916
+ const path = selections.get(r.branch) ?? r.candidates[0];
917
+ await repairWorktree(path, source, (msg) => console.log(`${green("==>")} ${msg}`));
918
+ }
919
+ console.log(`${green("==>")} Repaired ${found.length} worktree(s).
369
920
  `);
370
921
  const refreshed = await listWorktrees(source);
371
922
  allWorktrees.length = 0;
@@ -381,19 +932,19 @@ ${green("==>")} Reading worktrees from ${source}
381
932
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
382
933
  const safe = sanitizeBranch(branch);
383
934
  const isMain = branch === mainBranch;
384
- const destDir = join3(dest, safe);
935
+ const destDir = join5(dest, safe);
385
936
  return { branch, isMain, destDir };
386
937
  });
387
938
  const maxNameLen = entries.reduce((m, e) => Math.max(m, e.branch.length), 0);
388
939
  for (const { branch, isMain, destDir } of entries) {
389
940
  const tag = isMain ? yellow("[main]") : "";
390
941
  const tagPad = isMain ? ` (labeled ${yellow("[main]")})` : "";
391
- const nameCol = bold(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
942
+ const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
392
943
  const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
393
- console.log(` ${nameCol}${annotation} ${destDir}`);
944
+ console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
394
945
  }
395
946
  console.log();
396
- console.log(`Hub destination: ${bold(dest)} (bare repo at ${dest}/.bare)`);
947
+ console.log(`Hub destination: ${bold2(dest)} (bare repo at ${dest}/.bare)`);
397
948
  console.log();
398
949
  process.stdout.write("Proceed? [y/N] ");
399
950
  const ans = await prompt();
@@ -403,7 +954,12 @@ ${green("==>")} Reading worktrees from ${source}
403
954
  process.exit(0);
404
955
  }
405
956
  console.log();
406
- const hubPath = await migrate(config, { source: sourcePath, dest: destArg ?? "" });
957
+ const hubPath = await migrate(
958
+ config,
959
+ { source: sourcePath, dest: destArg ?? "" },
960
+ (msg) => console.log(`${green("==>")} ${msg}`),
961
+ (msg) => console.log(`${yellow("warn:")} ${msg}`)
962
+ );
407
963
  console.log(`${green("==>")} Verifying with git worktree list...`);
408
964
  const verifyOutput = run("git", ["-C", hubPath, "worktree", "list"]).stdout;
409
965
  console.log(verifyOutput);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-worktree-organize",
3
- "version": "1.0.12",
3
+ "version": "1.1.0",
4
4
  "description": "Convert any git repo into the canonical bare-hub worktree layout",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,14 +10,15 @@
10
10
  "dist/"
11
11
  ],
12
12
  "scripts": {
13
- "build": "bun build src/cli.ts --outfile dist/cli.js --target node",
14
- "prepublishOnly": "bun run build",
15
- "test": "bun test",
13
+ "build": "npx esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js",
14
+ "prepublishOnly": "npm run build",
15
+ "test": "vitest run",
16
16
  "test:watch": "vitest",
17
+ "test:coverage": "vitest run --coverage",
17
18
  "release": "op run -- npm publish"
18
19
  },
19
20
  "devDependencies": {
20
- "@types/bun": "latest",
21
+ "@types/node": "^20.0.0",
21
22
  "typescript": "^5.0.0",
22
23
  "vitest": "^2.0.0"
23
24
  },