git-worktree-organize 1.0.13 → 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 +585 -110
  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,131 +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, readdirSync } 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();
160
161
  }
161
162
  function findHub(startPath) {
162
163
  let current = resolve2(startPath);
163
164
  while (true) {
164
- if (isPartialMigration(current))
165
- return current;
166
- const parent = dirname(current);
167
- if (parent === current)
168
- return null;
165
+ if (isPartialMigration(current)) return current;
166
+ const parent = dirname2(current);
167
+ if (parent === current) return null;
169
168
  current = parent;
170
169
  }
171
170
  }
172
171
  async function repairHub(dest, log = console.log) {
173
172
  const adminBase = join2(dest, ".bare", "worktrees");
174
- if (!existsSync2(adminBase))
175
- return;
173
+ if (!existsSync3(adminBase)) return;
176
174
  for (const adminName of readdirSync(adminBase)) {
177
175
  const adminDir = join2(adminBase, adminName);
178
- if (!statSync2(adminDir).isDirectory())
179
- continue;
176
+ if (!statSync3(adminDir).isDirectory()) continue;
180
177
  const gitdirFile = join2(adminDir, "gitdir");
181
- if (!existsSync2(gitdirFile))
182
- continue;
178
+ if (!existsSync3(gitdirFile)) continue;
183
179
  const registeredGitFile = readFileSync2(gitdirFile, "utf8").trim();
184
- const worktreePath = dirname(registeredGitFile);
185
- if (!worktreePath.startsWith(dest + "/"))
186
- continue;
187
- if (!existsSync2(registeredGitFile) || !statSync2(registeredGitFile).isFile())
188
- continue;
180
+ const worktreePath = dirname2(registeredGitFile);
181
+ if (!worktreePath.startsWith(dest + "/")) continue;
182
+ if (!existsSync3(registeredGitFile) || !statSync3(registeredGitFile).isFile()) continue;
189
183
  const content = readFileSync2(registeredGitFile, "utf8");
190
184
  const match = content.match(/^gitdir:\s*(.+)/m);
191
- if (!match)
192
- continue;
193
- if (match[1].trim() === adminDir)
194
- continue;
185
+ if (!match) continue;
186
+ if (match[1].trim() === adminDir) continue;
195
187
  log(`Repairing .git for [${basename(worktreePath)}]`);
196
188
  writeFileSync(registeredGitFile, `gitdir: ${adminDir}
197
189
  `);
198
190
  }
199
191
  }
