git-worktree-organize 1.0.13 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -26
- package/dist/cli.js +767 -110
- package/package.json +6 -5
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
|
|
5
|
-
import { existsSync as
|
|
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, readdirSync as readdirSync3 } 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
|
-
|
|
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,296 @@ async function listWorktrees(repoPath) {
|
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
// src/migrate.ts
|
|
116
|
-
import { mkdirSync, writeFileSync, readFileSync as readFileSync2, existsSync as
|
|
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 } :
|
|
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/
|
|
135
|
-
|
|
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 (
|
|
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
|
|
147
|
+
var AGENTS_MD_TEMPLATE = `# Git Worktree Layout
|
|
148
|
+
|
|
149
|
+
This repository uses **git worktrees** with a bare repository pattern for parallel development across multiple branches.
|
|
150
|
+
|
|
151
|
+
## Directory Structure
|
|
152
|
+
|
|
153
|
+
\`\`\`
|
|
154
|
+
<project>/ # Root project directory
|
|
155
|
+
\u251C\u2500\u2500 .bare/ # Bare git repository (shared git data)
|
|
156
|
+
\u2502 \u251C\u2500\u2500 worktrees/ # Worktree metadata
|
|
157
|
+
\u2502 \u2502 \u251C\u2500\u2500 main/ # Main branch metadata
|
|
158
|
+
\u2502 \u2502 \u2514\u2500\u2500 <branch-name>/ # Per-branch worktree metadata
|
|
159
|
+
\u2502 \u251C\u2500\u2500 objects/ # Git objects (shared)
|
|
160
|
+
\u2502 \u251C\u2500\u2500 refs/ # Git refs (shared)
|
|
161
|
+
\u2502 \u2514\u2500\u2500 config # Repository config
|
|
162
|
+
\u251C\u2500\u2500 .git # Points to .bare (gitdir: ./.bare)
|
|
163
|
+
\u251C\u2500\u2500 main/ # Main branch worktree (primary)
|
|
164
|
+
\u251C\u2500\u2500 <branch-name>/ # Feature/fix branch worktrees
|
|
165
|
+
\u2514\u2500\u2500 *.code-workspace # VS Code multi-root workspace
|
|
166
|
+
\`\`\`
|
|
167
|
+
|
|
168
|
+
## How It Works
|
|
169
|
+
|
|
170
|
+
- **Bare Repository**: \`.bare/\` contains all git data (objects, refs, config)
|
|
171
|
+
- **Worktrees**: Each branch checkout is a separate directory at the root level
|
|
172
|
+
- **Shared History**: All worktrees share the same git history from \`.bare/\`
|
|
173
|
+
|
|
174
|
+
## Working with Worktrees
|
|
175
|
+
|
|
176
|
+
### Create a new worktree
|
|
177
|
+
|
|
178
|
+
\`\`\`bash
|
|
179
|
+
# From any worktree or the root
|
|
180
|
+
git worktree add <branch-name>
|
|
181
|
+
|
|
182
|
+
# Create new branch and worktree
|
|
183
|
+
git worktree add -b <new-branch> <directory-name>
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
### List worktrees
|
|
187
|
+
|
|
188
|
+
\`\`\`bash
|
|
189
|
+
git worktree list
|
|
190
|
+
\`\`\`
|
|
191
|
+
|
|
192
|
+
### Remove a worktree
|
|
193
|
+
|
|
194
|
+
\`\`\`bash
|
|
195
|
+
# After merging/deleting the branch
|
|
196
|
+
git worktree remove <branch-name>
|
|
197
|
+
|
|
198
|
+
# Force removal (if untracked files exist)
|
|
199
|
+
git worktree remove --force <branch-name>
|
|
200
|
+
\`\`\`
|
|
201
|
+
|
|
202
|
+
### Prune stale worktree references
|
|
203
|
+
|
|
204
|
+
\`\`\`bash
|
|
205
|
+
git worktree prune
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
## Conventions
|
|
209
|
+
|
|
210
|
+
1. **Naming**: Worktree directories match the branch name (e.g., \`feature-auth\`, \`fix-login-bug\`)
|
|
211
|
+
2. **Main worktree**: \`main/\` is the primary worktree for the main branch
|
|
212
|
+
3. **Workspace file**: Open \`*.code-workspace\` in VS Code to work with multiple worktrees
|
|
213
|
+
|
|
214
|
+
## Tips
|
|
215
|
+
|
|
216
|
+
- Each worktree has its own \`.git\` file pointing back to \`.bare/\`
|
|
217
|
+
- You can run different branches simultaneously without stashing
|
|
218
|
+
- IDEs can open multiple worktrees as separate folders in one workspace
|
|
219
|
+
- Run \`git worktree prune\` periodically to clean up deleted worktree references
|
|
220
|
+
`;
|
|
221
|
+
function writeAgentsMd(dest) {
|
|
222
|
+
const agentsPath = join2(dest, "AGENTS.md");
|
|
223
|
+
if (!existsSync3(agentsPath)) {
|
|
224
|
+
writeFileSync(agentsPath, AGENTS_MD_TEMPLATE);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function worktreeConfigEnabled(bareDir) {
|
|
228
|
+
const configFile = join2(bareDir, "config");
|
|
229
|
+
if (!existsSync3(configFile)) return false;
|
|
230
|
+
const content = readFileSync2(configFile, "utf8");
|
|
231
|
+
const extensionsMatch = content.match(/\[extensions\]([\s\S]*?)(?=\[|$)/i);
|
|
232
|
+
if (!extensionsMatch) return false;
|
|
233
|
+
return /worktreeconfig\s*=\s*true/i.test(extensionsMatch[1]);
|
|
234
|
+
}
|
|
235
|
+
var WORKTREE_CONFIG_CONTENT = "[core]\n bare = false\n";
|
|
236
|
+
function ensureWorktreeConfig(adminDir, bareDir, log) {
|
|
237
|
+
if (!worktreeConfigEnabled(bareDir)) return;
|
|
238
|
+
const configWtFile = join2(adminDir, "config.worktree");
|
|
239
|
+
if (!existsSync3(configWtFile) || readFileSync2(configWtFile, "utf8") !== WORKTREE_CONFIG_CONTENT) {
|
|
240
|
+
log?.(`Writing config.worktree for [${basename(adminDir)}]`);
|
|
241
|
+
writeFileSync(configWtFile, WORKTREE_CONFIG_CONTENT);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
144
244
|
function sanitizeBranch(branch) {
|
|
145
245
|
return branch.replace(/\//g, "-");
|
|
146
246
|
}
|
|
147
247
|
function resolveWorktreePath(worktreePath, dest, sourceParent) {
|
|
148
|
-
if (
|
|
149
|
-
return worktreePath;
|
|
248
|
+
if (existsSync3(worktreePath)) return worktreePath;
|
|
150
249
|
if (worktreePath.startsWith(dest + "/")) {
|
|
151
250
|
const remapped = sourceParent + worktreePath.slice(dest.length);
|
|
152
|
-
if (
|
|
153
|
-
return remapped;
|
|
251
|
+
if (existsSync3(remapped)) return remapped;
|
|
154
252
|
}
|
|
155
253
|
return worktreePath;
|
|
156
254
|
}
|
|
157
255
|
function isPartialMigration(dest) {
|
|
158
256
|
const gitFile = join2(dest, ".git");
|
|
159
|
-
return
|
|
257
|
+
return existsSync3(join2(dest, ".bare")) && existsSync3(gitFile) && statSync3(gitFile).isFile();
|
|
160
258
|
}
|
|
161
259
|
function findHub(startPath) {
|
|
162
260
|
let current = resolve2(startPath);
|
|
163
261
|
while (true) {
|
|
164
|
-
if (isPartialMigration(current))
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (parent === current)
|
|
168
|
-
return null;
|
|
262
|
+
if (isPartialMigration(current)) return current;
|
|
263
|
+
const parent = dirname2(current);
|
|
264
|
+
if (parent === current) return null;
|
|
169
265
|
current = parent;
|
|
170
266
|
}
|
|
171
267
|
}
|
|
172
268
|
async function repairHub(dest, log = console.log) {
|
|
173
269
|
const adminBase = join2(dest, ".bare", "worktrees");
|
|
174
|
-
if (!
|
|
175
|
-
return;
|
|
270
|
+
if (!existsSync3(adminBase)) return;
|
|
176
271
|
for (const adminName of readdirSync(adminBase)) {
|
|
177
272
|
const adminDir = join2(adminBase, adminName);
|
|
178
|
-
if (!
|
|
179
|
-
continue;
|
|
273
|
+
if (!statSync3(adminDir).isDirectory()) continue;
|
|
180
274
|
const gitdirFile = join2(adminDir, "gitdir");
|
|
181
|
-
if (!
|
|
182
|
-
continue;
|
|
275
|
+
if (!existsSync3(gitdirFile)) continue;
|
|
183
276
|
const registeredGitFile = readFileSync2(gitdirFile, "utf8").trim();
|
|
184
|
-
const worktreePath =
|
|
185
|
-
if (!worktreePath.startsWith(dest + "/"))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
277
|
+
const worktreePath = dirname2(registeredGitFile);
|
|
278
|
+
if (!worktreePath.startsWith(dest + "/")) continue;
|
|
279
|
+
if (!existsSync3(registeredGitFile) || !statSync3(registeredGitFile).isFile()) continue;
|
|
280
|
+
const commondirFile = join2(adminDir, "commondir");
|
|
281
|
+
const expectedCommondir = "../../\n";
|
|
282
|
+
if (!existsSync3(commondirFile) || readFileSync2(commondirFile, "utf8") !== expectedCommondir) {
|
|
283
|
+
log(`Repairing commondir for [${basename(worktreePath)}]`);
|
|
284
|
+
writeFileSync(commondirFile, expectedCommondir);
|
|
285
|
+
}
|
|
286
|
+
ensureWorktreeConfig(adminDir, join2(dest, ".bare"), log);
|
|
189
287
|
const content = readFileSync2(registeredGitFile, "utf8");
|
|
190
288
|
const match = content.match(/^gitdir:\s*(.+)/m);
|
|
191
|
-
if (!match)
|
|
192
|
-
|
|
193
|
-
if (match[1].trim() === adminDir)
|
|
194
|
-
continue;
|
|
289
|
+
if (!match) continue;
|
|
290
|
+
if (match[1].trim() === adminDir) continue;
|
|
195
291
|
log(`Repairing .git for [${basename(worktreePath)}]`);
|
|
196
292
|
writeFileSync(registeredGitFile, `gitdir: ${adminDir}
|
|
197
293
|
`);
|
|
198
294
|
}
|
|
199
295
|
}
|
|
200
|
-
async function resumeMigrate(dest, log = console.log) {
|
|
296
|
+
async function resumeMigrate(dest, log = console.log, warn) {
|
|
201
297
|
const destBare = join2(dest, ".bare");
|
|
202
298
|
const hubWorktrees = await listWorktrees(dest);
|
|
203
299
|
const pending = hubWorktrees.filter((wt) => {
|
|
204
|
-
if (wt.isBare)
|
|
205
|
-
return false;
|
|
300
|
+
if (wt.isBare) return false;
|
|
206
301
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
207
302
|
return wt.path !== join2(dest, sanitizeBranch(branch));
|
|
208
303
|
});
|
|
209
304
|
if (pending.length === 0) {
|
|
210
|
-
log("Nothing to resume
|
|
305
|
+
log("Nothing to resume \u2014 all worktrees are already in place.");
|
|
211
306
|
} else {
|
|
212
307
|
for (const wt of pending) {
|
|
213
308
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
214
309
|
const expectedPath = join2(dest, sanitizeBranch(branch));
|
|
215
310
|
let wtPath = wt.path;
|
|
216
|
-
if (!
|
|
217
|
-
if (
|
|
311
|
+
if (!existsSync3(wtPath)) {
|
|
312
|
+
if (existsSync3(expectedPath)) {
|
|
218
313
|
wtPath = expectedPath;
|
|
219
314
|
} else {
|
|
220
|
-
log(`warn: Skipping [${branch}]
|
|
315
|
+
log(`warn: Skipping [${branch}] \u2014 path no longer exists: ${wt.path}`);
|
|
221
316
|
continue;
|
|
222
317
|
}
|
|
223
318
|
}
|
|
224
|
-
log(`Moving [${branch}]
|
|
225
|
-
await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare);
|
|
319
|
+
log(`Moving [${branch}] \u2192 ${expectedPath}`);
|
|
320
|
+
await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare, log, warn);
|
|
226
321
|
}
|
|
227
322
|
}
|
|
228
323
|
await repairHub(dest, log);
|
|
229
324
|
return dest;
|
|
230
325
|
}
|
|
231
|
-
async function
|
|
326
|
+
async function migrateInPlace(source, log = console.log, warn) {
|
|
327
|
+
const resolvedSource = resolve2(source);
|
|
328
|
+
const oldPath = resolvedSource + ".old";
|
|
329
|
+
if (existsSync3(oldPath)) {
|
|
330
|
+
throw new Error(`'${oldPath}' already exists. Remove it and try again.`);
|
|
331
|
+
}
|
|
332
|
+
log(`Renaming ${bold(resolvedSource)} to ${bold(oldPath)}`);
|
|
333
|
+
await move(resolvedSource, oldPath);
|
|
334
|
+
const allWorktrees = await listWorktrees(oldPath);
|
|
335
|
+
const worktrees = allWorktrees.filter((wt) => !wt.isBare);
|
|
336
|
+
if (worktrees.length === 0) {
|
|
337
|
+
throw new Error("No worktrees found in source repository");
|
|
338
|
+
}
|
|
339
|
+
const seen = /* @__PURE__ */ new Map();
|
|
340
|
+
for (const wt of worktrees) {
|
|
341
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
342
|
+
const safe = sanitizeBranch(branch);
|
|
343
|
+
if (seen.has(safe)) {
|
|
344
|
+
throw new Error(`branch name collision: '${seen.get(safe)}' and '${branch}' both map to '${safe}'`);
|
|
345
|
+
}
|
|
346
|
+
seen.set(safe, branch);
|
|
347
|
+
}
|
|
348
|
+
const mainBranch = worktrees[0].branch;
|
|
349
|
+
const mainSafe = sanitizeBranch(mainBranch);
|
|
350
|
+
const destBare = join2(resolvedSource, ".bare");
|
|
351
|
+
const mainDest = join2(resolvedSource, mainSafe);
|
|
352
|
+
mkdirSync(destBare, { recursive: true });
|
|
353
|
+
const gitDir = join2(oldPath, ".git");
|
|
354
|
+
for (const entry of readdirSync(gitDir)) {
|
|
355
|
+
const srcPath = join2(gitDir, entry);
|
|
356
|
+
const destPath = join2(destBare, entry);
|
|
357
|
+
if (statSync3(srcPath).isDirectory()) {
|
|
358
|
+
run("cp", ["-a", srcPath + "/.", destPath + "/"]);
|
|
359
|
+
} else {
|
|
360
|
+
run("cp", ["-a", srcPath, destPath]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
await setGitConfig("core.bare", "true", { gitdir: destBare });
|
|
364
|
+
await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
|
|
365
|
+
writeFileSync(join2(resolvedSource, ".git"), "gitdir: ./.bare\n");
|
|
366
|
+
const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
|
|
367
|
+
log(`Creating main worktree at ${bold(mainDest)}`);
|
|
368
|
+
run("cp", ["-a", oldPath + "/.", mainDest + "/"]);
|
|
369
|
+
const mainAdminDir = join2(destBare, "worktrees", mainSafe);
|
|
370
|
+
mkdirSync(mainAdminDir, { recursive: true });
|
|
371
|
+
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
|
|
372
|
+
writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
|
|
373
|
+
ensureWorktreeConfig(mainAdminDir, destBare);
|
|
374
|
+
const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
|
|
375
|
+
writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
|
|
376
|
+
const bareIndex = join2(destBare, "index");
|
|
377
|
+
if (existsSync3(bareIndex)) {
|
|
378
|
+
renameSync2(bareIndex, join2(mainAdminDir, "index"));
|
|
379
|
+
}
|
|
380
|
+
run("rm", ["-rf", join2(mainDest, ".git")]);
|
|
381
|
+
writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
|
|
382
|
+
`);
|
|
383
|
+
for (let i = 1; i < worktrees.length; i++) {
|
|
384
|
+
await processLinkedWorktree(worktrees[i], resolvedSource, destBare, log, warn);
|
|
385
|
+
}
|
|
386
|
+
log(`Original repo backed up at: ${oldPath}`);
|
|
387
|
+
writeAgentsMd(resolvedSource);
|
|
388
|
+
return resolvedSource;
|
|
389
|
+
}
|
|
390
|
+
function bold(s) {
|
|
391
|
+
return `\x1B[1m${s}\x1B[0m`;
|
|
392
|
+
}
|
|
393
|
+
async function migrate(config, options, log, warn) {
|
|
232
394
|
const source = resolve2(options.source);
|
|
233
|
-
const dest = options.dest ? resolve2(options.dest) : join2(
|
|
395
|
+
const dest = options.dest ? resolve2(options.dest) : join2(dirname2(source), basename(source) + "-bare");
|
|
234
396
|
const destBare = join2(dest, ".bare");
|
|
235
|
-
if (
|
|
397
|
+
if (existsSync3(destBare)) {
|
|
236
398
|
throw new Error(`'${destBare}' already exists`);
|
|
237
399
|
}
|
|
238
400
|
const allWorktrees = await listWorktrees(source);
|
|
239
401
|
const worktrees = allWorktrees.filter((wt) => !wt.isBare);
|
|
240
|
-
const seen = new Map;
|
|
402
|
+
const seen = /* @__PURE__ */ new Map();
|
|
241
403
|
for (const wt of worktrees) {
|
|
242
404
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
243
405
|
const safe = sanitizeBranch(branch);
|
|
@@ -250,53 +412,52 @@ async function migrate(config, options) {
|
|
|
250
412
|
run("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
|
|
251
413
|
await setGitConfig("core.bare", "true", { gitdir: destBare });
|
|
252
414
|
await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
|
|
253
|
-
writeFileSync(join2(dest, ".git"),
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
415
|
+
writeFileSync(join2(dest, ".git"), "gitdir: ./.bare\n");
|
|
416
|
+
const sourceParent = dirname2(source);
|
|
417
|
+
const worktreesResolved = worktrees.map(
|
|
418
|
+
(wt, i) => i === 0 && config.type === "standard" ? wt : { ...wt, path: resolveWorktreePath(wt.path, dest, sourceParent) }
|
|
419
|
+
);
|
|
257
420
|
if (config.type === "standard") {
|
|
258
421
|
const mainBranch = worktrees[0].branch;
|
|
259
422
|
const mainSafe = sanitizeBranch(mainBranch);
|
|
260
423
|
const mainDest = join2(dest, mainSafe);
|
|
261
424
|
const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
|
|
262
425
|
run("rm", ["-rf", join2(source, ".git")]);
|
|
263
|
-
await
|
|
426
|
+
await move(source, mainDest);
|
|
264
427
|
const mainAdminDir = join2(destBare, "worktrees", mainSafe);
|
|
265
428
|
mkdirSync(mainAdminDir, { recursive: true });
|
|
266
|
-
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest +
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const headToWrite = mainHeadContent.endsWith(`
|
|
271
|
-
`) ? mainHeadContent : mainHeadContent + `
|
|
272
|
-
`;
|
|
429
|
+
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
|
|
430
|
+
writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
|
|
431
|
+
ensureWorktreeConfig(mainAdminDir, destBare);
|
|
432
|
+
const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
|
|
273
433
|
writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
|
|
274
434
|
const bareIndex = join2(destBare, "index");
|
|
275
|
-
if (
|
|
276
|
-
|
|
435
|
+
if (existsSync3(bareIndex)) {
|
|
436
|
+
renameSync2(bareIndex, join2(mainAdminDir, "index"));
|
|
277
437
|
}
|
|
278
438
|
writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
|
|
279
439
|
`);
|
|
280
|
-
for (let i = 1;i < worktreesResolved.length; i++) {
|
|
281
|
-
await processLinkedWorktree(worktreesResolved[i], dest, destBare);
|
|
440
|
+
for (let i = 1; i < worktreesResolved.length; i++) {
|
|
441
|
+
await processLinkedWorktree(worktreesResolved[i], dest, destBare, log, warn);
|
|
282
442
|
}
|
|
283
443
|
} else {
|
|
284
444
|
for (const wt of worktreesResolved) {
|
|
285
|
-
await processLinkedWorktree(wt, dest, destBare);
|
|
445
|
+
await processLinkedWorktree(wt, dest, destBare, log, warn);
|
|
286
446
|
}
|
|
287
447
|
}
|
|
448
|
+
writeAgentsMd(dest);
|
|
288
449
|
return dest;
|
|
289
450
|
}
|
|
290
|
-
async function processLinkedWorktree(wt, dest, destBare) {
|
|
451
|
+
async function processLinkedWorktree(wt, dest, destBare, log, warn) {
|
|
291
452
|
const wtSrc = wt.path;
|
|
292
453
|
const wtBranch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
293
454
|
const wtSafe = sanitizeBranch(wtBranch);
|
|
294
455
|
const wtDest = join2(dest, wtSafe);
|
|
295
|
-
await
|
|
456
|
+
await move(wtSrc, wtDest);
|
|
296
457
|
const gitFileContent = readFileSync2(join2(wtDest, ".git"), "utf8");
|
|
297
458
|
const match = gitFileContent.match(/^gitdir:\s*(.+)/m);
|
|
298
459
|
if (!match) {
|
|
299
|
-
|
|
460
|
+
warn?.(`Could not parse .git file in ${wtDest}`);
|
|
300
461
|
return;
|
|
301
462
|
}
|
|
302
463
|
const oldPath = match[1].trim();
|
|
@@ -304,17 +465,102 @@ async function processLinkedWorktree(wt, dest, destBare) {
|
|
|
304
465
|
const newAdmin = join2(destBare, "worktrees", adminName);
|
|
305
466
|
writeFileSync(join2(wtDest, ".git"), `gitdir: ${newAdmin}
|
|
306
467
|
`);
|
|
307
|
-
if (
|
|
308
|
-
writeFileSync(join2(newAdmin, "gitdir"), wtDest +
|
|
309
|
-
|
|
468
|
+
if (existsSync3(newAdmin)) {
|
|
469
|
+
writeFileSync(join2(newAdmin, "gitdir"), wtDest + "/.git\n");
|
|
470
|
+
writeFileSync(join2(newAdmin, "commondir"), "../../\n");
|
|
471
|
+
ensureWorktreeConfig(newAdmin, destBare);
|
|
310
472
|
} else {
|
|
311
|
-
|
|
473
|
+
warn?.(`Admin dir ${newAdmin} does not exist for worktree ${wtDest}`);
|
|
312
474
|
}
|
|
313
475
|
}
|
|
314
476
|
|
|
477
|
+
// src/recover.ts
|
|
478
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
479
|
+
import { join as join3, basename as basename2 } from "node:path";
|
|
480
|
+
async function searchForWorktree(branch, options, log) {
|
|
481
|
+
const sanitizedBranch = sanitizeBranch(branch);
|
|
482
|
+
const candidates = [];
|
|
483
|
+
for (const searchDir of options.searchDirs) {
|
|
484
|
+
searchAtDepth(searchDir, sanitizedBranch, 0, options.maxDepth, candidates, log);
|
|
485
|
+
}
|
|
486
|
+
const validCandidates = candidates.filter((c) => {
|
|
487
|
+
const gitPath = join3(c, ".git");
|
|
488
|
+
return existsSync4(gitPath) && statSync4(gitPath).isFile();
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
branch,
|
|
492
|
+
sanitizedBranch,
|
|
493
|
+
foundPath: validCandidates.length === 1 ? validCandidates[0] : null,
|
|
494
|
+
candidates: validCandidates
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function searchAtDepth(dir, targetName, currentDepth, maxDepth, candidates, log) {
|
|
498
|
+
if (currentDepth > maxDepth) return;
|
|
499
|
+
if (!existsSync4(dir)) return;
|
|
500
|
+
const entries = readdirSync2(dir);
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
503
|
+
const fullPath = join3(dir, entry);
|
|
504
|
+
if (!statSync4(fullPath).isDirectory()) continue;
|
|
505
|
+
if (entry === targetName) {
|
|
506
|
+
log?.(`Found candidate: ${fullPath}`);
|
|
507
|
+
candidates.push(fullPath);
|
|
508
|
+
}
|
|
509
|
+
searchAtDepth(fullPath, targetName, currentDepth + 1, maxDepth, candidates, log);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function findMissingWorktrees(hubPath, searchDirs, log) {
|
|
513
|
+
const worktrees = await listWorktrees(hubPath);
|
|
514
|
+
const results = [];
|
|
515
|
+
for (const wt of worktrees) {
|
|
516
|
+
if (wt.isBare) continue;
|
|
517
|
+
if (existsSync4(wt.path)) continue;
|
|
518
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
519
|
+
log?.(`Searching for missing worktree [${branch}]...`);
|
|
520
|
+
const result = await searchForWorktree(branch, { searchDirs, maxDepth: 3 }, log);
|
|
521
|
+
results.push(result);
|
|
522
|
+
}
|
|
523
|
+
return results;
|
|
524
|
+
}
|
|
525
|
+
async function repairWorktree(worktreePath, hubPath, log) {
|
|
526
|
+
const bareDir = join3(hubPath, ".bare");
|
|
527
|
+
const adminBase = join3(bareDir, "worktrees");
|
|
528
|
+
const gitFile = join3(worktreePath, ".git");
|
|
529
|
+
const content = readFileSync3(gitFile, "utf8");
|
|
530
|
+
const match = content.match(/^gitdir:\s*(.+)/m);
|
|
531
|
+
if (!match) {
|
|
532
|
+
throw new Error(`Cannot parse .git file in ${worktreePath}`);
|
|
533
|
+
}
|
|
534
|
+
const oldAdminPath = match[1].trim();
|
|
535
|
+
const adminName = basename2(oldAdminPath);
|
|
536
|
+
const newAdminPath = join3(adminBase, adminName);
|
|
537
|
+
log?.(`Repairing ${worktreePath} -> ${newAdminPath}`);
|
|
538
|
+
writeFileSync2(gitFile, `gitdir: ${newAdminPath}
|
|
539
|
+
`);
|
|
540
|
+
const gitdirFile = join3(newAdminPath, "gitdir");
|
|
541
|
+
if (existsSync4(gitdirFile)) {
|
|
542
|
+
writeFileSync2(gitdirFile, `${worktreePath}/.git
|
|
543
|
+
`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/version.ts
|
|
548
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
549
|
+
import { join as join4 } from "node:path";
|
|
550
|
+
function readVersion() {
|
|
551
|
+
const pkgPath = join4(import.meta.dirname, "..", "package.json");
|
|
552
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
553
|
+
return pkg.version;
|
|
554
|
+
}
|
|
555
|
+
var VERSION = readVersion();
|
|
556
|
+
function getVersion(withPrefix = false) {
|
|
557
|
+
return withPrefix ? `v${VERSION}` : VERSION;
|
|
558
|
+
}
|
|
559
|
+
|
|
315
560
|
// src/cli.ts
|
|
316
561
|
var GREEN = "\x1B[32m";
|
|
317
562
|
var YELLOW = "\x1B[33m";
|
|
563
|
+
var RED = "\x1B[31m";
|
|
318
564
|
var BOLD = "\x1B[1m";
|
|
319
565
|
var RESET = "\x1B[0m";
|
|
320
566
|
function green(s) {
|
|
@@ -323,7 +569,10 @@ function green(s) {
|
|
|
323
569
|
function yellow(s) {
|
|
324
570
|
return `${YELLOW}${s}${RESET}`;
|
|
325
571
|
}
|
|
326
|
-
function
|
|
572
|
+
function red(s) {
|
|
573
|
+
return `${RED}${s}${RESET}`;
|
|
574
|
+
}
|
|
575
|
+
function bold2(s) {
|
|
327
576
|
return `${BOLD}${s}${RESET}`;
|
|
328
577
|
}
|
|
329
578
|
function prompt() {
|
|
@@ -332,37 +581,254 @@ function prompt() {
|
|
|
332
581
|
process.stdin.once("data", (chunk) => res(chunk.toString().trim()));
|
|
333
582
|
});
|
|
334
583
|
}
|
|
584
|
+
function isGitPointerValid(worktreePath, hubPath) {
|
|
585
|
+
const gitFile = join5(worktreePath, ".git");
|
|
586
|
+
if (!existsSync5(gitFile) || !statSync5(gitFile).isFile()) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
const content = readFileSync5(gitFile, "utf8");
|
|
590
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
591
|
+
if (!match) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
const adminDir = match[1].trim();
|
|
595
|
+
const bareDir = join5(hubPath, ".bare");
|
|
596
|
+
if (!adminDir.includes(bareDir) || !adminDir.includes("/worktrees/")) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
if (!existsSync5(adminDir)) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
const adminGitdirFile = join5(adminDir, "gitdir");
|
|
603
|
+
if (!existsSync5(adminGitdirFile)) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
const adminGitdir = readFileSync5(adminGitdirFile, "utf8").trim();
|
|
607
|
+
if (adminGitdir !== gitFile) {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
const commondirFile = join5(adminDir, "commondir");
|
|
611
|
+
if (!existsSync5(commondirFile) || readFileSync5(commondirFile, "utf8").trim() !== "../..") {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const sharedConfig = join5(hubPath, ".bare", "config");
|
|
615
|
+
if (existsSync5(sharedConfig)) {
|
|
616
|
+
const cfg = readFileSync5(sharedConfig, "utf8");
|
|
617
|
+
const extMatch = cfg.match(/\[extensions\]([\s\S]*?)(?=\[|$)/i);
|
|
618
|
+
if (extMatch && /worktreeconfig\s*=\s*true/i.test(extMatch[1])) {
|
|
619
|
+
const configWt = join5(adminDir, "config.worktree");
|
|
620
|
+
if (!existsSync5(configWt) || !readFileSync5(configWt, "utf8").includes("bare = false")) {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
run("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
|
|
627
|
+
return true;
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function listWorktreesFromAdminDirs(hubPath) {
|
|
633
|
+
const adminBase = join5(hubPath, ".bare", "worktrees");
|
|
634
|
+
if (!existsSync5(adminBase)) return [];
|
|
635
|
+
const worktrees = [];
|
|
636
|
+
for (const adminName of readdirSync3(adminBase)) {
|
|
637
|
+
const adminDir = join5(adminBase, adminName);
|
|
638
|
+
if (!statSync5(adminDir).isDirectory()) continue;
|
|
639
|
+
const gitdirFile = join5(adminDir, "gitdir");
|
|
640
|
+
if (!existsSync5(gitdirFile)) continue;
|
|
641
|
+
const wtGitFile = readFileSync5(gitdirFile, "utf8").trim();
|
|
642
|
+
const wtPath = dirname3(wtGitFile);
|
|
643
|
+
const headFile = join5(adminDir, "HEAD");
|
|
644
|
+
let branch = null;
|
|
645
|
+
let head = "";
|
|
646
|
+
if (existsSync5(headFile)) {
|
|
647
|
+
const headContent = readFileSync5(headFile, "utf8").trim();
|
|
648
|
+
if (headContent.startsWith("ref: refs/heads/")) {
|
|
649
|
+
branch = headContent.slice("ref: refs/heads/".length);
|
|
650
|
+
} else {
|
|
651
|
+
head = headContent;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
worktrees.push({ path: wtPath, head, branch, isBare: false });
|
|
655
|
+
}
|
|
656
|
+
return worktrees;
|
|
657
|
+
}
|
|
658
|
+
async function runValidationMode(hubPath) {
|
|
659
|
+
let worktrees;
|
|
660
|
+
try {
|
|
661
|
+
worktrees = await listWorktrees(hubPath);
|
|
662
|
+
} catch {
|
|
663
|
+
console.log(`${yellow("warn:")} git worktree list failed; scanning admin dirs to enumerate worktrees`);
|
|
664
|
+
worktrees = listWorktreesFromAdminDirs(hubPath);
|
|
665
|
+
}
|
|
666
|
+
const validated = [];
|
|
667
|
+
for (const wt of worktrees) {
|
|
668
|
+
if (wt.isBare) continue;
|
|
669
|
+
let status;
|
|
670
|
+
if (!existsSync5(wt.path)) {
|
|
671
|
+
status = "missing";
|
|
672
|
+
} else if (!isGitPointerValid(wt.path, hubPath)) {
|
|
673
|
+
status = "stale";
|
|
674
|
+
} else {
|
|
675
|
+
status = "healthy";
|
|
676
|
+
}
|
|
677
|
+
validated.push({ worktree: wt, status });
|
|
678
|
+
}
|
|
679
|
+
console.log();
|
|
680
|
+
console.log(bold2("Validation Report"));
|
|
681
|
+
console.log(`Hub: ${hubPath}`);
|
|
682
|
+
console.log();
|
|
683
|
+
if (validated.length === 0) {
|
|
684
|
+
console.log("No worktrees found.");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const maxBranchLen = validated.reduce((m, v) => {
|
|
688
|
+
const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
|
|
689
|
+
return Math.max(m, branch.length);
|
|
690
|
+
}, 0);
|
|
691
|
+
const headerBranch = "Branch".padEnd(maxBranchLen);
|
|
692
|
+
const headerStatus = "Status";
|
|
693
|
+
const headerPath = "Path";
|
|
694
|
+
console.log(` ${bold2(headerBranch)} ${bold2(headerStatus.padEnd(7))} ${bold2(headerPath)}`);
|
|
695
|
+
const counts = { healthy: 0, missing: 0, stale: 0 };
|
|
696
|
+
for (const v of validated) {
|
|
697
|
+
const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
|
|
698
|
+
const branchCol = branch.padEnd(maxBranchLen);
|
|
699
|
+
let statusCol;
|
|
700
|
+
if (v.status === "healthy") {
|
|
701
|
+
statusCol = green("healthy");
|
|
702
|
+
counts.healthy++;
|
|
703
|
+
} else if (v.status === "missing") {
|
|
704
|
+
statusCol = red("missing");
|
|
705
|
+
counts.missing++;
|
|
706
|
+
} else {
|
|
707
|
+
statusCol = yellow("stale");
|
|
708
|
+
counts.stale++;
|
|
709
|
+
}
|
|
710
|
+
console.log(` ${branchCol} ${statusCol.padEnd(7 + (statusCol.length - v.status.length))} ${v.worktree.path}`);
|
|
711
|
+
}
|
|
712
|
+
console.log();
|
|
713
|
+
const summaryParts = [];
|
|
714
|
+
if (counts.healthy > 0) summaryParts.push(`${counts.healthy} healthy`);
|
|
715
|
+
if (counts.missing > 0) summaryParts.push(`${counts.missing} missing`);
|
|
716
|
+
if (counts.stale > 0) summaryParts.push(`${counts.stale} stale`);
|
|
717
|
+
console.log(`Summary: ${summaryParts.join(", ")}`);
|
|
718
|
+
const staleWorktrees = validated.filter((v) => v.status === "stale");
|
|
719
|
+
if (staleWorktrees.length > 0) {
|
|
720
|
+
console.log();
|
|
721
|
+
console.log(`${green("==>")} Auto-repairing ${staleWorktrees.length} stale worktree(s)...`);
|
|
722
|
+
await repairHub(hubPath, (msg) => console.log(` ${msg}`));
|
|
723
|
+
}
|
|
724
|
+
const needsRepair = validated.filter((v) => v.status === "missing");
|
|
725
|
+
if (needsRepair.length === 0) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log();
|
|
729
|
+
console.log(`${yellow("warn:")} ${needsRepair.length} missing worktree(s) need repair.`);
|
|
730
|
+
const searchDirs = [dirname3(hubPath)];
|
|
731
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
732
|
+
const results = await findMissingWorktrees(
|
|
733
|
+
hubPath,
|
|
734
|
+
searchDirs,
|
|
735
|
+
(msg) => console.log(` ${msg}`)
|
|
736
|
+
);
|
|
737
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
738
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
739
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
740
|
+
if (notFound.length > 0) {
|
|
741
|
+
console.log(`
|
|
742
|
+
${yellow("Not found:")}`);
|
|
743
|
+
for (const r of notFound) {
|
|
744
|
+
console.log(` [${r.branch}]`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (found.length === 0) {
|
|
748
|
+
console.log(`
|
|
749
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const selections = /* @__PURE__ */ new Map();
|
|
753
|
+
for (const r of multiple) {
|
|
754
|
+
console.log(`
|
|
755
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
756
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
757
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
760
|
+
const sel = await prompt();
|
|
761
|
+
const idx = parseInt(sel) - 1;
|
|
762
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
763
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
console.log(`
|
|
767
|
+
${green("Found:")}`);
|
|
768
|
+
const maxFoundBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
769
|
+
for (const r of found) {
|
|
770
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
771
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxFoundBranchLen + 2 + BOLD.length + RESET.length);
|
|
772
|
+
console.log(` ${branchCol} ${path}`);
|
|
773
|
+
}
|
|
774
|
+
console.log();
|
|
775
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
776
|
+
const repairAns = await prompt();
|
|
777
|
+
process.stdin.destroy();
|
|
778
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
779
|
+
console.log("Aborted.");
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
console.log();
|
|
783
|
+
for (const r of found) {
|
|
784
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
785
|
+
await repairWorktree(path, hubPath, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
786
|
+
}
|
|
787
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).`);
|
|
788
|
+
}
|
|
335
789
|
function usage() {
|
|
336
790
|
console.log(`Usage: git-worktree-organize <source> [destination]
|
|
337
791
|
|
|
338
792
|
Convert a git repository into the canonical bare-hub worktree layout:
|
|
339
793
|
|
|
340
|
-
<dest>/.bare/
|
|
341
|
-
<dest>/.git
|
|
342
|
-
<dest>/<branch>/
|
|
794
|
+
<dest>/.bare/ \u2190 bare git repo
|
|
795
|
+
<dest>/.git \u2190 plain file: "gitdir: ./.bare"
|
|
796
|
+
<dest>/<branch>/ \u2190 one directory per worktree
|
|
343
797
|
|
|
344
798
|
Arguments:
|
|
345
799
|
source Path to existing git repository
|
|
346
800
|
destination Target hub directory (default: <parent>/<name>-bare)
|
|
347
801
|
|
|
348
802
|
Options:
|
|
349
|
-
-h, --help
|
|
803
|
+
-h, --help Show help
|
|
804
|
+
-v, --version Show version`);
|
|
350
805
|
}
|
|
351
806
|
async function main() {
|
|
352
807
|
const args = process.argv.slice(2);
|
|
808
|
+
if (args[0] === "-v" || args[0] === "--version") {
|
|
809
|
+
console.log(`git-worktree-organize ${getVersion(true)}`);
|
|
810
|
+
process.exit(0);
|
|
811
|
+
}
|
|
353
812
|
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
|
354
813
|
usage();
|
|
355
814
|
process.exit(0);
|
|
356
815
|
}
|
|
816
|
+
console.log(`${bold2("git-worktree-organize")} ${getVersion(true)}
|
|
817
|
+
`);
|
|
357
818
|
const sourcePath = args[0];
|
|
358
819
|
const destArg = args[1];
|
|
359
820
|
const source = resolve3(sourcePath);
|
|
360
|
-
const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) :
|
|
821
|
+
const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) : join5(dirname3(source), basename3(source) + "-bare");
|
|
822
|
+
const config = await detect(source);
|
|
823
|
+
if (config.type === "bare-hub") {
|
|
824
|
+
await runValidationMode(source);
|
|
825
|
+
process.exit(0);
|
|
826
|
+
}
|
|
361
827
|
if (!isPartialMigration(source) && !destArg) {
|
|
362
|
-
const ancestorHub = findHub(
|
|
828
|
+
const ancestorHub = findHub(dirname3(source));
|
|
363
829
|
if (ancestorHub) {
|
|
364
830
|
console.log(`
|
|
365
|
-
${yellow("warn:")} ${
|
|
831
|
+
${yellow("warn:")} ${bold2(source)} is inside an existing hub at ${bold2(ancestorHub)}`);
|
|
366
832
|
console.log(`
|
|
367
833
|
This looks like manually-placed worktrees with stale .git files.`);
|
|
368
834
|
console.log(`Running repair will fix all worktree .git connections in the hub.
|
|
@@ -386,24 +852,104 @@ This looks like manually-placed worktrees with stale .git files.`);
|
|
|
386
852
|
if (isPartialMigration(dest)) {
|
|
387
853
|
const hubWorktrees = await listWorktrees(dest);
|
|
388
854
|
const pending = hubWorktrees.filter((wt) => {
|
|
389
|
-
if (wt.isBare)
|
|
390
|
-
return false;
|
|
855
|
+
if (wt.isBare) return false;
|
|
391
856
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
392
|
-
return wt.path !==
|
|
857
|
+
return wt.path !== join5(dest, sanitizeBranch(branch));
|
|
858
|
+
});
|
|
859
|
+
const missing2 = hubWorktrees.filter((wt) => {
|
|
860
|
+
if (wt.isBare) return false;
|
|
861
|
+
return !existsSync5(wt.path);
|
|
393
862
|
});
|
|
394
863
|
console.log(`
|
|
395
|
-
${yellow("warn:")} Partial migration detected at ${
|
|
864
|
+
${yellow("warn:")} Partial migration detected at ${bold2(dest)}`);
|
|
865
|
+
if (missing2.length > 0) {
|
|
866
|
+
console.log(`
|
|
867
|
+
The following worktree paths no longer exist:`);
|
|
868
|
+
for (const wt of missing2) {
|
|
869
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
870
|
+
console.log(` [${branch}] ${wt.path}`);
|
|
871
|
+
}
|
|
872
|
+
console.log();
|
|
873
|
+
const searchDirs = [dirname3(dest)];
|
|
874
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
875
|
+
const results = await findMissingWorktrees(
|
|
876
|
+
dest,
|
|
877
|
+
searchDirs,
|
|
878
|
+
(msg) => console.log(` ${msg}`)
|
|
879
|
+
);
|
|
880
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
881
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
882
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
883
|
+
if (notFound.length > 0) {
|
|
884
|
+
console.log(`
|
|
885
|
+
${yellow("Not found:")}`);
|
|
886
|
+
for (const r of notFound) {
|
|
887
|
+
console.log(` [${r.branch}]`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (found.length === 0) {
|
|
891
|
+
console.log(`
|
|
892
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
893
|
+
process.exit(0);
|
|
894
|
+
}
|
|
895
|
+
const selections = /* @__PURE__ */ new Map();
|
|
896
|
+
for (const r of multiple) {
|
|
897
|
+
console.log(`
|
|
898
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
899
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
900
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
901
|
+
}
|
|
902
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
903
|
+
const sel = await prompt();
|
|
904
|
+
const idx = parseInt(sel) - 1;
|
|
905
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
906
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
console.log(`
|
|
910
|
+
${green("Found:")}`);
|
|
911
|
+
const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
912
|
+
for (const r of found) {
|
|
913
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
914
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
|
|
915
|
+
console.log(` ${branchCol} ${path}`);
|
|
916
|
+
}
|
|
917
|
+
console.log();
|
|
918
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
919
|
+
const repairAns = await prompt();
|
|
920
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
921
|
+
console.log("Aborted.");
|
|
922
|
+
process.stdin.destroy();
|
|
923
|
+
process.exit(0);
|
|
924
|
+
}
|
|
925
|
+
console.log();
|
|
926
|
+
for (const r of found) {
|
|
927
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
928
|
+
await repairWorktree(path, dest, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
929
|
+
}
|
|
930
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).
|
|
931
|
+
`);
|
|
932
|
+
const refreshed = await listWorktrees(dest);
|
|
933
|
+
hubWorktrees.length = 0;
|
|
934
|
+
hubWorktrees.push(...refreshed);
|
|
935
|
+
pending.length = 0;
|
|
936
|
+
pending.push(...hubWorktrees.filter((wt) => {
|
|
937
|
+
if (wt.isBare) return false;
|
|
938
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
939
|
+
return wt.path !== join5(dest, sanitizeBranch(branch));
|
|
940
|
+
}));
|
|
941
|
+
}
|
|
396
942
|
if (pending.length === 0) {
|
|
397
|
-
|
|
943
|
+
await runValidationMode(dest);
|
|
398
944
|
process.exit(0);
|
|
399
945
|
}
|
|
400
946
|
console.log(`
|
|
401
947
|
Worktrees still to move:`);
|
|
402
948
|
for (const wt of pending) {
|
|
403
949
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
404
|
-
const exists =
|
|
950
|
+
const exists = existsSync5(wt.path);
|
|
405
951
|
const status = exists ? "" : ` ${yellow("(path missing)")}`;
|
|
406
|
-
console.log(` [${
|
|
952
|
+
console.log(` [${bold2(branch)}] ${wt.path} \u2192 ${join5(dest, sanitizeBranch(branch))}${status}`);
|
|
407
953
|
}
|
|
408
954
|
console.log();
|
|
409
955
|
process.stdout.write("Resume migration? [y/N] ");
|
|
@@ -413,7 +959,11 @@ Worktrees still to move:`);
|
|
|
413
959
|
process.exit(0);
|
|
414
960
|
}
|
|
415
961
|
console.log();
|
|
416
|
-
const hubPath2 = await resumeMigrate(
|
|
962
|
+
const hubPath2 = await resumeMigrate(
|
|
963
|
+
dest,
|
|
964
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
965
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
966
|
+
);
|
|
417
967
|
console.log();
|
|
418
968
|
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
419
969
|
console.log(run("git", ["-C", hubPath2, "worktree", "list"]).stdout);
|
|
@@ -423,30 +973,132 @@ Worktrees still to move:`);
|
|
|
423
973
|
console.log(`
|
|
424
974
|
${green("==>")} Reading worktrees from ${source}
|
|
425
975
|
`);
|
|
426
|
-
const config = await detect(source);
|
|
427
976
|
const allWorktrees = await listWorktrees(source);
|
|
977
|
+
if (config.type === "standard" && !destArg) {
|
|
978
|
+
const repoName = basename3(source);
|
|
979
|
+
let mainBranch2 = "main";
|
|
980
|
+
if (allWorktrees.length > 0) {
|
|
981
|
+
mainBranch2 = allWorktrees[0].branch ?? "main";
|
|
982
|
+
}
|
|
983
|
+
console.log("Worktrees to migrate:");
|
|
984
|
+
const maxNameLen2 = allWorktrees.reduce((m, wt) => {
|
|
985
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
986
|
+
return Math.max(m, branch.length);
|
|
987
|
+
}, 0);
|
|
988
|
+
for (const wt of allWorktrees) {
|
|
989
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
990
|
+
const safe = sanitizeBranch(branch);
|
|
991
|
+
const isMain = branch === mainBranch2;
|
|
992
|
+
const destDir = join5(source, safe);
|
|
993
|
+
const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen2 + 2 + BOLD.length + RESET.length);
|
|
994
|
+
const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
|
|
995
|
+
console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
|
|
996
|
+
}
|
|
997
|
+
console.log();
|
|
998
|
+
console.log(`Hub destination: ${bold2(source)} (bare repo at ${source}/.bare)`);
|
|
999
|
+
console.log();
|
|
1000
|
+
console.log(`No destination specified. Migrate in-place?`);
|
|
1001
|
+
console.log(`This will rename '${bold2(repoName)}' to '${bold2(repoName + ".old")}' and create the hub here.`);
|
|
1002
|
+
console.log();
|
|
1003
|
+
process.stdout.write("Proceed with in-place migration? [y/N] ");
|
|
1004
|
+
const inPlaceAns = await prompt();
|
|
1005
|
+
process.stdin.destroy();
|
|
1006
|
+
if (!/^[Yy]$/.test(inPlaceAns)) {
|
|
1007
|
+
console.log("Aborted.");
|
|
1008
|
+
console.log("Tip: Specify a destination directory to migrate to a new location.");
|
|
1009
|
+
process.exit(0);
|
|
1010
|
+
}
|
|
1011
|
+
console.log();
|
|
1012
|
+
const hubPath2 = await migrateInPlace(
|
|
1013
|
+
source,
|
|
1014
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
1015
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
1016
|
+
);
|
|
1017
|
+
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
1018
|
+
const verifyOutput2 = run("git", ["-C", hubPath2, "worktree", "list"]).stdout;
|
|
1019
|
+
console.log(verifyOutput2);
|
|
1020
|
+
console.log(`Done! Hub: ${hubPath2}`);
|
|
1021
|
+
console.log(`Backup: ${source}.old`);
|
|
1022
|
+
console.log();
|
|
1023
|
+
console.log("Useful commands:");
|
|
1024
|
+
console.log(` git -C ${hubPath2} worktree list`);
|
|
1025
|
+
console.log(` git -C ${hubPath2}/main log --oneline -5`);
|
|
1026
|
+
process.exit(0);
|
|
1027
|
+
}
|
|
428
1028
|
const missing = allWorktrees.filter((wt) => {
|
|
429
|
-
if (wt.isBare)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return !existsSync3(actual);
|
|
1029
|
+
if (wt.isBare) return false;
|
|
1030
|
+
const actual = resolveWorktreePath(wt.path, dest, dirname3(source));
|
|
1031
|
+
return !existsSync5(actual);
|
|
433
1032
|
});
|
|
434
1033
|
if (missing.length > 0) {
|
|
435
|
-
console.log(
|
|
1034
|
+
console.log(`
|
|
1035
|
+
${yellow("warn:")} The following worktree paths no longer exist:`);
|
|
436
1036
|
for (const wt of missing) {
|
|
437
1037
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
438
1038
|
console.log(` [${branch}] ${wt.path}`);
|
|
439
1039
|
}
|
|
440
1040
|
console.log();
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
1041
|
+
const searchDirs = [dirname3(source)];
|
|
1042
|
+
if (dest !== source) {
|
|
1043
|
+
searchDirs.push(dest);
|
|
1044
|
+
}
|
|
1045
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
1046
|
+
const results = await findMissingWorktrees(
|
|
1047
|
+
source,
|
|
1048
|
+
searchDirs,
|
|
1049
|
+
(msg) => console.log(` ${msg}`)
|
|
1050
|
+
);
|
|
1051
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
1052
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
1053
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
1054
|
+
if (notFound.length > 0) {
|
|
1055
|
+
console.log(`
|
|
1056
|
+
${yellow("Not found:")}`);
|
|
1057
|
+
for (const r of notFound) {
|
|
1058
|
+
console.log(` [${r.branch}]`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (found.length === 0) {
|
|
1062
|
+
console.log(`
|
|
1063
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
1064
|
+
process.exit(0);
|
|
1065
|
+
}
|
|
1066
|
+
const selections = /* @__PURE__ */ new Map();
|
|
1067
|
+
for (const r of multiple) {
|
|
1068
|
+
console.log(`
|
|
1069
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
1070
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
1071
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
1072
|
+
}
|
|
1073
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
1074
|
+
const sel = await prompt();
|
|
1075
|
+
const idx = parseInt(sel) - 1;
|
|
1076
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
1077
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
console.log(`
|
|
1081
|
+
${green("Found:")}`);
|
|
1082
|
+
const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
1083
|
+
for (const r of found) {
|
|
1084
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
1085
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
|
|
1086
|
+
console.log(` ${branchCol} ${path}`);
|
|
1087
|
+
}
|
|
1088
|
+
console.log();
|
|
1089
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
1090
|
+
const repairAns = await prompt();
|
|
1091
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
444
1092
|
console.log("Aborted.");
|
|
445
1093
|
process.stdin.destroy();
|
|
446
1094
|
process.exit(0);
|
|
447
1095
|
}
|
|
448
|
-
|
|
449
|
-
|
|
1096
|
+
console.log();
|
|
1097
|
+
for (const r of found) {
|
|
1098
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
1099
|
+
await repairWorktree(path, source, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
1100
|
+
}
|
|
1101
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).
|
|
450
1102
|
`);
|
|
451
1103
|
const refreshed = await listWorktrees(source);
|
|
452
1104
|
allWorktrees.length = 0;
|
|
@@ -462,19 +1114,19 @@ ${green("==>")} Reading worktrees from ${source}
|
|
|
462
1114
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
463
1115
|
const safe = sanitizeBranch(branch);
|
|
464
1116
|
const isMain = branch === mainBranch;
|
|
465
|
-
const destDir =
|
|
1117
|
+
const destDir = join5(dest, safe);
|
|
466
1118
|
return { branch, isMain, destDir };
|
|
467
1119
|
});
|
|
468
1120
|
const maxNameLen = entries.reduce((m, e) => Math.max(m, e.branch.length), 0);
|
|
469
1121
|
for (const { branch, isMain, destDir } of entries) {
|
|
470
1122
|
const tag = isMain ? yellow("[main]") : "";
|
|
471
1123
|
const tagPad = isMain ? ` (labeled ${yellow("[main]")})` : "";
|
|
472
|
-
const nameCol =
|
|
1124
|
+
const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
|
|
473
1125
|
const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
|
|
474
|
-
console.log(` ${nameCol}${annotation}
|
|
1126
|
+
console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
|
|
475
1127
|
}
|
|
476
1128
|
console.log();
|
|
477
|
-
console.log(`Hub destination: ${
|
|
1129
|
+
console.log(`Hub destination: ${bold2(dest)} (bare repo at ${dest}/.bare)`);
|
|
478
1130
|
console.log();
|
|
479
1131
|
process.stdout.write("Proceed? [y/N] ");
|
|
480
1132
|
const ans = await prompt();
|
|
@@ -484,7 +1136,12 @@ ${green("==>")} Reading worktrees from ${source}
|
|
|
484
1136
|
process.exit(0);
|
|
485
1137
|
}
|
|
486
1138
|
console.log();
|
|
487
|
-
const hubPath = await migrate(
|
|
1139
|
+
const hubPath = await migrate(
|
|
1140
|
+
config,
|
|
1141
|
+
{ source: sourcePath, dest: destArg ?? "" },
|
|
1142
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
1143
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
1144
|
+
);
|
|
488
1145
|
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
489
1146
|
const verifyOutput = run("git", ["-C", hubPath, "worktree", "list"]).stdout;
|
|
490
1147
|
console.log(verifyOutput);
|