git-worktree-organize 1.0.12 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -10
- package/dist/cli.js +644 -88
- 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/
|
|
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 (
|
|
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
|
-
##
|
|
55
|
+
## Examples
|
|
48
56
|
|
|
49
|
-
|
|
57
|
+
### In-place migration (recommended)
|
|
50
58
|
|
|
51
59
|
```sh
|
|
52
|
-
git-worktree-organize /projects/myrepo
|
|
60
|
+
git-worktree-organize /projects/myrepo
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
Prompts to reorganize in place, resulting in:
|
|
56
64
|
|
|
57
65
|
```
|
|
58
|
-
/projects/myrepo
|
|
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`
|
|
92
|
+
The original `/projects/myrepo` becomes the `main/` worktree. No data is lost.
|
|
67
93
|
|
|
68
|
-
##
|
|
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
|
-
|
|
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
|
|
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 } 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,80 +110,190 @@ async function listWorktrees(repoPath) {
|
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
// src/migrate.ts
|
|
116
|
-
import { mkdirSync, writeFileSync, readFileSync as readFileSync2, existsSync as
|
|
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
|
|
144
147
|
function sanitizeBranch(branch) {
|
|
145
148
|
return branch.replace(/\//g, "-");
|
|
146
149
|
}
|
|
147
150
|
function resolveWorktreePath(worktreePath, dest, sourceParent) {
|
|
148
|
-
if (
|
|
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 (
|
|
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
|
|
160
|
+
return existsSync3(join2(dest, ".bare")) && existsSync3(gitFile) && statSync3(gitFile).isFile();
|
|
161
|
+
}
|
|
162
|
+
function findHub(startPath) {
|
|
163
|
+
let current = resolve2(startPath);
|
|
164
|
+
while (true) {
|
|
165
|
+
if (isPartialMigration(current)) return current;
|
|
166
|
+
const parent = dirname2(current);
|
|
167
|
+
if (parent === current) return null;
|
|
168
|
+
current = parent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function repairHub(dest, log = console.log) {
|
|
172
|
+
const adminBase = join2(dest, ".bare", "worktrees");
|
|
173
|
+
if (!existsSync3(adminBase)) return;
|
|
174
|
+
for (const adminName of readdirSync(adminBase)) {
|
|
175
|
+
const adminDir = join2(adminBase, adminName);
|
|
176
|
+
if (!statSync3(adminDir).isDirectory()) continue;
|
|
177
|
+
const gitdirFile = join2(adminDir, "gitdir");
|
|
178
|
+
if (!existsSync3(gitdirFile)) continue;
|
|
179
|
+
const registeredGitFile = readFileSync2(gitdirFile, "utf8").trim();
|
|
180
|
+
const worktreePath = dirname2(registeredGitFile);
|
|
181
|
+
if (!worktreePath.startsWith(dest + "/")) continue;
|
|
182
|
+
if (!existsSync3(registeredGitFile) || !statSync3(registeredGitFile).isFile()) continue;
|
|
183
|
+
const content = readFileSync2(registeredGitFile, "utf8");
|
|
184
|
+
const match = content.match(/^gitdir:\s*(.+)/m);
|
|
185
|
+
if (!match) continue;
|
|
186
|
+
if (match[1].trim() === adminDir) continue;
|
|
187
|
+
log(`Repairing .git for [${basename(worktreePath)}]`);
|
|
188
|
+
writeFileSync(registeredGitFile, `gitdir: ${adminDir}
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
160
191
|
}
|
|
161
|
-
async function resumeMigrate(dest, log = console.log) {
|
|
192
|
+
async function resumeMigrate(dest, log = console.log, warn) {
|
|
162
193
|
const destBare = join2(dest, ".bare");
|
|
163
194
|
const hubWorktrees = await listWorktrees(dest);
|
|
164
|
-
const pending = hubWorktrees.filter((wt) =>
|
|
195
|
+
const pending = hubWorktrees.filter((wt) => {
|
|
196
|
+
if (wt.isBare) return false;
|
|
197
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
198
|
+
return wt.path !== join2(dest, sanitizeBranch(branch));
|
|
199
|
+
});
|
|
165
200
|
if (pending.length === 0) {
|
|
166
|
-
log("Nothing to resume
|
|
167
|
-
|
|
201
|
+
log("Nothing to resume \u2014 all worktrees are already in place.");
|
|
202
|
+
} else {
|
|
203
|
+
for (const wt of pending) {
|
|
204
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
205
|
+
const expectedPath = join2(dest, sanitizeBranch(branch));
|
|
206
|
+
let wtPath = wt.path;
|
|
207
|
+
if (!existsSync3(wtPath)) {
|
|
208
|
+
if (existsSync3(expectedPath)) {
|
|
209
|
+
wtPath = expectedPath;
|
|
210
|
+
} else {
|
|
211
|
+
log(`warn: Skipping [${branch}] \u2014 path no longer exists: ${wt.path}`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
log(`Moving [${branch}] \u2192 ${expectedPath}`);
|
|
216
|
+
await processLinkedWorktree({ ...wt, path: wtPath }, dest, destBare, log, warn);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
await repairHub(dest, log);
|
|
220
|
+
return dest;
|
|
221
|
+
}
|
|
222
|
+
async function migrateInPlace(source, log = console.log, warn) {
|
|
223
|
+
const resolvedSource = resolve2(source);
|
|
224
|
+
const oldPath = resolvedSource + ".old";
|
|
225
|
+
if (existsSync3(oldPath)) {
|
|
226
|
+
throw new Error(`'${oldPath}' already exists. Remove it and try again.`);
|
|
168
227
|
}
|
|
169
|
-
|
|
228
|
+
log(`Renaming ${bold(resolvedSource)} to ${bold(oldPath)}`);
|
|
229
|
+
await move(resolvedSource, oldPath);
|
|
230
|
+
const allWorktrees = await listWorktrees(oldPath);
|
|
231
|
+
const worktrees = allWorktrees.filter((wt) => !wt.isBare);
|
|
232
|
+
if (worktrees.length === 0) {
|
|
233
|
+
throw new Error("No worktrees found in source repository");
|
|
234
|
+
}
|
|
235
|
+
const seen = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const wt of worktrees) {
|
|
170
237
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
238
|
+
const safe = sanitizeBranch(branch);
|
|
239
|
+
if (seen.has(safe)) {
|
|
240
|
+
throw new Error(`branch name collision: '${seen.get(safe)}' and '${branch}' both map to '${safe}'`);
|
|
174
241
|
}
|
|
175
|
-
|
|
176
|
-
await processLinkedWorktree(wt, dest, destBare);
|
|
242
|
+
seen.set(safe, branch);
|
|
177
243
|
}
|
|
178
|
-
|
|
244
|
+
const mainBranch = worktrees[0].branch;
|
|
245
|
+
const mainSafe = sanitizeBranch(mainBranch);
|
|
246
|
+
const destBare = join2(resolvedSource, ".bare");
|
|
247
|
+
const mainDest = join2(resolvedSource, mainSafe);
|
|
248
|
+
mkdirSync(destBare, { recursive: true });
|
|
249
|
+
const gitDir = join2(oldPath, ".git");
|
|
250
|
+
for (const entry of readdirSync(gitDir)) {
|
|
251
|
+
const srcPath = join2(gitDir, entry);
|
|
252
|
+
const destPath = join2(destBare, entry);
|
|
253
|
+
if (statSync3(srcPath).isDirectory()) {
|
|
254
|
+
run("cp", ["-a", srcPath + "/.", destPath + "/"]);
|
|
255
|
+
} else {
|
|
256
|
+
run("cp", ["-a", srcPath, destPath]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await setGitConfig("core.bare", "true", { gitdir: destBare });
|
|
260
|
+
await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
|
|
261
|
+
writeFileSync(join2(resolvedSource, ".git"), "gitdir: ./.bare\n");
|
|
262
|
+
const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
|
|
263
|
+
log(`Creating main worktree at ${bold(mainDest)}`);
|
|
264
|
+
run("cp", ["-a", oldPath + "/.", mainDest + "/"]);
|
|
265
|
+
const mainAdminDir = join2(destBare, "worktrees", mainSafe);
|
|
266
|
+
mkdirSync(mainAdminDir, { recursive: true });
|
|
267
|
+
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
|
|
268
|
+
writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
|
|
269
|
+
const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
|
|
270
|
+
writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
|
|
271
|
+
const bareIndex = join2(destBare, "index");
|
|
272
|
+
if (existsSync3(bareIndex)) {
|
|
273
|
+
renameSync2(bareIndex, join2(mainAdminDir, "index"));
|
|
274
|
+
}
|
|
275
|
+
run("rm", ["-rf", join2(mainDest, ".git")]);
|
|
276
|
+
writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
|
|
277
|
+
`);
|
|
278
|
+
for (let i = 1; i < worktrees.length; i++) {
|
|
279
|
+
await processLinkedWorktree(worktrees[i], resolvedSource, destBare, log, warn);
|
|
280
|
+
}
|
|
281
|
+
log(`Original repo backed up at: ${oldPath}`);
|
|
282
|
+
return resolvedSource;
|
|
283
|
+
}
|
|
284
|
+
function bold(s) {
|
|
285
|
+
return `\x1B[1m${s}\x1B[0m`;
|
|
179
286
|
}
|
|
180
|
-
async function migrate(config, options) {
|
|
287
|
+
async function migrate(config, options, log, warn) {
|
|
181
288
|
const source = resolve2(options.source);
|
|
182
|
-
const dest = options.dest ? resolve2(options.dest) : join2(
|
|
289
|
+
const dest = options.dest ? resolve2(options.dest) : join2(dirname2(source), basename(source) + "-bare");
|
|
183
290
|
const destBare = join2(dest, ".bare");
|
|
184
|
-
if (
|
|
291
|
+
if (existsSync3(destBare)) {
|
|
185
292
|
throw new Error(`'${destBare}' already exists`);
|
|
186
293
|
}
|
|
187
294
|
const allWorktrees = await listWorktrees(source);
|
|
188
295
|
const worktrees = allWorktrees.filter((wt) => !wt.isBare);
|
|
189
|
-
const seen = new Map;
|
|
296
|
+
const seen = /* @__PURE__ */ new Map();
|
|
190
297
|
for (const wt of worktrees) {
|
|
191
298
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
192
299
|
const safe = sanitizeBranch(branch);
|
|
@@ -199,53 +306,50 @@ async function migrate(config, options) {
|
|
|
199
306
|
run("cp", ["-a", config.gitdir + "/.", destBare + "/"]);
|
|
200
307
|
await setGitConfig("core.bare", "true", { gitdir: destBare });
|
|
201
308
|
await setGitConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*", { gitdir: destBare });
|
|
202
|
-
writeFileSync(join2(dest, ".git"),
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
309
|
+
writeFileSync(join2(dest, ".git"), "gitdir: ./.bare\n");
|
|
310
|
+
const sourceParent = dirname2(source);
|
|
311
|
+
const worktreesResolved = worktrees.map(
|
|
312
|
+
(wt, i) => i === 0 && config.type === "standard" ? wt : { ...wt, path: resolveWorktreePath(wt.path, dest, sourceParent) }
|
|
313
|
+
);
|
|
206
314
|
if (config.type === "standard") {
|
|
207
315
|
const mainBranch = worktrees[0].branch;
|
|
208
316
|
const mainSafe = sanitizeBranch(mainBranch);
|
|
209
317
|
const mainDest = join2(dest, mainSafe);
|
|
210
318
|
const mainHeadContent = readFileSync2(join2(destBare, "HEAD"), "utf8");
|
|
211
319
|
run("rm", ["-rf", join2(source, ".git")]);
|
|
212
|
-
await
|
|
320
|
+
await move(source, mainDest);
|
|
213
321
|
const mainAdminDir = join2(destBare, "worktrees", mainSafe);
|
|
214
322
|
mkdirSync(mainAdminDir, { recursive: true });
|
|
215
|
-
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest +
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`);
|
|
219
|
-
const headToWrite = mainHeadContent.endsWith(`
|
|
220
|
-
`) ? mainHeadContent : mainHeadContent + `
|
|
221
|
-
`;
|
|
323
|
+
writeFileSync(join2(mainAdminDir, "gitdir"), mainDest + "/.git\n");
|
|
324
|
+
writeFileSync(join2(mainAdminDir, "commondir"), "../../\n");
|
|
325
|
+
const headToWrite = mainHeadContent.endsWith("\n") ? mainHeadContent : mainHeadContent + "\n";
|
|
222
326
|
writeFileSync(join2(mainAdminDir, "HEAD"), headToWrite);
|
|
223
327
|
const bareIndex = join2(destBare, "index");
|
|
224
|
-
if (
|
|
225
|
-
|
|
328
|
+
if (existsSync3(bareIndex)) {
|
|
329
|
+
renameSync2(bareIndex, join2(mainAdminDir, "index"));
|
|
226
330
|
}
|
|
227
331
|
writeFileSync(join2(mainDest, ".git"), `gitdir: ${mainAdminDir}
|
|
228
332
|
`);
|
|
229
|
-
for (let i = 1;i < worktreesResolved.length; i++) {
|
|
230
|
-
await processLinkedWorktree(worktreesResolved[i], dest, destBare);
|
|
333
|
+
for (let i = 1; i < worktreesResolved.length; i++) {
|
|
334
|
+
await processLinkedWorktree(worktreesResolved[i], dest, destBare, log, warn);
|
|
231
335
|
}
|
|
232
336
|
} else {
|
|
233
337
|
for (const wt of worktreesResolved) {
|
|
234
|
-
await processLinkedWorktree(wt, dest, destBare);
|
|
338
|
+
await processLinkedWorktree(wt, dest, destBare, log, warn);
|
|
235
339
|
}
|
|
236
340
|
}
|
|
237
341
|
return dest;
|
|
238
342
|
}
|
|
239
|
-
async function processLinkedWorktree(wt, dest, destBare) {
|
|
343
|
+
async function processLinkedWorktree(wt, dest, destBare, log, warn) {
|
|
240
344
|
const wtSrc = wt.path;
|
|
241
345
|
const wtBranch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
242
346
|
const wtSafe = sanitizeBranch(wtBranch);
|
|
243
347
|
const wtDest = join2(dest, wtSafe);
|
|
244
|
-
await
|
|
348
|
+
await move(wtSrc, wtDest);
|
|
245
349
|
const gitFileContent = readFileSync2(join2(wtDest, ".git"), "utf8");
|
|
246
350
|
const match = gitFileContent.match(/^gitdir:\s*(.+)/m);
|
|
247
351
|
if (!match) {
|
|
248
|
-
|
|
352
|
+
warn?.(`Could not parse .git file in ${wtDest}`);
|
|
249
353
|
return;
|
|
250
354
|
}
|
|
251
355
|
const oldPath = match[1].trim();
|
|
@@ -253,17 +357,100 @@ async function processLinkedWorktree(wt, dest, destBare) {
|
|
|
253
357
|
const newAdmin = join2(destBare, "worktrees", adminName);
|
|
254
358
|
writeFileSync(join2(wtDest, ".git"), `gitdir: ${newAdmin}
|
|
255
359
|
`);
|
|
256
|
-
if (
|
|
257
|
-
writeFileSync(join2(newAdmin, "gitdir"), wtDest +
|
|
258
|
-
`);
|
|
360
|
+
if (existsSync3(newAdmin)) {
|
|
361
|
+
writeFileSync(join2(newAdmin, "gitdir"), wtDest + "/.git\n");
|
|
259
362
|
} else {
|
|
260
|
-
|
|
363
|
+
warn?.(`Admin dir ${newAdmin} does not exist for worktree ${wtDest}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/recover.ts
|
|
368
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
369
|
+
import { join as join3, basename as basename2 } from "node:path";
|
|
370
|
+
async function searchForWorktree(branch, options, log) {
|
|
371
|
+
const sanitizedBranch = sanitizeBranch(branch);
|
|
372
|
+
const candidates = [];
|
|
373
|
+
for (const searchDir of options.searchDirs) {
|
|
374
|
+
searchAtDepth(searchDir, sanitizedBranch, 0, options.maxDepth, candidates, log);
|
|
375
|
+
}
|
|
376
|
+
const validCandidates = candidates.filter((c) => {
|
|
377
|
+
const gitPath = join3(c, ".git");
|
|
378
|
+
return existsSync4(gitPath) && statSync4(gitPath).isFile();
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
branch,
|
|
382
|
+
sanitizedBranch,
|
|
383
|
+
foundPath: validCandidates.length === 1 ? validCandidates[0] : null,
|
|
384
|
+
candidates: validCandidates
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function searchAtDepth(dir, targetName, currentDepth, maxDepth, candidates, log) {
|
|
388
|
+
if (currentDepth > maxDepth) return;
|
|
389
|
+
if (!existsSync4(dir)) return;
|
|
390
|
+
const entries = readdirSync2(dir);
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
393
|
+
const fullPath = join3(dir, entry);
|
|
394
|
+
if (!statSync4(fullPath).isDirectory()) continue;
|
|
395
|
+
if (entry === targetName) {
|
|
396
|
+
log?.(`Found candidate: ${fullPath}`);
|
|
397
|
+
candidates.push(fullPath);
|
|
398
|
+
}
|
|
399
|
+
searchAtDepth(fullPath, targetName, currentDepth + 1, maxDepth, candidates, log);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async function findMissingWorktrees(hubPath, searchDirs, log) {
|
|
403
|
+
const worktrees = await listWorktrees(hubPath);
|
|
404
|
+
const results = [];
|
|
405
|
+
for (const wt of worktrees) {
|
|
406
|
+
if (wt.isBare) continue;
|
|
407
|
+
if (existsSync4(wt.path)) continue;
|
|
408
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
409
|
+
log?.(`Searching for missing worktree [${branch}]...`);
|
|
410
|
+
const result = await searchForWorktree(branch, { searchDirs, maxDepth: 3 }, log);
|
|
411
|
+
results.push(result);
|
|
412
|
+
}
|
|
413
|
+
return results;
|
|
414
|
+
}
|
|
415
|
+
async function repairWorktree(worktreePath, hubPath, log) {
|
|
416
|
+
const bareDir = join3(hubPath, ".bare");
|
|
417
|
+
const adminBase = join3(bareDir, "worktrees");
|
|
418
|
+
const gitFile = join3(worktreePath, ".git");
|
|
419
|
+
const content = readFileSync3(gitFile, "utf8");
|
|
420
|
+
const match = content.match(/^gitdir:\s*(.+)/m);
|
|
421
|
+
if (!match) {
|
|
422
|
+
throw new Error(`Cannot parse .git file in ${worktreePath}`);
|
|
423
|
+
}
|
|
424
|
+
const oldAdminPath = match[1].trim();
|
|
425
|
+
const adminName = basename2(oldAdminPath);
|
|
426
|
+
const newAdminPath = join3(adminBase, adminName);
|
|
427
|
+
log?.(`Repairing ${worktreePath} -> ${newAdminPath}`);
|
|
428
|
+
writeFileSync2(gitFile, `gitdir: ${newAdminPath}
|
|
429
|
+
`);
|
|
430
|
+
const gitdirFile = join3(newAdminPath, "gitdir");
|
|
431
|
+
if (existsSync4(gitdirFile)) {
|
|
432
|
+
writeFileSync2(gitdirFile, `${worktreePath}/.git
|
|
433
|
+
`);
|
|
261
434
|
}
|
|
262
435
|
}
|
|
263
436
|
|
|
437
|
+
// src/version.ts
|
|
438
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
439
|
+
import { join as join4 } from "node:path";
|
|
440
|
+
function readVersion() {
|
|
441
|
+
const pkgPath = join4(import.meta.dirname, "..", "package.json");
|
|
442
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
443
|
+
return pkg.version;
|
|
444
|
+
}
|
|
445
|
+
var VERSION = readVersion();
|
|
446
|
+
function getVersion(withPrefix = false) {
|
|
447
|
+
return withPrefix ? `v${VERSION}` : VERSION;
|
|
448
|
+
}
|
|
449
|
+
|
|
264
450
|
// src/cli.ts
|
|
265
451
|
var GREEN = "\x1B[32m";
|
|
266
452
|
var YELLOW = "\x1B[33m";
|
|
453
|
+
var RED = "\x1B[31m";
|
|
267
454
|
var BOLD = "\x1B[1m";
|
|
268
455
|
var RESET = "\x1B[0m";
|
|
269
456
|
function green(s) {
|
|
@@ -272,7 +459,10 @@ function green(s) {
|
|
|
272
459
|
function yellow(s) {
|
|
273
460
|
return `${YELLOW}${s}${RESET}`;
|
|
274
461
|
}
|
|
275
|
-
function
|
|
462
|
+
function red(s) {
|
|
463
|
+
return `${RED}${s}${RESET}`;
|
|
464
|
+
}
|
|
465
|
+
function bold2(s) {
|
|
276
466
|
return `${BOLD}${s}${RESET}`;
|
|
277
467
|
}
|
|
278
468
|
function prompt() {
|
|
@@ -281,48 +471,303 @@ function prompt() {
|
|
|
281
471
|
process.stdin.once("data", (chunk) => res(chunk.toString().trim()));
|
|
282
472
|
});
|
|
283
473
|
}
|
|
474
|
+
function isGitPointerValid(worktreePath, hubPath) {
|
|
475
|
+
const gitFile = join5(worktreePath, ".git");
|
|
476
|
+
if (!existsSync5(gitFile) || !statSync5(gitFile).isFile()) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
const content = readFileSync5(gitFile, "utf8");
|
|
480
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
481
|
+
if (!match) {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
const gitdir = match[1].trim();
|
|
485
|
+
const bareDir = join5(hubPath, ".bare");
|
|
486
|
+
return gitdir.includes(bareDir) && gitdir.includes("/worktrees/");
|
|
487
|
+
}
|
|
488
|
+
async function runValidationMode(hubPath) {
|
|
489
|
+
const worktrees = await listWorktrees(hubPath);
|
|
490
|
+
const validated = [];
|
|
491
|
+
for (const wt of worktrees) {
|
|
492
|
+
if (wt.isBare) continue;
|
|
493
|
+
let status;
|
|
494
|
+
if (!existsSync5(wt.path)) {
|
|
495
|
+
status = "missing";
|
|
496
|
+
} else if (!isGitPointerValid(wt.path, hubPath)) {
|
|
497
|
+
status = "stale";
|
|
498
|
+
} else {
|
|
499
|
+
status = "healthy";
|
|
500
|
+
}
|
|
501
|
+
validated.push({ worktree: wt, status });
|
|
502
|
+
}
|
|
503
|
+
console.log();
|
|
504
|
+
console.log(bold2("Validation Report"));
|
|
505
|
+
console.log(`Hub: ${hubPath}`);
|
|
506
|
+
console.log();
|
|
507
|
+
if (validated.length === 0) {
|
|
508
|
+
console.log("No worktrees found.");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const maxBranchLen = validated.reduce((m, v) => {
|
|
512
|
+
const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
|
|
513
|
+
return Math.max(m, branch.length);
|
|
514
|
+
}, 0);
|
|
515
|
+
const headerBranch = "Branch".padEnd(maxBranchLen);
|
|
516
|
+
const headerStatus = "Status";
|
|
517
|
+
const headerPath = "Path";
|
|
518
|
+
console.log(` ${bold2(headerBranch)} ${bold2(headerStatus.padEnd(7))} ${bold2(headerPath)}`);
|
|
519
|
+
const counts = { healthy: 0, missing: 0, stale: 0 };
|
|
520
|
+
for (const v of validated) {
|
|
521
|
+
const branch = v.worktree.branch ?? `detached-${v.worktree.head.slice(0, 8)}`;
|
|
522
|
+
const branchCol = branch.padEnd(maxBranchLen);
|
|
523
|
+
let statusCol;
|
|
524
|
+
if (v.status === "healthy") {
|
|
525
|
+
statusCol = green("healthy");
|
|
526
|
+
counts.healthy++;
|
|
527
|
+
} else if (v.status === "missing") {
|
|
528
|
+
statusCol = red("missing");
|
|
529
|
+
counts.missing++;
|
|
530
|
+
} else {
|
|
531
|
+
statusCol = yellow("stale");
|
|
532
|
+
counts.stale++;
|
|
533
|
+
}
|
|
534
|
+
console.log(` ${branchCol} ${statusCol.padEnd(7 + (statusCol.length - v.status.length))} ${v.worktree.path}`);
|
|
535
|
+
}
|
|
536
|
+
console.log();
|
|
537
|
+
const summaryParts = [];
|
|
538
|
+
if (counts.healthy > 0) summaryParts.push(`${counts.healthy} healthy`);
|
|
539
|
+
if (counts.missing > 0) summaryParts.push(`${counts.missing} missing`);
|
|
540
|
+
if (counts.stale > 0) summaryParts.push(`${counts.stale} stale`);
|
|
541
|
+
console.log(`Summary: ${summaryParts.join(", ")}`);
|
|
542
|
+
const needsRepair = validated.filter((v) => v.status === "missing" || v.status === "stale");
|
|
543
|
+
if (needsRepair.length === 0) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
console.log();
|
|
547
|
+
console.log(`${yellow("warn:")} ${needsRepair.length} worktree(s) need repair.`);
|
|
548
|
+
const searchDirs = [dirname3(hubPath)];
|
|
549
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
550
|
+
const results = await findMissingWorktrees(
|
|
551
|
+
hubPath,
|
|
552
|
+
searchDirs,
|
|
553
|
+
(msg) => console.log(` ${msg}`)
|
|
554
|
+
);
|
|
555
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
556
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
557
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
558
|
+
if (notFound.length > 0) {
|
|
559
|
+
console.log(`
|
|
560
|
+
${yellow("Not found:")}`);
|
|
561
|
+
for (const r of notFound) {
|
|
562
|
+
console.log(` [${r.branch}]`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (found.length === 0) {
|
|
566
|
+
console.log(`
|
|
567
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const selections = /* @__PURE__ */ new Map();
|
|
571
|
+
for (const r of multiple) {
|
|
572
|
+
console.log(`
|
|
573
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
574
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
575
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
576
|
+
}
|
|
577
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
578
|
+
const sel = await prompt();
|
|
579
|
+
const idx = parseInt(sel) - 1;
|
|
580
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
581
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
console.log(`
|
|
585
|
+
${green("Found:")}`);
|
|
586
|
+
const maxFoundBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
587
|
+
for (const r of found) {
|
|
588
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
589
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxFoundBranchLen + 2 + BOLD.length + RESET.length);
|
|
590
|
+
console.log(` ${branchCol} ${path}`);
|
|
591
|
+
}
|
|
592
|
+
console.log();
|
|
593
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
594
|
+
const repairAns = await prompt();
|
|
595
|
+
process.stdin.destroy();
|
|
596
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
597
|
+
console.log("Aborted.");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
console.log();
|
|
601
|
+
for (const r of found) {
|
|
602
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
603
|
+
await repairWorktree(path, hubPath, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
604
|
+
}
|
|
605
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).`);
|
|
606
|
+
}
|
|
284
607
|
function usage() {
|
|
285
608
|
console.log(`Usage: git-worktree-organize <source> [destination]
|
|
286
609
|
|
|
287
610
|
Convert a git repository into the canonical bare-hub worktree layout:
|
|
288
611
|
|
|
289
|
-
<dest>/.bare/
|
|
290
|
-
<dest>/.git
|
|
291
|
-
<dest>/<branch>/
|
|
612
|
+
<dest>/.bare/ \u2190 bare git repo
|
|
613
|
+
<dest>/.git \u2190 plain file: "gitdir: ./.bare"
|
|
614
|
+
<dest>/<branch>/ \u2190 one directory per worktree
|
|
292
615
|
|
|
293
616
|
Arguments:
|
|
294
617
|
source Path to existing git repository
|
|
295
618
|
destination Target hub directory (default: <parent>/<name>-bare)
|
|
296
619
|
|
|
297
620
|
Options:
|
|
298
|
-
-h, --help
|
|
621
|
+
-h, --help Show help
|
|
622
|
+
-v, --version Show version`);
|
|
299
623
|
}
|
|
300
624
|
async function main() {
|
|
301
625
|
const args = process.argv.slice(2);
|
|
626
|
+
if (args[0] === "-v" || args[0] === "--version") {
|
|
627
|
+
console.log(`git-worktree-organize ${getVersion(true)}`);
|
|
628
|
+
process.exit(0);
|
|
629
|
+
}
|
|
302
630
|
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
|
303
631
|
usage();
|
|
304
632
|
process.exit(0);
|
|
305
633
|
}
|
|
634
|
+
console.log(`${bold2("git-worktree-organize")} ${getVersion(true)}
|
|
635
|
+
`);
|
|
306
636
|
const sourcePath = args[0];
|
|
307
637
|
const destArg = args[1];
|
|
308
638
|
const source = resolve3(sourcePath);
|
|
309
|
-
const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) :
|
|
639
|
+
const dest = isPartialMigration(source) ? source : destArg ? resolve3(destArg) : join5(dirname3(source), basename3(source) + "-bare");
|
|
640
|
+
const config = await detect(source);
|
|
641
|
+
if (config.type === "bare-hub") {
|
|
642
|
+
await runValidationMode(source);
|
|
643
|
+
process.exit(0);
|
|
644
|
+
}
|
|
645
|
+
if (!isPartialMigration(source) && !destArg) {
|
|
646
|
+
const ancestorHub = findHub(dirname3(source));
|
|
647
|
+
if (ancestorHub) {
|
|
648
|
+
console.log(`
|
|
649
|
+
${yellow("warn:")} ${bold2(source)} is inside an existing hub at ${bold2(ancestorHub)}`);
|
|
650
|
+
console.log(`
|
|
651
|
+
This looks like manually-placed worktrees with stale .git files.`);
|
|
652
|
+
console.log(`Running repair will fix all worktree .git connections in the hub.
|
|
653
|
+
`);
|
|
654
|
+
process.stdout.write(`Repair hub at ${ancestorHub}? [y/N] `);
|
|
655
|
+
const repairAns = await prompt();
|
|
656
|
+
process.stdin.destroy();
|
|
657
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
658
|
+
console.log("Aborted.");
|
|
659
|
+
process.exit(0);
|
|
660
|
+
}
|
|
661
|
+
console.log();
|
|
662
|
+
await repairHub(ancestorHub, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
663
|
+
console.log();
|
|
664
|
+
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
665
|
+
console.log(run("git", ["-C", ancestorHub, "worktree", "list"]).stdout);
|
|
666
|
+
console.log(`Done! Hub: ${ancestorHub}`);
|
|
667
|
+
process.exit(0);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
310
670
|
if (isPartialMigration(dest)) {
|
|
311
671
|
const hubWorktrees = await listWorktrees(dest);
|
|
312
|
-
const pending = hubWorktrees.filter((wt) =>
|
|
672
|
+
const pending = hubWorktrees.filter((wt) => {
|
|
673
|
+
if (wt.isBare) return false;
|
|
674
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
675
|
+
return wt.path !== join5(dest, sanitizeBranch(branch));
|
|
676
|
+
});
|
|
677
|
+
const missing2 = hubWorktrees.filter((wt) => {
|
|
678
|
+
if (wt.isBare) return false;
|
|
679
|
+
return !existsSync5(wt.path);
|
|
680
|
+
});
|
|
313
681
|
console.log(`
|
|
314
|
-
${yellow("warn:")} Partial migration detected at ${
|
|
682
|
+
${yellow("warn:")} Partial migration detected at ${bold2(dest)}`);
|
|
683
|
+
if (missing2.length > 0) {
|
|
684
|
+
console.log(`
|
|
685
|
+
The following worktree paths no longer exist:`);
|
|
686
|
+
for (const wt of missing2) {
|
|
687
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
688
|
+
console.log(` [${branch}] ${wt.path}`);
|
|
689
|
+
}
|
|
690
|
+
console.log();
|
|
691
|
+
const searchDirs = [dirname3(dest)];
|
|
692
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
693
|
+
const results = await findMissingWorktrees(
|
|
694
|
+
dest,
|
|
695
|
+
searchDirs,
|
|
696
|
+
(msg) => console.log(` ${msg}`)
|
|
697
|
+
);
|
|
698
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
699
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
700
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
701
|
+
if (notFound.length > 0) {
|
|
702
|
+
console.log(`
|
|
703
|
+
${yellow("Not found:")}`);
|
|
704
|
+
for (const r of notFound) {
|
|
705
|
+
console.log(` [${r.branch}]`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (found.length === 0) {
|
|
709
|
+
console.log(`
|
|
710
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
711
|
+
process.exit(0);
|
|
712
|
+
}
|
|
713
|
+
const selections = /* @__PURE__ */ new Map();
|
|
714
|
+
for (const r of multiple) {
|
|
715
|
+
console.log(`
|
|
716
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
717
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
718
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
719
|
+
}
|
|
720
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
721
|
+
const sel = await prompt();
|
|
722
|
+
const idx = parseInt(sel) - 1;
|
|
723
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
724
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
console.log(`
|
|
728
|
+
${green("Found:")}`);
|
|
729
|
+
const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
730
|
+
for (const r of found) {
|
|
731
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
732
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
|
|
733
|
+
console.log(` ${branchCol} ${path}`);
|
|
734
|
+
}
|
|
735
|
+
console.log();
|
|
736
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
737
|
+
const repairAns = await prompt();
|
|
738
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
739
|
+
console.log("Aborted.");
|
|
740
|
+
process.stdin.destroy();
|
|
741
|
+
process.exit(0);
|
|
742
|
+
}
|
|
743
|
+
console.log();
|
|
744
|
+
for (const r of found) {
|
|
745
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
746
|
+
await repairWorktree(path, dest, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
747
|
+
}
|
|
748
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).
|
|
749
|
+
`);
|
|
750
|
+
const refreshed = await listWorktrees(dest);
|
|
751
|
+
hubWorktrees.length = 0;
|
|
752
|
+
hubWorktrees.push(...refreshed);
|
|
753
|
+
pending.length = 0;
|
|
754
|
+
pending.push(...hubWorktrees.filter((wt) => {
|
|
755
|
+
if (wt.isBare) return false;
|
|
756
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
757
|
+
return wt.path !== join5(dest, sanitizeBranch(branch));
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
315
760
|
if (pending.length === 0) {
|
|
316
|
-
|
|
761
|
+
await runValidationMode(dest);
|
|
317
762
|
process.exit(0);
|
|
318
763
|
}
|
|
319
764
|
console.log(`
|
|
320
765
|
Worktrees still to move:`);
|
|
321
766
|
for (const wt of pending) {
|
|
322
767
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
323
|
-
const exists =
|
|
768
|
+
const exists = existsSync5(wt.path);
|
|
324
769
|
const status = exists ? "" : ` ${yellow("(path missing)")}`;
|
|
325
|
-
console.log(` [${
|
|
770
|
+
console.log(` [${bold2(branch)}] ${wt.path} \u2192 ${join5(dest, sanitizeBranch(branch))}${status}`);
|
|
326
771
|
}
|
|
327
772
|
console.log();
|
|
328
773
|
process.stdout.write("Resume migration? [y/N] ");
|
|
@@ -332,7 +777,11 @@ Worktrees still to move:`);
|
|
|
332
777
|
process.exit(0);
|
|
333
778
|
}
|
|
334
779
|
console.log();
|
|
335
|
-
const hubPath2 = await resumeMigrate(
|
|
780
|
+
const hubPath2 = await resumeMigrate(
|
|
781
|
+
dest,
|
|
782
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
783
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
784
|
+
);
|
|
336
785
|
console.log();
|
|
337
786
|
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
338
787
|
console.log(run("git", ["-C", hubPath2, "worktree", "list"]).stdout);
|
|
@@ -342,30 +791,132 @@ Worktrees still to move:`);
|
|
|
342
791
|
console.log(`
|
|
343
792
|
${green("==>")} Reading worktrees from ${source}
|
|
344
793
|
`);
|
|
345
|
-
const config = await detect(source);
|
|
346
794
|
const allWorktrees = await listWorktrees(source);
|
|
795
|
+
if (config.type === "standard" && !destArg) {
|
|
796
|
+
const repoName = basename3(source);
|
|
797
|
+
let mainBranch2 = "main";
|
|
798
|
+
if (allWorktrees.length > 0) {
|
|
799
|
+
mainBranch2 = allWorktrees[0].branch ?? "main";
|
|
800
|
+
}
|
|
801
|
+
console.log("Worktrees to migrate:");
|
|
802
|
+
const maxNameLen2 = allWorktrees.reduce((m, wt) => {
|
|
803
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
804
|
+
return Math.max(m, branch.length);
|
|
805
|
+
}, 0);
|
|
806
|
+
for (const wt of allWorktrees) {
|
|
807
|
+
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
808
|
+
const safe = sanitizeBranch(branch);
|
|
809
|
+
const isMain = branch === mainBranch2;
|
|
810
|
+
const destDir = join5(source, safe);
|
|
811
|
+
const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen2 + 2 + BOLD.length + RESET.length);
|
|
812
|
+
const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
|
|
813
|
+
console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
|
|
814
|
+
}
|
|
815
|
+
console.log();
|
|
816
|
+
console.log(`Hub destination: ${bold2(source)} (bare repo at ${source}/.bare)`);
|
|
817
|
+
console.log();
|
|
818
|
+
console.log(`No destination specified. Migrate in-place?`);
|
|
819
|
+
console.log(`This will rename '${bold2(repoName)}' to '${bold2(repoName + ".old")}' and create the hub here.`);
|
|
820
|
+
console.log();
|
|
821
|
+
process.stdout.write("Proceed with in-place migration? [y/N] ");
|
|
822
|
+
const inPlaceAns = await prompt();
|
|
823
|
+
process.stdin.destroy();
|
|
824
|
+
if (!/^[Yy]$/.test(inPlaceAns)) {
|
|
825
|
+
console.log("Aborted.");
|
|
826
|
+
console.log("Tip: Specify a destination directory to migrate to a new location.");
|
|
827
|
+
process.exit(0);
|
|
828
|
+
}
|
|
829
|
+
console.log();
|
|
830
|
+
const hubPath2 = await migrateInPlace(
|
|
831
|
+
source,
|
|
832
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
833
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
834
|
+
);
|
|
835
|
+
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
836
|
+
const verifyOutput2 = run("git", ["-C", hubPath2, "worktree", "list"]).stdout;
|
|
837
|
+
console.log(verifyOutput2);
|
|
838
|
+
console.log(`Done! Hub: ${hubPath2}`);
|
|
839
|
+
console.log(`Backup: ${source}.old`);
|
|
840
|
+
console.log();
|
|
841
|
+
console.log("Useful commands:");
|
|
842
|
+
console.log(` git -C ${hubPath2} worktree list`);
|
|
843
|
+
console.log(` git -C ${hubPath2}/main log --oneline -5`);
|
|
844
|
+
process.exit(0);
|
|
845
|
+
}
|
|
347
846
|
const missing = allWorktrees.filter((wt) => {
|
|
348
|
-
if (wt.isBare)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return !existsSync3(actual);
|
|
847
|
+
if (wt.isBare) return false;
|
|
848
|
+
const actual = resolveWorktreePath(wt.path, dest, dirname3(source));
|
|
849
|
+
return !existsSync5(actual);
|
|
352
850
|
});
|
|
353
851
|
if (missing.length > 0) {
|
|
354
|
-
console.log(
|
|
852
|
+
console.log(`
|
|
853
|
+
${yellow("warn:")} The following worktree paths no longer exist:`);
|
|
355
854
|
for (const wt of missing) {
|
|
356
855
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
357
856
|
console.log(` [${branch}] ${wt.path}`);
|
|
358
857
|
}
|
|
359
858
|
console.log();
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
859
|
+
const searchDirs = [dirname3(source)];
|
|
860
|
+
if (dest !== source) {
|
|
861
|
+
searchDirs.push(dest);
|
|
862
|
+
}
|
|
863
|
+
console.log(`${green("==>")} Searching for missing worktrees...`);
|
|
864
|
+
const results = await findMissingWorktrees(
|
|
865
|
+
source,
|
|
866
|
+
searchDirs,
|
|
867
|
+
(msg) => console.log(` ${msg}`)
|
|
868
|
+
);
|
|
869
|
+
const found = results.filter((r) => r.candidates.length > 0);
|
|
870
|
+
const notFound = results.filter((r) => r.candidates.length === 0);
|
|
871
|
+
const multiple = results.filter((r) => r.candidates.length > 1);
|
|
872
|
+
if (notFound.length > 0) {
|
|
873
|
+
console.log(`
|
|
874
|
+
${yellow("Not found:")}`);
|
|
875
|
+
for (const r of notFound) {
|
|
876
|
+
console.log(` [${r.branch}]`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (found.length === 0) {
|
|
880
|
+
console.log(`
|
|
881
|
+
No worktrees could be located. Consider pruning them with 'git worktree prune'.`);
|
|
882
|
+
process.exit(0);
|
|
883
|
+
}
|
|
884
|
+
const selections = /* @__PURE__ */ new Map();
|
|
885
|
+
for (const r of multiple) {
|
|
886
|
+
console.log(`
|
|
887
|
+
${bold2(`[${r.branch}]`)} has multiple candidates:`);
|
|
888
|
+
for (let i = 0; i < r.candidates.length; i++) {
|
|
889
|
+
console.log(` ${i + 1}) ${r.candidates[i]}`);
|
|
890
|
+
}
|
|
891
|
+
process.stdout.write(`Select which to use (1-${r.candidates.length}) [skip]: `);
|
|
892
|
+
const sel = await prompt();
|
|
893
|
+
const idx = parseInt(sel) - 1;
|
|
894
|
+
if (idx >= 0 && idx < r.candidates.length) {
|
|
895
|
+
selections.set(r.branch, r.candidates[idx]);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
console.log(`
|
|
899
|
+
${green("Found:")}`);
|
|
900
|
+
const maxBranchLen = found.reduce((m, r) => Math.max(m, r.branch.length), 0);
|
|
901
|
+
for (const r of found) {
|
|
902
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
903
|
+
const branchCol = bold2(`[${r.branch}]`).padEnd(maxBranchLen + 2 + BOLD.length + RESET.length);
|
|
904
|
+
console.log(` ${branchCol} ${path}`);
|
|
905
|
+
}
|
|
906
|
+
console.log();
|
|
907
|
+
process.stdout.write("Repair these worktrees? [y/N] ");
|
|
908
|
+
const repairAns = await prompt();
|
|
909
|
+
if (!/^[Yy]$/.test(repairAns)) {
|
|
363
910
|
console.log("Aborted.");
|
|
364
911
|
process.stdin.destroy();
|
|
365
912
|
process.exit(0);
|
|
366
913
|
}
|
|
367
|
-
|
|
368
|
-
|
|
914
|
+
console.log();
|
|
915
|
+
for (const r of found) {
|
|
916
|
+
const path = selections.get(r.branch) ?? r.candidates[0];
|
|
917
|
+
await repairWorktree(path, source, (msg) => console.log(`${green("==>")} ${msg}`));
|
|
918
|
+
}
|
|
919
|
+
console.log(`${green("==>")} Repaired ${found.length} worktree(s).
|
|
369
920
|
`);
|
|
370
921
|
const refreshed = await listWorktrees(source);
|
|
371
922
|
allWorktrees.length = 0;
|
|
@@ -381,19 +932,19 @@ ${green("==>")} Reading worktrees from ${source}
|
|
|
381
932
|
const branch = wt.branch ?? `detached-${wt.head.slice(0, 8)}`;
|
|
382
933
|
const safe = sanitizeBranch(branch);
|
|
383
934
|
const isMain = branch === mainBranch;
|
|
384
|
-
const destDir =
|
|
935
|
+
const destDir = join5(dest, safe);
|
|
385
936
|
return { branch, isMain, destDir };
|
|
386
937
|
});
|
|
387
938
|
const maxNameLen = entries.reduce((m, e) => Math.max(m, e.branch.length), 0);
|
|
388
939
|
for (const { branch, isMain, destDir } of entries) {
|
|
389
940
|
const tag = isMain ? yellow("[main]") : "";
|
|
390
941
|
const tagPad = isMain ? ` (labeled ${yellow("[main]")})` : "";
|
|
391
|
-
const nameCol =
|
|
942
|
+
const nameCol = bold2(`[${branch}]`).padEnd(maxNameLen + 2 + BOLD.length + RESET.length);
|
|
392
943
|
const annotation = isMain ? ` (labeled ${yellow("[main]")})`.padEnd(18 + YELLOW.length + RESET.length) : "".padEnd(18);
|
|
393
|
-
console.log(` ${nameCol}${annotation}
|
|
944
|
+
console.log(` ${nameCol}${annotation} \u2192 ${destDir}`);
|
|
394
945
|
}
|
|
395
946
|
console.log();
|
|
396
|
-
console.log(`Hub destination: ${
|
|
947
|
+
console.log(`Hub destination: ${bold2(dest)} (bare repo at ${dest}/.bare)`);
|
|
397
948
|
console.log();
|
|
398
949
|
process.stdout.write("Proceed? [y/N] ");
|
|
399
950
|
const ans = await prompt();
|
|
@@ -403,7 +954,12 @@ ${green("==>")} Reading worktrees from ${source}
|
|
|
403
954
|
process.exit(0);
|
|
404
955
|
}
|
|
405
956
|
console.log();
|
|
406
|
-
const hubPath = await migrate(
|
|
957
|
+
const hubPath = await migrate(
|
|
958
|
+
config,
|
|
959
|
+
{ source: sourcePath, dest: destArg ?? "" },
|
|
960
|
+
(msg) => console.log(`${green("==>")} ${msg}`),
|
|
961
|
+
(msg) => console.log(`${yellow("warn:")} ${msg}`)
|
|
962
|
+
);
|
|
407
963
|
console.log(`${green("==>")} Verifying with git worktree list...`);
|
|
408
964
|
const verifyOutput = run("git", ["-C", hubPath, "worktree", "list"]).stdout;
|
|
409
965
|
console.log(verifyOutput);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-worktree-organize",
|
|
3
|
-
"version": "1.0
|
|
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": "
|
|
14
|
-
"prepublishOnly": "
|
|
15
|
-
"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/
|
|
21
|
+
"@types/node": "^20.0.0",
|
|
21
22
|
"typescript": "^5.0.0",
|
|
22
23
|
"vitest": "^2.0.0"
|
|
23
24
|
},
|