200
- async function resumeMigrate(dest, log = console.log) {
192
+ async function resumeMigrate(dest, log = console.log, warn) {
201
193
  const destBare = join2(dest, ".bare");
202
194
  const hubWorktrees = await listWorktrees(dest);
203
195
  const pending = hubWorktrees.filter((wt) => {
204
- if (wt.isBare)
205
- return false;
196
+ if (wt.isBare) return false;
206
197
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
207
198
  return wt.path !== join2(dest, sanitizeBranch(branch));
208
199
  });
209
200
  if (pending.length === 0) {
210
- log("Nothing to resume all worktrees are already in place.");
201
+ log("Nothing to resume \u2014 all worktrees are already in place.");
211
202
  } else {
212
203
  for (const wt of pending) {
213
204
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
214
205
  const expectedPath = join2(dest, sanitizeBranch(branch));
215
206
  let wtPath = wt.path;
216
- if (!existsSync2(wtPath)) {
217
- if (existsSync2(expectedPath)) {
207
+ if (!existsSync3(wtPath)) {
208
+ if (existsSync3(expectedPath)) {
218
209
  wtPath = expectedPath;
219
210
  } else {
220
- log(`warn: Skipping [${branch}] path no longer exists: ${wt.path}`);
211
+ log(`warn: Skipping [${branch}] \u2014 path no longer exists: ${wt.path}`);
221
212
  continue;
222
213
  }
223
214
  }
224
- log(`Moving [${branch}] ${expectedPath}`);
225
- await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare);
215
+ log(`Moving [${branch}] \u2192 ${expectedPath}`);
216
+ await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare, log, warn);
226
217
  }
227
218
  }
228
219
  await repairHub(dest, log);
229
220
  return dest;
230
221
  }
231
- async function migrate(config, options) {
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.`);
227
+ }
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) {
237
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
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}'`);
241
+ }
242
+ seen.set(safe, branch);
243
+ }
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`;
286
+ }
287
+ async function migrate(config, options, log, warn) {
232
288
  const source = resolve2(options.source);
233
- 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");
234
290
  const destBare = join2(dest, ".bare");
235
- if (existsSync2(destBare)) {
291
+ if (existsSync3(destBare)) {
236
292
  throw new Error(`'${destBare}' already exists`);
237
293
  }
238
294
  const allWorktrees = await listWorktrees(source);
239
295
  const worktrees = allWorktrees.filter((wt) => !wt.isBare);
240
- const seen = new Map;
296
+ const seen = /* @__PURE__ */ new Map();
241
297
  for (const wt of worktrees) {
242
298
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
243
299
  const safe = sanitizeBranch(branch);
@@ -250,53 +306,50 @@ async function migrate(config, options) {
250
306
  run("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
251
307
  await setGitConfig("core.bare", "true", { gitdir: destBare });
252
308
  await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
253
- writeFileSync(join2(dest, ".git"), `gitdir: ./.bare
254
- `);
255
- const sourceParent = dirname(source);
256
- 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
+ );
257
314
  if (config.type === "standard") {
258
315
  const mainBranch = worktrees[0].branch;
259
316
  const mainSafe = sanitizeBranch(mainBranch);
260
317
  const mainDest = join2(dest, mainSafe);
261
318
  const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
262
319
  run("rm", ["-rf", join2(source, ".git")]);
263
- await moveDir(source, mainDest);
320
+ await move(source, mainDest);
264
321
  const mainAdminDir = join2(destBare, "worktrees", mainSafe);
265
322
  mkdirSync(mainAdminDir, { recursive: true });
266
- writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + `/.git
267
- `);
268
- writeFileSync(join2(mainAdminDir, "commondir"), `../../
269
- `);
270
- const headToWrite = mainHeadContent.endsWith(`
271
- `) ? mainHeadContent : mainHeadContent + `
272
- `;
323
+ writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
324
+ writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
325
+ const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
273
326
  writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
274
327
  const bareIndex = join2(destBare, "index");
275
- if (existsSync2(bareIndex)) {
276
- renameSync(bareIndex, join2(mainAdminDir, "index"));
328
+ if (existsSync3(bareIndex)) {
329
+ renameSync2(bareIndex, join2(mainAdminDir, "index"));
277
330
  }
278
331
  writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
279
332
  `);
280
- for (let i = 1;i < worktreesResolved.length; i++) {
281
- await processLinkedWorktree(worktreesResolved[i], dest, destBare);
333
+ for (let i = 1; i < worktreesResolved.length; i++) {
334
+ await processLinkedWorktree(worktreesResolved[i], dest, destBare, log, warn);
282
335
  }
283
336
  } else {
284
337
  for (const wt of worktreesResolved) {
285
- await processLinkedWorktree(wt, dest, destBare);
338
+ await processLinkedWorktree(wt, dest, destBare, log, warn);
286
339
  }
287
340
  }
288
341
  return dest;
289
342
  }
