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