git-worktree-organize 1.0.10 → 1.0.12

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 (2) hide show
  1. package/dist/cli.js +120 -79
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
- #!/usr/bin/env bun
2
- // @bun
1
+ #!/usr/bin/env node
3
2
 
4
3
  // src/cli.ts
5
- import { resolve as resolve3, join as join3, dirname as dirname2, basename as basename2 } from "path";
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";
6
6
 
7
7
  // src/run.ts
8
8
  import { spawnSync } from "node:child_process";
@@ -24,28 +24,9 @@ function run(cmd, args, options) {
24
24
  // src/detect.ts
25
25
  import { existsSync, statSync, readFileSync } from "node:fs";
26
26
  import { join, resolve, isAbsolute } from "node:path";
27
-
28
- // src/run.ts
29
- import { spawnSync as spawnSync2 } from "node:child_process";
30
- function run2(cmd, args, options) {
31
- const result = spawnSync2(cmd, args, {
32
- encoding: "utf8",
33
- cwd: options?.cwd,
34
- env: options?.env ?? process.env
35
- });
36
- if (result.status !== 0) {
37
- const err = new Error(`${cmd} ${args.join(" ")} failed: ${result.stderr?.trim() || result.error?.message}`);
38
- err.exitCode = result.status;
39
- err.stderr = result.stderr;
40
- throw err;
41
- }
42
- return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
43
- }
44
-
45
- // src/detect.ts
46
27
  function readCoreBare(gitdir) {
47
28
  try {
48
- const result = run2("git", ["--git-dir", gitdir, "config", "--get", "core.bare"]);
29
+ const result = run("git", ["--git-dir", gitdir, "config", "--get", "core.bare"]);
49
30
  return result.stdout.trim() === "true";
50
31
  } catch {
51
32
  return false;
@@ -127,7 +108,7 @@ function parsePorcelain(output) {
127
108
  return worktrees;
128
109
  }
129
110
  async function listWorktrees(repoPath) {
130
- const result = run2("git", ["-C", repoPath, "worktree", "list", "--porcelain"]);
111
+ const result = run("git", ["-C", repoPath, "worktree", "list", "--porcelain"]);
131
112
  return parsePorcelain(result.stdout);
132
113
  }
133
114
 
@@ -135,46 +116,9 @@ async function listWorktrees(repoPath) {
135
116
  import { mkdirSync, writeFileSync, readFileSync as readFileSync2, existsSync as existsSync2, renameSync, statSync as statSync2 } from "node:fs";
136
117
  import { join as join2, dirname, basename, resolve as resolve2 } from "node:path";
137
118
 
138
- // src/worktrees.ts
139
- function parsePorcelain2(output) {
140
- const worktrees = [];
141
- const blocks = output.trim().split(/\n\n+/);
142
- for (const block of blocks) {
143
- if (!block.trim())
144
- continue;
145
- const lines = block.trim().split(`
146
- `);
147
- let path = "";
148
- let head = "";
149
- let branch = null;
150
- let isBare = false;
151
- for (const line of lines) {
152
- if (line.startsWith("worktree ")) {
153
- path = line.slice("worktree ".length);
154
- } else if (line.startsWith("HEAD ")) {
155
- head = line.slice("HEAD ".length);
156
- } else if (line.startsWith("branch ")) {
157
- const ref = line.slice("branch ".length);
158
- branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
159
- } else if (line === "detached") {
160
- branch = null;
161
- } else if (line === "bare") {
162
- isBare = true;
163
- branch = null;
164
- }
165
- }
166
- worktrees.push({ path, head, branch, isBare });
167
- }
168
- return worktrees;
169
- }
170
- async function listWorktrees2(repoPath) {
171
- const result = run2("git", ["-C", repoPath, "worktree", "list", "--porcelain"]);
172
- return parsePorcelain2(result.stdout);
173
- }
174
-
175
119
  // src/git.ts
176
120
  async function git(args, options) {
177
- const result = run2("git", args, {
121
+ const result = run("git", args, {
178
122
  cwd: options?.cwd,
179
123
  env: options?.env ? { ...process.env, ...options.env } : undefined
180
124
  });
@@ -193,13 +137,46 @@ async function moveDir(src, dest) {
193
137
  if (statSync2(src).dev === statSync2(destForStat).dev) {
194
138
  renameSync(src, dest);
195
139
  } else {
196
- run2("cp", ["-a", src, dest]);
197
- run2("rm", ["-rf", src]);
140
+ run("cp", ["-a", src, dest]);
141
+ run("rm", ["-rf", src]);
198
142
  }
199
143
  }
200
144
  function sanitizeBranch(branch) {
201
145
  return branch.replace(/\//g, "-");
202
146
  }
147
+ function resolveWorktreePath(worktreePath, dest, sourceParent) {
148
+ if (existsSync2(worktreePath))
149
+ return worktreePath;
150
+ if (worktreePath.startsWith(dest + "/")) {
151
+ const remapped = sourceParent + worktreePath.slice(dest.length);
152
+ if (existsSync2(remapped))
153
+ return remapped;
154
+ }
155
+ return worktreePath;
156
+ }
157
+ function isPartialMigration(dest) {
158
+ const gitFile = join2(dest, ".git");
159
+ return existsSync2(join2(dest, ".bare")) && existsSync2(gitFile) && statSync2(gitFile).isFile();
160
+ }
161
+ async function resumeMigrate(dest, log = console.log) {
162
+ const destBare = join2(dest, ".bare");
163
+ const hubWorktrees = await listWorktrees(dest);
164
+ const pending = hubWorktrees.filter((wt) => !wt.isBare && !wt.path.startsWith(dest + "/"));
165
+ if (pending.length === 0) {
166
+ log("Nothing to resume — all worktrees are already in place.");
167
+ return dest;
168
+ }
169
+ for (const wt of pending) {
170
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
171
+ if (!existsSync2(wt.path)) {
172
+ log(`warn: Skipping [${branch}] — path no longer exists: ${wt.path}`);
173
+ continue;
174
+ }
175
+ log(`Moving [${branch}] → ${join2(dest, sanitizeBranch(branch))}`);
176
+ await processLinkedWorktree(wt, dest, destBare);
177
+ }
178
+ return dest;
179
+ }
203
180
  async function migrate(config, options) {
204
181
  const source = resolve2(options.source);
205
182
  const dest = options.dest ? resolve2(options.dest) : join2(dirname(source), basename(source) + "-bare");
@@ -207,7 +184,7 @@ async function migrate(config, options) {
207
184
  if (existsSync2(destBare)) {
208
185
  throw new Error(`'${destBare}' already exists`);
209
186
  }
210
- const allWorktrees = await listWorktrees2(source);
187
+ const allWorktrees = await listWorktrees(source);
211
188
  const worktrees = allWorktrees.filter((wt) => !wt.isBare);
212
189
  const seen = new Map;
213
190
  for (const wt of worktrees) {
@@ -219,17 +196,19 @@ async function migrate(config, options) {
219
196
  seen.set(safe, branch);
220
197
  }
221
198
  mkdirSync(destBare, { recursive: true });
222
- run2("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
199
+ run("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
223
200
  await setGitConfig("core.bare", "true", { gitdir: destBare });
224
201
  await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
225
202
  writeFileSync(join2(dest, ".git"), `gitdir: ./.bare
226
203
  `);
204
+ const sourceParent = dirname(source);
205
+ const worktreesResolved = worktrees.map((wt, i) => i === 0 && config.type === "standard" ? wt : { ...wt, path: resolveWorktreePath(wt.path, dest, sourceParent) });
227
206
  if (config.type === "standard") {
228
207
  const mainBranch = worktrees[0].branch;
229
208
  const mainSafe = sanitizeBranch(mainBranch);
230
209
  const mainDest = join2(dest, mainSafe);
231
210
  const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
232
- run2("rm", ["-rf", join2(source, ".git")]);
211
+ run("rm", ["-rf", join2(source, ".git")]);
233
212
  await moveDir(source, mainDest);
234
213
  const mainAdminDir = join2(destBare, "worktrees", mainSafe);
235
214
  mkdirSync(mainAdminDir, { recursive: true });
@@ -247,11 +226,11 @@ async function migrate(config, options) {
247
226
  }
248
227
  writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
249
228
  `);
250
- for (let i = 1;i < worktrees.length; i++) {
251
- await processLinkedWorktree(worktrees[i], dest, destBare);
229
+ for (let i = 1;i < worktreesResolved.length; i++) {
230
+ await processLinkedWorktree(worktreesResolved[i], dest, destBare);
252
231
  }
253
232
  } else {
254
- for (const wt of worktrees) {
233
+ for (const wt of worktreesResolved) {
255
234
  await processLinkedWorktree(wt, dest, destBare);
256
235
  }
257
236
  }
@@ -296,14 +275,20 @@ function yellow(s) {
296
275
  function bold(s) {
297
276
  return `${BOLD}${s}${RESET}`;
298
277
  }
278
+ function prompt() {
279
+ return new Promise((res) => {
280
+ process.stdin.setEncoding("utf8");
281
+ process.stdin.once("data", (chunk) => res(chunk.toString().trim()));
282
+ });
283
+ }
299
284
  function usage() {
300
285
  console.log(`Usage: git-worktree-organize <source> [destination]
301
286
 
302
287
  Convert a git repository into the canonical bare-hub worktree layout:
303
288
 
304
- <dest>/.bare/ \u2190 bare git repo
305
- <dest>/.git \u2190 plain file: "gitdir: ./.bare"
306
- <dest>/<branch>/ \u2190 one directory per worktree
289
+ <dest>/.bare/ bare git repo
290
+ <dest>/.git plain file: "gitdir: ./.bare"
291
+ <dest>/<branch>/ one directory per worktree
307
292
 
308
293
  Arguments:
309
294
  source Path to existing git repository
@@ -321,12 +306,71 @@ async function main() {
321
306
  const sourcePath = args[0];
322
307
  const destArg = args[1];
323
308
  const source = resolve3(sourcePath);
324
- const dest = destArg ? resolve3(destArg) : join3(dirname2(source), basename2(source) + "-bare");
309
+ const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) : join3(dirname2(source), basename2(source) + "-bare");
310
+ if (isPartialMigration(dest)) {
311
+ const hubWorktrees = await listWorktrees(dest);
312
+ const pending = hubWorktrees.filter((wt) => !wt.isBare && !wt.path.startsWith(dest + "/"));
313
+ console.log(`
314
+ ${yellow("warn:")} Partial migration detected at ${bold(dest)}`);
315
+ if (pending.length === 0) {
316
+ console.log("All worktrees are already in place — nothing to resume.");
317
+ process.exit(0);
318
+ }
319
+ console.log(`
320
+ Worktrees still to move:`);
321
+ for (const wt of pending) {
322
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
323
+ const exists = existsSync3(wt.path);
324
+ const status = exists ? "" : ` ${yellow("(path missing)")}`;
325
+ console.log(` [${bold(branch)}] ${wt.path} → ${join3(dest, sanitizeBranch(branch))}${status}`);
326
+ }
327
+ console.log();
328
+ process.stdout.write("Resume migration? [y/N] ");
329
+ const resumeAns = await prompt();
330
+ if (!/^[Yy]$/.test(resumeAns)) {
331
+ console.log("Aborted.");
332
+ process.exit(0);
333
+ }
334
+ console.log();
335
+ const hubPath2 = await resumeMigrate(dest, (msg) => console.log(`${green("==>")} ${msg}`));
336
+ console.log();
337
+ console.log(`${green("==>")} Verifying with git worktree list...`);
338
+ console.log(run("git", ["-C", hubPath2, "worktree", "list"]).stdout);
339
+ console.log(`Done! Hub: ${hubPath2}`);
340
+ process.exit(0);
341
+ }
325
342
  console.log(`
326
343
  ${green("==>")} Reading worktrees from ${source}
327
344
  `);
328
345
  const config = await detect(source);
329
346
  const allWorktrees = await listWorktrees(source);
347
+ const missing = allWorktrees.filter((wt) => {
348
+ if (wt.isBare)
349
+ return false;
350
+ const actual = resolveWorktreePath(wt.path, dest, dirname2(source));
351
+ return !existsSync3(actual);
352
+ });
353
+ if (missing.length > 0) {
354
+ console.log(`${yellow("warn:")} The following worktree paths no longer exist:`);
355
+ for (const wt of missing) {
356
+ const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
357
+ console.log(` [${branch}] ${wt.path}`);
358
+ }
359
+ console.log();
360
+ process.stdout.write("Remove them with `git worktree prune` and continue? [y/N] ");
361
+ const pruneAns = await prompt();
362
+ if (!/^[Yy]$/.test(pruneAns)) {
363
+ console.log("Aborted.");
364
+ process.stdin.destroy();
365
+ process.exit(0);
366
+ }
367
+ run("git", ["-C", source, "worktree", "prune"]);
368
+ console.log(`${green("==>")} Pruned stale worktrees.
369
+ `);
370
+ const refreshed = await listWorktrees(source);
371
+ allWorktrees.length = 0;
372
+ allWorktrees.push(...refreshed);
373
+ }
330
374
  const worktrees = allWorktrees.filter((wt) => !wt.isBare);
331
375
  let mainBranch = null;
332
376
  if (config.type === "standard" && worktrees.length > 0) {
@@ -346,16 +390,13 @@ ${green("==>")} Reading worktrees from ${source}
346
390
  const tagPad = isMain ? ` (labeled ${yellow("[main]")})` : "";
347
391
  const nameCol = bold(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
348
392
  const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
349
- console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
393
+ console.log(` ${nameCol}${annotation} ${destDir}`);
350
394
  }
351
395
  console.log();
352
396
  console.log(`Hub destination: ${bold(dest)} (bare repo at ${dest}/.bare)`);
353
397
  console.log();
354
398
  process.stdout.write("Proceed? [y/N] ");
355
- const ans = await new Promise((resolve4) => {
356
- process.stdin.setEncoding("utf8");
357
- process.stdin.once("data", (chunk) => resolve4(chunk.toString().trim()));
358
- });
399
+ const ans = await prompt();
359
400
  process.stdin.destroy();
360
401
  if (!/^[Yy]$/.test(ans)) {
361
402
  console.log("Aborted.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-worktree-organize",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Convert any git repo into the canonical bare-hub worktree layout",
5
5
  "type": "module",
6
6
  "bin": {