290
- async function processLinkedWorktree(wt, dest, destBare) {
343
+ async function processLinkedWorktree(wt, dest, destBare, log, warn) {
291
344
  const wtSrc = wt.path;
292
345
  const wtBranch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
293
346
  const wtSafe = sanitizeBranch(wtBranch);
294
347
  const wtDest = join2(dest, wtSafe);
295
- await moveDir(wtSrc, wtDest);
348
+ await move(wtSrc, wtDest);
296
349
  const gitFileContent = readFileSync2(join2(wtDest, ".git"), "utf8");
297
350
  const match = gitFileContent.match(/^gitdir:\s*(.+)/m);
298
351
  if (!match) {
299
- console.warn(`Could not parse .git file in ${wtDest}`);
352
+ warn?.(`Could not parse .git file in ${wtDest}`);
300
353
  return;
301
354
  }
302
355
  const oldPath = match[1].trim();
@@ -304,17 +357,100 @@ async function processLinkedWorktree(wt, dest, destBare) {
304
357
  const newAdmin = join2(destBare, "worktrees", adminName);
305
358
  writeFileSync(join2(wtDest, ".git"), `gitdir: ${newAdmin}
306
359
  `);
307
- if (existsSync2(newAdmin)) {
308
- writeFileSync(join2(newAdmin, "gitdir"), wtDest + `/.git
309
- `);
360
+ if (existsSync3(newAdmin)) {
361
+ writeFileSync(join2(newAdmin, "gitdir"), wtDest + "/.git\n");
310
362
  } else {
311
- 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
+ `);
312
434
  }
313
435
  }
314
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
+
315
450
  // src/cli.ts
316
451
  var GREEN = "\x1B[32m";
317
452
  var YELLOW = "\x1B[33m";
453
+ var RED = "\x1B[31m";
318
454
  var BOLD = "\x1B[1m";
319
455
  var RESET = "\x1B[0m";
320
456
  function green(s) {
@@ -323,7 +459,10 @@ function green(s) {
323
459
  function yellow(s) {
324
460
  return `${YELLOW}${s}${RESET}`;
325
461
  }
326
- function bold(s) {
462
+ function red(s) {
463
+ return `${RED}${s}${RESET}`;
464
+ }
465
+ function bold2(s) {
327
466
  return `${BOLD}${s}${RESET}`;
328
467
  }
329
468
  function prompt() {
@@ -332,37 +471,182 @@ function prompt() {
332
471
  process.stdin.once("data", (chunk) => res(chunk.toString().trim()));
333
472
  });
334
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
+ }
335
607
  function usage() {
336
608
  console.log(`Usage: git-worktree-organize <source> [destination]
337
609
 
338
610
  Convert a git repository into the canonical bare-hub worktree layout:
339
611
 
340
- <dest>/.bare/ bare git repo
341
- <dest>/.git plain file: "gitdir: ./.bare"
342
- <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
343
615
 
344
616
  Arguments:
345
617
  source Path to existing git repository
346
618
  destination Target hub directory (default: <parent>/<name>-bare)
347
619
 
348
620
  Options:
349
- -h, --help Show help`);
621
+ -h, --help Show help
622
+ -v, --version Show version`);
350
623
  }
351
624
  async function main() {
352
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
+ }
353
630
  if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
354
631
  usage();
355
632
  process.exit(0);
356
633
  }
634
+ console.log(`${bold2("git-worktree-organize")} ${getVersion(true)}
635
+ `);
357
636
  const sourcePath = args[0];
358
637
  const destArg = args[1];
359
638
  const source = resolve3(sourcePath);
360
- 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
+ }
361
645
  if (!isPartialMigration(source) && !destArg) {
362
- const ancestorHub = findHub(dirname2(source));
646
+ const ancestorHub = findHub(dirname3(source));
363
647
  if (ancestorHub) {
364
648
  console.log(`
365
- ${yellow("warn:")} ${bold(source)} is inside an existing hub at ${bold(ancestorHub)}`);
649
+ ${yellow("warn:")} ${bold2(source)} is inside an existing hub at ${bold2(ancestorHub)}`);
366
650
  console.log(`
367
651
  This looks like manually-placed worktrees with stale .git files.`);
368
652
  console.log(`Running repair will fix all worktree .git connections in the hub.
@@ -386,24 +670,104 @@ This looks like manually-placed worktrees with stale .git files.`);
386
670
  if (isPartialMigration(dest)) {
387
671
  const hubWorktrees = await listWorktrees(dest);
388
672
  const pending = hubWorktrees.filter((wt) => {
389
- if (wt.isBare)
390
- return false;
673
+ if (wt.isBare) return false;
391
674
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
392
- return wt.path !== join3(dest, sanitizeBranch(branch));
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);
393
680
  });
394
681
  console.log(`
395
- ${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
+ }
396
760
  if (pending.length === 0) {
397
- console.log("All worktrees are already in place — nothing to resume.");
761
+ await runValidationMode(dest);
398
762
  process.exit(0);
399
763
  }
400
764
  console.log(`
401
765
  Worktrees still to move:`);
402
766
  for (const wt of pending) {
403
767
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
404
- const exists = existsSync3(wt.path);
768
+ const exists = existsSync5(wt.path);
405
769
  const status = exists ? "" : ` ${yellow("(path missing)")}`;
406
- console.log(` [${bold(branch)}] ${wt.path} ${join3(dest, sanitizeBranch(branch))}${status}`);
770
+ console.log(` [${bold2(branch)}] ${wt.path} \u2192 ${join5(dest, sanitizeBranch(branch))}${status}`);
407
771
  }
408
772
  console.log();
409
773
  process.stdout.write("Resume migration? [y/N] ");
@@ -413,7 +777,11 @@ Worktrees still to move:`);
413
777
  process.exit(0);
414
778
  }
415
779
  console.log();
416
- 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
+ );
417
785
  console.log();
418
786
  console.log(`${green("==>")} Verifying with git worktree list...`);
419
787
  console.log(run("git", ["-C", hubPath2, "worktree", "list"]).stdout);
@@ -423,30 +791,132 @@ Worktrees still to move:`);
423
791
  console.log(`
424
792
  ${green("==>")} Reading worktrees from ${source}
425
793
  `);
426
- const config = await detect(source);
427
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
+ }
428
846
  const missing = allWorktrees.filter((wt) => {
429
- if (wt.isBare)
430
- return false;
431
- const actual = resolveWorktreePath(wt.path, dest, dirname2(source));
432
- return !existsSync3(actual);
847
+ if (wt.isBare) return false;
848
+ const actual = resolveWorktreePath(wt.path, dest, dirname3(source));
849
+ return !existsSync5(actual);
433
850
  });
434
851
  if (missing.length > 0) {
435
- console.log(`${yellow("warn:")} The following worktree paths no longer exist:`);
852
+ console.log(`
853
+ ${yellow("warn:")} The following worktree paths no longer exist:`);
436
854
  for (const wt of missing) {
437
855
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
438
856
  console.log(` [${branch}] ${wt.path}`);
439
857
  }
440
858
  console.log();
441
- process.stdout.write("Remove them with `git worktree prune` and continue? [y/N] ");
442
- const pruneAns = await prompt();
443
- 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)) {
444
910
  console.log("Aborted.");
445
911
  process.stdin.destroy();
446
912
  process.exit(0);
447
913
  }
