@ajaykumarnpm/talea 0.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 +174 -0
- package/bin/talea.js +11 -0
- package/manifest/talea.repos.json +11 -0
- package/package.json +39 -0
- package/src/adopt.js +983 -0
- package/src/cli.js +219 -0
- package/src/commands/add.js +136 -0
- package/src/commands/adopt.js +441 -0
- package/src/commands/clone.js +233 -0
- package/src/commands/discover.js +194 -0
- package/src/commands/doctor.js +142 -0
- package/src/commands/exec.js +78 -0
- package/src/commands/init.js +106 -0
- package/src/commands/list.js +100 -0
- package/src/commands/manifest.js +190 -0
- package/src/commands/status.js +114 -0
- package/src/commands/sync.js +203 -0
- package/src/commands/tree.js +103 -0
- package/src/commands/upgrade.js +84 -0
- package/src/commands/where.js +67 -0
- package/src/config.js +155 -0
- package/src/docs.js +122 -0
- package/src/git.js +291 -0
- package/src/github.js +208 -0
- package/src/live.js +197 -0
- package/src/log.js +190 -0
- package/src/prompt.js +375 -0
- package/src/select.js +97 -0
- package/src/theme.js +138 -0
- package/src/update.js +119 -0
- package/src/workspace.js +116 -0
- package/templates/.gitkeep +0 -0
package/src/adopt.js
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
// Adopting repos the developer already has.
|
|
2
|
+
//
|
|
3
|
+
// Three situations are really one situation — a repo exists, but not where the
|
|
4
|
+
// catalogue says it should be:
|
|
5
|
+
//
|
|
6
|
+
// 1. cloned by hand somewhere ad-hoc ~/Desktop/eklavya
|
|
7
|
+
// 2. a whole workspace in another shape ~/code/bg/order-integrity
|
|
8
|
+
// 3. the catalogue itself moved it old-owner/... -> new-owner/...
|
|
9
|
+
//
|
|
10
|
+
// In all three the right answer is to MOVE the existing checkout, never to
|
|
11
|
+
// clone a fresh copy beside it. A move keeps branches, stashes, reflog,
|
|
12
|
+
// remotes, the index and any uncommitted work. A re-clone throws all of that
|
|
13
|
+
// away, which is exactly the loss `CLAUDE.md` rule 2 exists to prevent.
|
|
14
|
+
//
|
|
15
|
+
// Nothing here deletes anything, ever. Every refusal leaves the repo where it
|
|
16
|
+
// is and says why.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
chmodSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
lstatSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
realpathSync,
|
|
26
|
+
renameSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
|
|
35
|
+
import { git, pooled } from './git.js';
|
|
36
|
+
import { repoUrl, repoDir, repoGroup } from './config.js';
|
|
37
|
+
|
|
38
|
+
// Directories that never contain a checkout we care about but do contain
|
|
39
|
+
// thousands of files. Skipping them is the difference between a scan that
|
|
40
|
+
// takes a second and one that walks a JVM target tree.
|
|
41
|
+
/** Git output is \n on POSIX but can be \r\n on Windows; a stray \r corrupts a
|
|
42
|
+
* parsed SHA or path just enough to be baffling. */
|
|
43
|
+
export const lines = (text) => String(text).split(/\r?\n/).filter(Boolean);
|
|
44
|
+
|
|
45
|
+
const SCAN_SKIP = new Set([
|
|
46
|
+
'node_modules',
|
|
47
|
+
'target',
|
|
48
|
+
'dist',
|
|
49
|
+
'build',
|
|
50
|
+
'out',
|
|
51
|
+
'vendor',
|
|
52
|
+
'venv',
|
|
53
|
+
'.venv',
|
|
54
|
+
'__pycache__',
|
|
55
|
+
'Library',
|
|
56
|
+
'Applications',
|
|
57
|
+
'AppData',
|
|
58
|
+
'$Recycle.Bin',
|
|
59
|
+
'Windows',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compare two paths the way the filesystem does.
|
|
64
|
+
*
|
|
65
|
+
* Windows and macOS are case-insensitive, so `C:\\Work\\Repo` and `c:\\work\\repo`
|
|
66
|
+
* name the same directory. Comparing them as raw strings made a repo already
|
|
67
|
+
* sitting at its catalogue path look like a stray copy — which would then be
|
|
68
|
+
* "moved" onto itself, or parked as a duplicate of nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function samePath(a, b) {
|
|
71
|
+
if (!a || !b) return false;
|
|
72
|
+
const caseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
|
|
73
|
+
const norm = (p) => (caseInsensitive ? path.resolve(p).toLowerCase() : path.resolve(p));
|
|
74
|
+
return norm(a) === norm(b);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reduce a git remote URL to something comparable.
|
|
79
|
+
*
|
|
80
|
+
* The same repo is reachable by genuinely different URLs — `git@github.com:me/x.git`,
|
|
81
|
+
* `https://github.com/me/x`, `ssh://git@github.com/me/x.git`, and the same again
|
|
82
|
+
* with or without the `.git`. This normalises away scheme, credentials, port,
|
|
83
|
+
* percent-escapes, `.git` and case so that the forms that ARE the same compare
|
|
84
|
+
* equal — otherwise a checkout cloned over HTTPS does not match a catalogue that
|
|
85
|
+
* says SSH, and gets cloned a second time.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeUrl(url) {
|
|
88
|
+
if (!url) return null;
|
|
89
|
+
let s = String(url).trim();
|
|
90
|
+
try {
|
|
91
|
+
s = decodeURIComponent(s);
|
|
92
|
+
} catch {
|
|
93
|
+
// A malformed escape is not worth failing over; compare the raw form.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A remote can be a plain filesystem path, and on Windows the same one
|
|
97
|
+
// arrives spelled two ways: git prints `C:/dir`, Node hands back `C:\dir`.
|
|
98
|
+
// Left alone they normalise to different strings, the checkout stops
|
|
99
|
+
// matching its own catalogue entry, and talea clones a second copy of a
|
|
100
|
+
// repo the developer already has. No real URL contains a backslash.
|
|
101
|
+
s = s.replace(/\\/g, '/');
|
|
102
|
+
|
|
103
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(s);
|
|
104
|
+
if (scheme) s = s.slice(scheme[0].length);
|
|
105
|
+
|
|
106
|
+
// Strip credentials: user@host or user:pass@host.
|
|
107
|
+
s = s.replace(/^[^/@]*@/, '');
|
|
108
|
+
|
|
109
|
+
if (scheme) {
|
|
110
|
+
// Real URL: drop an explicit port.
|
|
111
|
+
s = s.replace(/^([^/:]+):\d+/, '$1');
|
|
112
|
+
} else {
|
|
113
|
+
// scp-style `host:path` — the colon is a separator, not a port.
|
|
114
|
+
s = s.replace(':', '/');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
s = s
|
|
118
|
+
.replace(/\.git$/i, '')
|
|
119
|
+
.replace(/\/+$/, '')
|
|
120
|
+
.replace(/\/{2,}/g, '/');
|
|
121
|
+
|
|
122
|
+
return s.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The last path segment of a remote — the repo name as the server knows it. */
|
|
126
|
+
export function urlRepoName(url) {
|
|
127
|
+
const n = normalizeUrl(url);
|
|
128
|
+
return n ? (n.split('/').pop() ?? null) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every URL form the catalogue knows for this repo. */
|
|
132
|
+
export function catalogueUrls(manifest, repo) {
|
|
133
|
+
const urls = [];
|
|
134
|
+
if (repo.url) urls.push(repo.url);
|
|
135
|
+
for (const protocol of Object.keys(manifest.remotes ?? {})) {
|
|
136
|
+
try {
|
|
137
|
+
urls.push(repoUrl(manifest, { ...repo, url: undefined }, protocol));
|
|
138
|
+
} catch {
|
|
139
|
+
// An unknown protocol in the manifest is `list`'s problem, not ours.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return urls;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Which catalogue repo does this remote belong to?
|
|
147
|
+
*
|
|
148
|
+
* `exact` — the normalised URL matches a form the catalogue knows.
|
|
149
|
+
* `name` — only the repo name matches, because the developer cloned from a
|
|
150
|
+
* host the catalogue does not list (a self-hosted mirror, a fork). Still
|
|
151
|
+
* almost certainly the right repo, but reported as the weaker match
|
|
152
|
+
* so a human sees it before anything moves.
|
|
153
|
+
*/
|
|
154
|
+
export function matchRepo(manifest, repos, originUrl) {
|
|
155
|
+
const norm = normalizeUrl(originUrl);
|
|
156
|
+
if (!norm) return null;
|
|
157
|
+
|
|
158
|
+
for (const repo of repos) {
|
|
159
|
+
if (catalogueUrls(manifest, repo).some((u) => normalizeUrl(u) === norm)) {
|
|
160
|
+
return { repo, confidence: 'exact' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const name = urlRepoName(originUrl);
|
|
165
|
+
for (const repo of repos) {
|
|
166
|
+
if (name && repo.name.toLowerCase() === name) {
|
|
167
|
+
return { repo, confidence: 'name' };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find git checkouts under `roots`, without descending into them.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately never scans $HOME on its own initiative: moving a developer's
|
|
177
|
+
* repositories is not something to do off a guess about where they might be.
|
|
178
|
+
* Callers pass the workspace root by default and anything else explicitly.
|
|
179
|
+
*/
|
|
180
|
+
export function findGitDirs(roots, maxDepth = 3) {
|
|
181
|
+
const found = [];
|
|
182
|
+
const visited = new Set();
|
|
183
|
+
|
|
184
|
+
const walk = (dir, depth) => {
|
|
185
|
+
if (visited.has(dir)) return;
|
|
186
|
+
visited.add(dir);
|
|
187
|
+
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
return; // unreadable (permissions, a dead symlink) — not our business
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (entries.some((e) => e.name === '.git')) {
|
|
196
|
+
found.push(dir);
|
|
197
|
+
return; // a repo is a leaf; never look for repos inside one
|
|
198
|
+
}
|
|
199
|
+
if (depth >= maxDepth) return;
|
|
200
|
+
|
|
201
|
+
for (const e of entries) {
|
|
202
|
+
if (!e.isDirectory() || e.isSymbolicLink()) continue;
|
|
203
|
+
if (SCAN_SKIP.has(e.name)) continue;
|
|
204
|
+
if (e.name.startsWith('.')) continue;
|
|
205
|
+
walk(path.join(dir, e.name), depth + 1);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const root of roots) {
|
|
210
|
+
const abs = path.resolve(root);
|
|
211
|
+
if (existsSync(abs)) walk(abs, 0);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Read `origin` for each candidate directory, a few at a time. */
|
|
217
|
+
export async function readOrigins(dirs, jobs = 8) {
|
|
218
|
+
// Return pooled's own index-ordered results. Pushing from inside the worker
|
|
219
|
+
// ordered candidates by whichever `git` process happened to exit first, which
|
|
220
|
+
// made the choice of "which copy is the real one" a coin flip.
|
|
221
|
+
return pooled(dirs, jobs, async (dir) => {
|
|
222
|
+
const { code, stdout } = await git(['remote', 'get-url', 'origin'], { cwd: dir });
|
|
223
|
+
return { dir, originUrl: code === 0 ? stdout : null };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Is the destination free? An existing non-empty directory belongs to the developer. */
|
|
228
|
+
function destinationBlocked(to) {
|
|
229
|
+
if (!existsSync(to)) return null;
|
|
230
|
+
let entries;
|
|
231
|
+
try {
|
|
232
|
+
entries = readdirSync(to);
|
|
233
|
+
} catch {
|
|
234
|
+
return 'destination exists and cannot be read';
|
|
235
|
+
}
|
|
236
|
+
return entries.length ? 'destination already exists and is not empty' : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reasons a checkout must not be moved. Each one is a case where `rename`
|
|
241
|
+
* would leave git pointing at a path that no longer exists.
|
|
242
|
+
*/
|
|
243
|
+
async function moveBlockers(dir) {
|
|
244
|
+
const dotGit = path.join(dir, '.git');
|
|
245
|
+
try {
|
|
246
|
+
if (lstatSync(dotGit).isFile()) {
|
|
247
|
+
return 'this is a linked worktree (.git is a file) — move its main repo instead';
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
return 'no .git found';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const { code, stdout } = await git(['worktree', 'list', '--porcelain'], { cwd: dir });
|
|
254
|
+
if (code === 0) {
|
|
255
|
+
const outside = strandedWorktrees(dir, stdout);
|
|
256
|
+
if (outside.length) {
|
|
257
|
+
return (
|
|
258
|
+
`${outside.length} extra worktree(s) registered elsewhere — their absolute ` +
|
|
259
|
+
`paths would break: ${outside.join(', ')}`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A path spelled the way the filesystem spells it.
|
|
268
|
+
*
|
|
269
|
+
* Two absolute paths to the same place compare unequal often enough to matter:
|
|
270
|
+
* Windows hands Node the 8.3 short form out of TEMP (`VSSADM~1`) while git
|
|
271
|
+
* prints the long one, and macOS symlinks /tmp to /private/tmp. `path.relative`
|
|
272
|
+
* then reads a nested worktree as living somewhere else entirely, and every
|
|
273
|
+
* repo with one is refused a move it could safely make. Found on windows-latest,
|
|
274
|
+
* where it refused every repo with a `.claude/worktrees/x` in it.
|
|
275
|
+
*
|
|
276
|
+
* Realpath needs the path to exist. Worktree records outlive their directories,
|
|
277
|
+
* so canonicalise the longest ancestor that does and re-attach the rest.
|
|
278
|
+
*/
|
|
279
|
+
export function canonical(p) {
|
|
280
|
+
let dir = path.resolve(p);
|
|
281
|
+
const tail = [];
|
|
282
|
+
for (;;) {
|
|
283
|
+
try {
|
|
284
|
+
return path.join(realpathSync.native(dir), ...tail);
|
|
285
|
+
} catch {
|
|
286
|
+
const parent = path.dirname(dir);
|
|
287
|
+
if (parent === dir) return path.resolve(p);
|
|
288
|
+
tail.unshift(path.basename(dir));
|
|
289
|
+
dir = parent;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Worktrees a move would actually strand.
|
|
296
|
+
*
|
|
297
|
+
* Two kinds are not blockers, and treating them as such refused a move that
|
|
298
|
+
* was perfectly safe:
|
|
299
|
+
*
|
|
300
|
+
* - `prunable` — git already knows the directory is gone, so a stale
|
|
301
|
+
* registration has no path left to break.
|
|
302
|
+
* - one living *inside* the repo (`.claude/worktrees/x`) — it travels with
|
|
303
|
+
* the rename, and `git worktree repair` re-links it afterwards.
|
|
304
|
+
*
|
|
305
|
+
* What is left is a worktree somewhere else on disk, which really would be
|
|
306
|
+
* orphaned. `--porcelain` emits a blank-line-separated block per worktree,
|
|
307
|
+
* the first being the main checkout.
|
|
308
|
+
*/
|
|
309
|
+
export function strandedWorktrees(dir, porcelain) {
|
|
310
|
+
const base = canonical(dir);
|
|
311
|
+
const inside = (p) => {
|
|
312
|
+
const rel = path.relative(base, canonical(p));
|
|
313
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return String(porcelain)
|
|
317
|
+
.split(/\r?\n\s*\r?\n/)
|
|
318
|
+
.map((block) => lines(block))
|
|
319
|
+
.filter((block) => block.length && block[0].startsWith('worktree '))
|
|
320
|
+
.slice(1) // the main checkout is the thing being moved, not a blocker
|
|
321
|
+
.filter((block) => !block.some((l) => l === 'prunable' || l.startsWith('prunable ')))
|
|
322
|
+
.map((block) => block[0].slice('worktree '.length))
|
|
323
|
+
.filter((p) => !inside(p));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* What a second copy holds that the copy being kept does not.
|
|
328
|
+
*
|
|
329
|
+
* Purely informational: nothing here decides whether the copy is touched, only
|
|
330
|
+
* what the developer is told about it. An empty list means the copy is fully
|
|
331
|
+
* redundant and can be thrown away at leisure; a non-empty one means there is
|
|
332
|
+
* something in it worth looking at before it goes.
|
|
333
|
+
*/
|
|
334
|
+
export async function uniqueWork(loser, winner) {
|
|
335
|
+
const blockers = [];
|
|
336
|
+
|
|
337
|
+
const { stdout: dirty } = await git(['status', '--porcelain'], { cwd: loser });
|
|
338
|
+
if (dirty) {
|
|
339
|
+
const n = lines(dirty).length;
|
|
340
|
+
blockers.push(`${n} uncommitted change${n > 1 ? 's' : ''}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { stdout: stashes } = await git(['stash', 'list'], { cwd: loser });
|
|
344
|
+
if (stashes) {
|
|
345
|
+
const n = lines(stashes).length;
|
|
346
|
+
blockers.push(`${n} stash${n > 1 ? 'es' : ''}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Every local branch tip must already be an object the winner knows about.
|
|
350
|
+
// Asked as one batched call: a long-lived checkout can have hundreds of local
|
|
351
|
+
// branches, and one subprocess each is a visible stall before any output.
|
|
352
|
+
const { stdout: heads } = await git(
|
|
353
|
+
['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads'],
|
|
354
|
+
{ cwd: loser },
|
|
355
|
+
);
|
|
356
|
+
const branches = lines(heads).map((line) => {
|
|
357
|
+
const [name, sha] = line.split(' ');
|
|
358
|
+
return { name, sha };
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (branches.length) {
|
|
362
|
+
const { stdout: batch } = await git(['cat-file', '--batch-check'], {
|
|
363
|
+
cwd: winner,
|
|
364
|
+
input: branches.map((b) => b.sha).join('\n') + '\n',
|
|
365
|
+
});
|
|
366
|
+
// One line per input, in order: "<sha> commit <size>" or "<sha> missing".
|
|
367
|
+
const checked = lines(batch);
|
|
368
|
+
const missing = branches
|
|
369
|
+
.filter((_, i) => !/\bcommit\b/.test(checked[i] ?? 'missing'))
|
|
370
|
+
.map((b) => b.name);
|
|
371
|
+
if (missing.length) {
|
|
372
|
+
blockers.push(`commits the other copy does not have (${missing.join(', ')})`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return blockers;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Where second copies are parked. Dot-prefixed, so `findGitDirs` skips it —
|
|
380
|
+
* without that, every run would find the parked copy and park it again. */
|
|
381
|
+
export const DUPLICATES_DIR = '.talea-duplicates';
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A free path under the duplicates area for this repo. Suffixed rather than
|
|
385
|
+
* overwritten, because a second stray copy is not permission to bin the first.
|
|
386
|
+
*/
|
|
387
|
+
export function parkingSpot(root, repo, manifest) {
|
|
388
|
+
// The group's configured dir, not its catalogue key — they are equal today,
|
|
389
|
+
// and would silently diverge the first time one is not.
|
|
390
|
+
const group = repoGroup(repo);
|
|
391
|
+
const groupDir = manifest?.groups?.[group]?.dir ?? group;
|
|
392
|
+
const base = path.join(root, DUPLICATES_DIR, groupDir, repo.dir ?? repo.name);
|
|
393
|
+
if (!existsSync(base)) return base;
|
|
394
|
+
for (let n = 2; n < 100; n++) {
|
|
395
|
+
const candidate = `${base}-${n}`;
|
|
396
|
+
if (!existsSync(candidate)) return candidate;
|
|
397
|
+
}
|
|
398
|
+
return `${base}-${Date.now()}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Turn candidate checkouts into a plan of what to move where.
|
|
403
|
+
*
|
|
404
|
+
* `repos` is the selected subset of the catalogue, so `talea clone -r foo`
|
|
405
|
+
* only ever adopts foo.
|
|
406
|
+
*/
|
|
407
|
+
export async function planAdoptions(manifest, root, repos, candidates) {
|
|
408
|
+
// Group every candidate by the catalogue repo it belongs to, so duplicates
|
|
409
|
+
// are visible as duplicates rather than as two unrelated findings.
|
|
410
|
+
const byRepo = new Map();
|
|
411
|
+
for (const { dir, originUrl } of candidates) {
|
|
412
|
+
const match = matchRepo(manifest, repos, originUrl);
|
|
413
|
+
if (!match) continue;
|
|
414
|
+
const key = match.repo.name;
|
|
415
|
+
if (!byRepo.has(key)) byRepo.set(key, { repo: match.repo, copies: [] });
|
|
416
|
+
byRepo.get(key).copies.push({ dir, originUrl, confidence: match.confidence });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const plans = [];
|
|
420
|
+
|
|
421
|
+
for (const { repo, copies } of byRepo.values()) {
|
|
422
|
+
const to = repoDir(manifest, root, repo);
|
|
423
|
+
|
|
424
|
+
// The directory structure decides the winner: a copy already sitting at the
|
|
425
|
+
// catalogue path stays, and everything else is a duplicate of it. With no
|
|
426
|
+
// copy there, the first one found is moved in and becomes the winner.
|
|
427
|
+
// Order the copies so the winner is a decision, not an accident: a copy
|
|
428
|
+
// already at the catalogue path first, then anything inside the workspace,
|
|
429
|
+
// then by path so the result is stable across runs and machines.
|
|
430
|
+
const rank = (copy) => {
|
|
431
|
+
if (samePath(copy.dir, to)) return 0;
|
|
432
|
+
const rel = path.relative(root, copy.dir);
|
|
433
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? 1 : 2;
|
|
434
|
+
};
|
|
435
|
+
const ordered = [...copies].sort((a, b) => rank(a) - rank(b) || a.dir.localeCompare(b.dir));
|
|
436
|
+
|
|
437
|
+
const atTarget = ordered.find((c) => samePath(c.dir, to));
|
|
438
|
+
const winner = atTarget ?? ordered[0];
|
|
439
|
+
const base = (copy) => ({ repo, from: copy.dir, to, confidence: copy.confidence, originUrl: copy.originUrl });
|
|
440
|
+
|
|
441
|
+
// Where the winner will actually be once this run finishes. Duplicates are
|
|
442
|
+
// checked against that path, not against where the winner sits right now —
|
|
443
|
+
// it is about to move out from under them.
|
|
444
|
+
let winnerEndsAt = to;
|
|
445
|
+
|
|
446
|
+
if (atTarget) {
|
|
447
|
+
plans.push({ ...base(atTarget), action: 'in-place' });
|
|
448
|
+
} else {
|
|
449
|
+
const blocked = destinationBlocked(to) ?? (await moveBlockers(winner.dir));
|
|
450
|
+
if (blocked) {
|
|
451
|
+
plans.push({ ...base(winner), action: 'refuse', reason: blocked });
|
|
452
|
+
winnerEndsAt = null; // the winner is not going anywhere
|
|
453
|
+
} else {
|
|
454
|
+
plans.push({ ...base(winner), action: 'move' });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const copy of ordered) {
|
|
459
|
+
if (copy === winner) continue;
|
|
460
|
+
|
|
461
|
+
// With the winner's own move refused, there is no settled copy to be
|
|
462
|
+
// redundant against. Keep every duplicate until that is sorted out.
|
|
463
|
+
if (winnerEndsAt === null) {
|
|
464
|
+
plans.push({
|
|
465
|
+
...base(copy),
|
|
466
|
+
action: 'refuse',
|
|
467
|
+
reason: `a second copy, but ${path.relative(root, winner.dir) || winner.dir} could not be moved into place first`,
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Parking is still a move, so it needs every guard a move needs. A
|
|
473
|
+
// `git worktree` of this repo shares its origin URL and therefore looks
|
|
474
|
+
// exactly like a second copy — renaming one silently breaks the link
|
|
475
|
+
// back to its main repo and leaves its commits unrooted.
|
|
476
|
+
const blocked = await moveBlockers(copy.dir);
|
|
477
|
+
if (blocked) {
|
|
478
|
+
plans.push({ ...base(copy), action: 'refuse', reason: `a second copy, but ${blocked}` });
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// A second copy is never left lying outside the structure and never
|
|
483
|
+
// deleted. It moves into the workspace's duplicates area, keeping
|
|
484
|
+
// everything it holds, so the tree is tidy and nothing is lost.
|
|
485
|
+
plans.push({
|
|
486
|
+
...base(copy),
|
|
487
|
+
action: 'park',
|
|
488
|
+
to: parkingSpot(root, repo, manifest),
|
|
489
|
+
keeping: winnerEndsAt,
|
|
490
|
+
holds: await uniqueWork(copy.dir, winner.dir),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return plans;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Perform one relocation — into place, or into the duplicates area. `rename`
|
|
500
|
+
* only: it is atomic, it preserves everything, and it cannot half-succeed. A
|
|
501
|
+
* cross-device move is reported rather than turned into a copy, because
|
|
502
|
+
* copying a multi-gigabyte .git and then deleting the original is precisely
|
|
503
|
+
* the "destroy uncommitted work" failure this tool refuses to risk.
|
|
504
|
+
*
|
|
505
|
+
* There is no delete anywhere in this module. A copy that is not wanted is
|
|
506
|
+
* moved aside, and the developer removes the duplicates area themselves.
|
|
507
|
+
*/
|
|
508
|
+
/**
|
|
509
|
+
* Re-link worktrees that lived inside the repo and moved with it.
|
|
510
|
+
*
|
|
511
|
+
* Every link between a repo and its worktrees is an absolute path to where the
|
|
512
|
+
* repo used to be, and `git worktree repair` with no arguments cannot help:
|
|
513
|
+
* it looks for each worktree at its recorded path, which is exactly the path
|
|
514
|
+
* that no longer exists. Handing it the new paths is what the flag is for.
|
|
515
|
+
*/
|
|
516
|
+
function repairNestedWorktrees(from, to) {
|
|
517
|
+
const listed = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
518
|
+
cwd: to,
|
|
519
|
+
encoding: 'utf8',
|
|
520
|
+
});
|
|
521
|
+
if (listed.status !== 0) return;
|
|
522
|
+
|
|
523
|
+
const moved = lines(listed.stdout)
|
|
524
|
+
.filter((l) => l.startsWith('worktree '))
|
|
525
|
+
.map((l) => l.slice('worktree '.length))
|
|
526
|
+
.map((p) => path.relative(canonical(from), canonical(p)))
|
|
527
|
+
.filter((rel) => rel && !rel.startsWith('..') && !path.isAbsolute(rel))
|
|
528
|
+
.map((rel) => path.join(to, rel));
|
|
529
|
+
|
|
530
|
+
if (moved.length) {
|
|
531
|
+
spawnSync('git', ['worktree', 'repair', ...moved], { cwd: to, stdio: 'ignore' });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function executeMove(plan) {
|
|
536
|
+
try {
|
|
537
|
+
mkdirSync(path.dirname(plan.to), { recursive: true });
|
|
538
|
+
renameSync(plan.from, plan.to);
|
|
539
|
+
repairNestedWorktrees(plan.from, plan.to);
|
|
540
|
+
return { ok: true, to: plan.to };
|
|
541
|
+
} catch (err) {
|
|
542
|
+
if (err.code === 'EXDEV') {
|
|
543
|
+
const cmd = process.platform === 'win32' ? 'move' : 'mv';
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
message:
|
|
547
|
+
`on a different filesystem — move it yourself, then re-run:\n` +
|
|
548
|
+
` ${cmd} "${plan.from}" "${plan.to}"`,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
// Windows refuses to rename a directory while any process holds a handle
|
|
552
|
+
// inside it, which an open editor, terminal or virus scanner routinely
|
|
553
|
+
// does. On POSIX the same rename would simply succeed, so the advice has
|
|
554
|
+
// to name the real cause rather than the errno.
|
|
555
|
+
if (['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY', 'ENAMETOOLONG', 'EINVAL'].includes(err.code)) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
message:
|
|
559
|
+
`could not be moved (${err.code}) — something has it open.\n` +
|
|
560
|
+
` Close any editor, terminal or file manager sitting in\n` +
|
|
561
|
+
` ${plan.from}\n and run the command again.`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return { ok: false, message: err.message };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Repairing absolute paths that pointed at the old location.
|
|
570
|
+
//
|
|
571
|
+
// Scope here is deliberately narrow and was chosen by measuring a real
|
|
572
|
+
// machine, not by imagining what might break:
|
|
573
|
+
//
|
|
574
|
+
// ~/.claude/projects/<slug>/ session history + memory, keyed by path slug
|
|
575
|
+
// ~/.claude.json per-project settings, keyed by absolute path
|
|
576
|
+
// a short list of config files that are known to hold absolute paths
|
|
577
|
+
//
|
|
578
|
+
// Measured as NOT affected, and therefore not touched: claude-mem (keyed by
|
|
579
|
+
// project *name*, so a move is invisible to it), ~/.claude/settings.json,
|
|
580
|
+
// Cursor/VS Code settings.json, and the workspace .mcp.json. IDE "recent
|
|
581
|
+
// projects" state is left alone too — it self-heals, and writing it while the
|
|
582
|
+
// IDE is running loses the write.
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
/** How Claude Code names a project directory: every non-alphanumeric becomes `-`. */
|
|
586
|
+
export const claudeSlug = (p) => path.resolve(p).replace(/[^a-zA-Z0-9]/g, '-');
|
|
587
|
+
|
|
588
|
+
const claudeHome = () => path.join(os.homedir(), '.claude');
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Is a Claude Code process running? It holds ~/.claude.json in memory and
|
|
592
|
+
* writes the whole file back when it exits, so an edit made underneath a live
|
|
593
|
+
* session is silently reverted. Worth warning about; not worth blocking on.
|
|
594
|
+
*/
|
|
595
|
+
export function claudeMaybeRunning() {
|
|
596
|
+
if (process.platform === 'win32') {
|
|
597
|
+
const res = spawnSync('tasklist', ['/FI', 'IMAGENAME eq claude.exe', '/NH'], {
|
|
598
|
+
stdio: 'pipe',
|
|
599
|
+
encoding: 'utf8',
|
|
600
|
+
});
|
|
601
|
+
return res.status === 0 && /claude\.exe/i.test(res.stdout ?? '');
|
|
602
|
+
}
|
|
603
|
+
const res = spawnSync('pgrep', ['-x', 'claude'], { stdio: 'pipe' });
|
|
604
|
+
return res.status === 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Move a project's session history and memory to its new path slug.
|
|
609
|
+
* If a directory already exists for the new path (because someone worked there
|
|
610
|
+
* before the move), the two are merged rather than either being replaced.
|
|
611
|
+
*/
|
|
612
|
+
export function moveClaudeSessions(from, to) {
|
|
613
|
+
if (samePath(from, to)) return { changed: false };
|
|
614
|
+
const oldDir = path.join(claudeHome(), 'projects', claudeSlug(from));
|
|
615
|
+
const newDir = path.join(claudeHome(), 'projects', claudeSlug(to));
|
|
616
|
+
if (!existsSync(oldDir)) return { changed: false };
|
|
617
|
+
|
|
618
|
+
if (!existsSync(newDir)) {
|
|
619
|
+
try {
|
|
620
|
+
mkdirSync(path.dirname(newDir), { recursive: true });
|
|
621
|
+
renameSync(oldDir, newDir);
|
|
622
|
+
return { changed: true, merged: false, dir: newDir };
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return { changed: false, error: err.message };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Merge: carry over anything the new location does not already have. Never
|
|
629
|
+
// overwrite — a name collision means the new side has its own history.
|
|
630
|
+
let moved = 0;
|
|
631
|
+
let kept = 0;
|
|
632
|
+
for (const entry of readdirSync(oldDir)) {
|
|
633
|
+
const src = path.join(oldDir, entry);
|
|
634
|
+
const dest = path.join(newDir, entry);
|
|
635
|
+
if (existsSync(dest)) {
|
|
636
|
+
kept++;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
renameSync(src, dest);
|
|
641
|
+
moved++;
|
|
642
|
+
} catch {
|
|
643
|
+
kept++;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { changed: moved > 0, merged: true, moved, kept, dir: newDir, leftBehind: oldDir };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Write JSON through a temp file so a crash can never leave it truncated. */
|
|
650
|
+
function writeJsonAtomic(file, data) {
|
|
651
|
+
const tmp = `${file}.talea-tmp`;
|
|
652
|
+
try {
|
|
653
|
+
writeAtomicInner(file, tmp, data);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
// Windows can refuse the rename while another process holds the file open —
|
|
656
|
+
// exactly what claudeMaybeRunning() warns about. Do not leave litter next
|
|
657
|
+
// to the developer's config.
|
|
658
|
+
try {
|
|
659
|
+
rmSync(tmp, { force: true });
|
|
660
|
+
} catch {
|
|
661
|
+
// Nothing further to try.
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function writeAtomicInner(file, tmp, data) {
|
|
668
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
669
|
+
// Carry the original mode across. ~/.claude.json holds account data and a
|
|
670
|
+
// developer may well have chmod 600'd it; a fresh temp file would default to
|
|
671
|
+
// 0644 and quietly widen it on the rename.
|
|
672
|
+
try {
|
|
673
|
+
chmodSync(tmp, statSync(file).mode & 0o777);
|
|
674
|
+
} catch {
|
|
675
|
+
// No original to copy from, or a filesystem without modes — not fatal.
|
|
676
|
+
}
|
|
677
|
+
renameSync(tmp, file);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Re-key this project's entry in ~/.claude.json from the old path to the new.
|
|
682
|
+
* An entry already present at the new path wins — it is the newer of the two.
|
|
683
|
+
*/
|
|
684
|
+
export function rekeyClaudeJson(from, to) {
|
|
685
|
+
// Same path in and out: there is nothing to re-key. Without this the
|
|
686
|
+
// "already an entry at the new path" branch below deletes the live one.
|
|
687
|
+
if (samePath(from, to)) return { changed: false };
|
|
688
|
+
|
|
689
|
+
const file = path.join(os.homedir(), '.claude.json');
|
|
690
|
+
if (!existsSync(file)) return { changed: false };
|
|
691
|
+
|
|
692
|
+
let data;
|
|
693
|
+
try {
|
|
694
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
695
|
+
} catch (err) {
|
|
696
|
+
return { changed: false, error: `could not parse ~/.claude.json (${err.message})` };
|
|
697
|
+
}
|
|
698
|
+
if (!data?.projects) return { changed: false };
|
|
699
|
+
|
|
700
|
+
const oldKey = path.resolve(from);
|
|
701
|
+
const newKey = path.resolve(to);
|
|
702
|
+
if (!(oldKey in data.projects)) return { changed: false };
|
|
703
|
+
|
|
704
|
+
if (newKey in data.projects) {
|
|
705
|
+
delete data.projects[oldKey];
|
|
706
|
+
try {
|
|
707
|
+
writeJsonAtomic(file, data);
|
|
708
|
+
return { changed: true, note: 'dropped the stale entry; the new path already had one' };
|
|
709
|
+
} catch (err) {
|
|
710
|
+
return { changed: false, error: err.message };
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
data.projects[newKey] = data.projects[oldKey];
|
|
715
|
+
delete data.projects[oldKey];
|
|
716
|
+
try {
|
|
717
|
+
writeJsonAtomic(file, data);
|
|
718
|
+
return { changed: true };
|
|
719
|
+
} catch (err) {
|
|
720
|
+
return { changed: false, error: err.message };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Files known to carry absolute paths, checked in the workspace root and in
|
|
725
|
+
// the repo's new home. The list is measured, not guessed: everything here was
|
|
726
|
+
// found holding a real absolute path on a working developer machine, or is the
|
|
727
|
+
// documented place a tool keeps one.
|
|
728
|
+
//
|
|
729
|
+
// Deliberately absent, with reasons, in `unfixablePaths()` below.
|
|
730
|
+
const CONFIG_BY_NAME = new Set([
|
|
731
|
+
'CLAUDE.md',
|
|
732
|
+
'AGENTS.md',
|
|
733
|
+
'.mcp.json',
|
|
734
|
+
'.envrc',
|
|
735
|
+
'bruno.json',
|
|
736
|
+
'.cursorrules',
|
|
737
|
+
]);
|
|
738
|
+
|
|
739
|
+
const CONFIG_BY_PATTERN = [
|
|
740
|
+
/\.code-workspace$/,
|
|
741
|
+
/^\.env(\..+)?$/,
|
|
742
|
+
/^docker-compose.*\.ya?ml$/,
|
|
743
|
+
];
|
|
744
|
+
|
|
745
|
+
// Config directories, searched recursively — `.idea/runConfigurations/*.xml`
|
|
746
|
+
// and `.claude/commands/*.md` are both a level down, and a flat glob missed
|
|
747
|
+
// them.
|
|
748
|
+
const CONFIG_SUBDIRS = {
|
|
749
|
+
'.idea': /\.(xml|iml)$/,
|
|
750
|
+
'.vscode': /\.(json|code-snippets)$/,
|
|
751
|
+
'.claude': /\.(json|md)$/,
|
|
752
|
+
'.devcontainer': /\.json$/,
|
|
753
|
+
'.cursor': /\.(json|mdc)$/,
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// Never rewritten even when it matches: .talea.json records `adopted[].from`,
|
|
757
|
+
// the literal old path, which is the record `--fix-paths` replays from.
|
|
758
|
+
const NEVER_REWRITE = new Set(['.talea.json']);
|
|
759
|
+
|
|
760
|
+
function walkConfigDir(dir, pattern, depth = 0) {
|
|
761
|
+
const found = [];
|
|
762
|
+
if (depth > 2 || !existsSync(dir)) return found;
|
|
763
|
+
let entries;
|
|
764
|
+
try {
|
|
765
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
766
|
+
} catch {
|
|
767
|
+
return found;
|
|
768
|
+
}
|
|
769
|
+
for (const entry of entries) {
|
|
770
|
+
const full = path.join(dir, entry.name);
|
|
771
|
+
if (entry.isDirectory()) found.push(...walkConfigDir(full, pattern, depth + 1));
|
|
772
|
+
else if (pattern.test(entry.name)) found.push(full);
|
|
773
|
+
}
|
|
774
|
+
return found;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function configFilesUnder(dir) {
|
|
778
|
+
const files = [];
|
|
779
|
+
if (!existsSync(dir)) return files;
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
783
|
+
if (entry.isDirectory()) continue;
|
|
784
|
+
if (NEVER_REWRITE.has(entry.name)) continue;
|
|
785
|
+
if (CONFIG_BY_NAME.has(entry.name) || CONFIG_BY_PATTERN.some((re) => re.test(entry.name))) {
|
|
786
|
+
files.push(path.join(dir, entry.name));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} catch {
|
|
790
|
+
// unreadable directory — nothing to offer
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const [sub, pattern] of Object.entries(CONFIG_SUBDIRS)) {
|
|
794
|
+
files.push(...walkConfigDir(path.join(dir, sub), pattern));
|
|
795
|
+
}
|
|
796
|
+
return files;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The spellings one absolute path takes across config files.
|
|
801
|
+
*
|
|
802
|
+
* On Windows the same directory is written `C:\\src\\repo` by one tool,
|
|
803
|
+
* `C:/src/repo` by another (JetBrains, and anything that just uses forward
|
|
804
|
+
* slashes), and `C:\\\\src\\\\repo` inside JSON, where the backslash is escaped.
|
|
805
|
+
* Shell startup files write `~/src/repo` or `$HOME/src/repo` — which is how a
|
|
806
|
+
* broken `cd` alias went unnoticed until it was looked for directly.
|
|
807
|
+
*
|
|
808
|
+
* Keyed by kind, so a rewrite can pair like with like: a `$HOME`-relative
|
|
809
|
+
* reference is replaced by a `$HOME`-relative one, not by an absolute path.
|
|
810
|
+
*/
|
|
811
|
+
function spellingsOf(p) {
|
|
812
|
+
const native = path.resolve(p);
|
|
813
|
+
const forms = {
|
|
814
|
+
native,
|
|
815
|
+
forward: native.split('\\').join('/'),
|
|
816
|
+
jsonEscaped: native.split('\\').join('\\\\'),
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const home = os.homedir();
|
|
820
|
+
if (home && native.startsWith(home + path.sep)) {
|
|
821
|
+
const rest = native.slice(home.length).split('\\').join('/');
|
|
822
|
+
forms.tilde = `~${rest}`;
|
|
823
|
+
forms.homeVar = `$HOME${rest}`;
|
|
824
|
+
}
|
|
825
|
+
return forms;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Every distinct spelling, longest first. Used to find references. */
|
|
829
|
+
export function pathSpellings(p) {
|
|
830
|
+
return [...new Set(Object.values(spellingsOf(p)))].sort((a, b) => b.length - a.length);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Matched from/to spellings for a rewrite, paired by kind rather than by
|
|
835
|
+
* position — sorting two independent lists by length can pair a `~` form on one
|
|
836
|
+
* side with an absolute form on the other. A kind present on only one side is
|
|
837
|
+
* dropped: not rewriting beats rewriting into the wrong shape.
|
|
838
|
+
*/
|
|
839
|
+
export function pathSpellingPairs(from, to) {
|
|
840
|
+
const f = spellingsOf(from);
|
|
841
|
+
const t = spellingsOf(to);
|
|
842
|
+
const seen = new Set();
|
|
843
|
+
return Object.keys(f)
|
|
844
|
+
.filter((kind) => t[kind] !== undefined)
|
|
845
|
+
.map((kind) => ({ from: f[kind], to: t[kind] }))
|
|
846
|
+
.filter((pair) => {
|
|
847
|
+
if (seen.has(pair.from)) return false;
|
|
848
|
+
seen.add(pair.from);
|
|
849
|
+
return true;
|
|
850
|
+
})
|
|
851
|
+
.sort((a, b) => b.from.length - a.from.length);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Characters that can continue a path segment. If one of these follows a match,
|
|
855
|
+
// the match was a prefix of a longer path, not the path itself — the catalogue
|
|
856
|
+
// really does contain `V2/CEP-ONLINE-PORTAL-V2` alongside
|
|
857
|
+
// `V2/CEP-ONLINE-PORTAL-V2-UI`, and rewriting the first inside the second points
|
|
858
|
+
// an IDE at a directory that does not exist.
|
|
859
|
+
const CONTINUES_PATH = /[A-Za-z0-9_.\-~+@]/;
|
|
860
|
+
|
|
861
|
+
const atBoundary = (text, index, length) => {
|
|
862
|
+
const next = text[index + length];
|
|
863
|
+
return next === undefined || !CONTINUES_PATH.test(next);
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
/** Replace `needle` with `replacement`, but only where it is a whole path. */
|
|
867
|
+
export function replaceAtBoundary(text, needle, replacement) {
|
|
868
|
+
if (!needle) return text;
|
|
869
|
+
let out = '';
|
|
870
|
+
let i = 0;
|
|
871
|
+
for (;;) {
|
|
872
|
+
const at = text.indexOf(needle, i);
|
|
873
|
+
if (at === -1) return out + text.slice(i);
|
|
874
|
+
out += text.slice(i, at);
|
|
875
|
+
out += atBoundary(text, at, needle.length) ? replacement : needle;
|
|
876
|
+
i = at + needle.length;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** How many whole-path occurrences of `needle` are in `text`. */
|
|
881
|
+
export function countAtBoundary(text, needle) {
|
|
882
|
+
if (!needle) return 0;
|
|
883
|
+
let n = 0;
|
|
884
|
+
let i = 0;
|
|
885
|
+
for (;;) {
|
|
886
|
+
const at = text.indexOf(needle, i);
|
|
887
|
+
if (at === -1) return n;
|
|
888
|
+
if (atBoundary(text, at, needle.length)) n++;
|
|
889
|
+
i = at + needle.length;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Config files that contain the old absolute path, in any of its spellings. */
|
|
894
|
+
export function findConfigHits(dirs, from) {
|
|
895
|
+
const needles = pathSpellings(from);
|
|
896
|
+
const hits = [];
|
|
897
|
+
for (const file of new Set(dirs.flatMap(configFilesUnder))) {
|
|
898
|
+
try {
|
|
899
|
+
const text = readFileSync(file, 'utf8');
|
|
900
|
+
const count = needles.reduce((n, needle) => n + countAtBoundary(text, needle), 0);
|
|
901
|
+
if (count > 0) hits.push({ file, count });
|
|
902
|
+
} catch {
|
|
903
|
+
// binary or unreadable — not something to rewrite blind
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return hits;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Literal, non-regex replacement of one absolute path with another. */
|
|
910
|
+
export function rewriteConfigFile(file, from, to) {
|
|
911
|
+
try {
|
|
912
|
+
const text = readFileSync(file, 'utf8');
|
|
913
|
+
let next = text;
|
|
914
|
+
for (const pair of pathSpellingPairs(from, to)) {
|
|
915
|
+
next = replaceAtBoundary(next, pair.from, pair.to);
|
|
916
|
+
}
|
|
917
|
+
if (next === text) return { changed: false };
|
|
918
|
+
writeFileSync(file, next);
|
|
919
|
+
return { changed: true };
|
|
920
|
+
} catch (err) {
|
|
921
|
+
return { changed: false, error: err.message };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Places that still hold the old path and that this tool will NOT rewrite.
|
|
928
|
+
*
|
|
929
|
+
* Each was measured on a real machine. The rule for being on this list rather
|
|
930
|
+
* than being fixed: it is a log, a cache, a historical record, or it is keyed
|
|
931
|
+
* by something we cannot recompute. Rewriting a log rewrites history;
|
|
932
|
+
* rewriting a cache is pointless; and a hash-keyed store cannot be renamed
|
|
933
|
+
* without reproducing the hash.
|
|
934
|
+
*/
|
|
935
|
+
export function unfixablePaths(from) {
|
|
936
|
+
const home = os.homedir();
|
|
937
|
+
const needles = pathSpellings(from);
|
|
938
|
+
const out = [];
|
|
939
|
+
|
|
940
|
+
const countIn = (file) => {
|
|
941
|
+
try {
|
|
942
|
+
const text = readFileSync(file, 'utf8');
|
|
943
|
+
return needles.reduce((n, needle) => n + countAtBoundary(text, needle), 0);
|
|
944
|
+
} catch {
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// Shell startup files: `cd` aliases pointing at the old location. Not
|
|
950
|
+
// rewritten because a bad edit to a login shell config breaks every new
|
|
951
|
+
// terminal — but reported with the line numbers, because a stale alias here
|
|
952
|
+
// is the failure a developer actually notices first.
|
|
953
|
+
for (const name of ['.zshrc', '.bashrc', '.bash_profile', '.zprofile', '.profile', '.zshenv']) {
|
|
954
|
+
const file = path.join(home, name);
|
|
955
|
+
if (!existsSync(file)) continue;
|
|
956
|
+
let text;
|
|
957
|
+
try {
|
|
958
|
+
text = readFileSync(file, 'utf8');
|
|
959
|
+
} catch {
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const hits = [];
|
|
963
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
964
|
+
if (needles.some((needle) => countAtBoundary(line, needle) > 0)) hits.push(i + 1);
|
|
965
|
+
});
|
|
966
|
+
if (hits.length) {
|
|
967
|
+
out.push({
|
|
968
|
+
what: `~/${name}`,
|
|
969
|
+
detail: `line${hits.length > 1 ? 's' : ''} ${hits.join(', ')}`,
|
|
970
|
+
why: 'shell aliases — edit by hand; a bad edit here breaks every new terminal',
|
|
971
|
+
actionable: true,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const note = (rel, why) => {
|
|
977
|
+
const file = path.join(home, rel);
|
|
978
|
+
if (existsSync(file) && countIn(file) > 0) out.push({ what: `~/${rel}`, why, actionable: false });
|
|
979
|
+
};
|
|
980
|
+
note('.claude/history.jsonl', 'a log of commands you ran — rewriting it rewrites history');
|
|
981
|
+
|
|
982
|
+
return out;
|
|
983
|
+
}
|