448
- run("git", ["-C", source, "worktree", "prune"]);
449
- 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).
450
920
  `);
451
921
  const refreshed = await listWorktrees(source);
452
922
  allWorktrees.length = 0;
@@ -462,19 +932,19 @@ ${green("==>")} Reading worktrees from ${source}
462
932
  const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
463
933
  const safe = sanitizeBranch(branch);
464
934
  const isMain = branch === mainBranch;
465
- const destDir = join3(dest, safe);
935
+ const destDir = join5(dest, safe);
466
936
  return { branch, isMain, destDir };
467
937
  });
468
938
  const maxNameLen = entries.reduce((m, e) => Math.max(m, e.branch.length), 0);
469
939
  for (const { branch, isMain, destDir } of entries) {
470
940
  const tag = isMain ? yellow("[main]") : "";
471
941
  const tagPad = isMain ? ` (labeled ${yellow("[main]")})` : "";
472
- const nameCol = bold(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
942
+ const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
473
943
  const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
474
- console.log(` ${nameCol}${annotation} ${destDir}`);
944
+ console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
475
945
  }
476
946
  console.log();
477
- console.log(`Hub destination: ${bold(dest)} (bare repo at ${dest}/.bare)`);
947
+ console.log(`Hub destination: ${bold2(dest)} (bare repo at ${dest}/.bare)`);
478
948
  console.log();
479
949
  process.stdout.write("Proceed? [y/N] ");
480
950
  const ans = await prompt();
@@ -484,7 +954,12 @@ ${green("==>")} Reading worktrees from ${source}
484
954
  process.exit(0);
485
955
  }
486
956
  console.log();
487
- 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
+ );
488
963
  console.log(`${green("==>")} Verifying with git worktree list...`);
489
964
  const verifyOutput = run("git", ["-C", hubPath, "worktree", "list"]).stdout;
490
965
  console.log(verifyOutput);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-worktree-organize",
3
- "version": "1.0.13",
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
  },