@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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { expandHome, loadState, saveState } from '../config.js';
|
|
6
|
+
import { c, fail, glyph, heading, icon, info, ok, plain, skip, summary, warn } from '../log.js';
|
|
7
|
+
import { requireCatalogue, requireWorkspace, selectRepos } from '../workspace.js';
|
|
8
|
+
import {
|
|
9
|
+
DUPLICATES_DIR,
|
|
10
|
+
claudeMaybeRunning,
|
|
11
|
+
executeMove,
|
|
12
|
+
parkingSpot,
|
|
13
|
+
findConfigHits,
|
|
14
|
+
findGitDirs,
|
|
15
|
+
moveClaudeSessions,
|
|
16
|
+
planAdoptions,
|
|
17
|
+
readOrigins,
|
|
18
|
+
rekeyClaudeJson,
|
|
19
|
+
rewriteConfigFile,
|
|
20
|
+
unfixablePaths,
|
|
21
|
+
} from '../adopt.js';
|
|
22
|
+
import { git } from '../git.js';
|
|
23
|
+
|
|
24
|
+
export const help = `
|
|
25
|
+
${c.bold('talea adopt')} — move repos you already have into the right place
|
|
26
|
+
|
|
27
|
+
${c.dim('talea adopt')} show what would move
|
|
28
|
+
${c.dim('talea adopt --apply')} actually move it
|
|
29
|
+
${c.dim('talea adopt --from ~/Desktop')} also look there for checkouts
|
|
30
|
+
${c.dim('talea adopt -r eklavya --apply')}
|
|
31
|
+
|
|
32
|
+
Repos are matched by their ${c.bold('git remote')}, never by folder name, so a checkout
|
|
33
|
+
called ${c.dim('~/tmp/clone2')} is still recognised as the repo it holds. A matched repo in the wrong place is
|
|
34
|
+
${c.bold('moved')}, never re-cloned — the move keeps every branch, stash, reflog entry
|
|
35
|
+
and uncommitted change exactly as it is.
|
|
36
|
+
|
|
37
|
+
A repo that cannot be moved safely (a linked worktree, extra worktrees, an
|
|
38
|
+
occupied destination, another filesystem) is left alone and the reason is
|
|
39
|
+
printed.
|
|
40
|
+
|
|
41
|
+
When the same repo is found twice, the copy at the catalogue path wins and the
|
|
42
|
+
other moves into ${c.bold(DUPLICATES_DIR)}/ — never deleted, never left outside the tree.
|
|
43
|
+
Whatever it holds (branches, stashes, uncommitted work) comes with it, and is
|
|
44
|
+
listed so you can decide what to do with it.
|
|
45
|
+
|
|
46
|
+
After a move, absolute paths that pointed at the old location are repaired:
|
|
47
|
+
Claude Code session history and memory, its per-project settings, and config
|
|
48
|
+
files in the workspace and the repo (.idea, .vscode, .claude, CLAUDE.md).
|
|
49
|
+
|
|
50
|
+
Options
|
|
51
|
+
-g, --group <names> restrict to groups
|
|
52
|
+
-r, --repo <names> restrict to repos
|
|
53
|
+
--from <path> extra folder to search (repeatable, remembered)
|
|
54
|
+
--apply perform the moves (default is a dry run)
|
|
55
|
+
--fix-paths re-repair config for repos already adopted, moving nothing
|
|
56
|
+
-j, --jobs <n> parallel git calls (default 8)
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scan, match, and optionally move. Exported so `clone` can adopt before it
|
|
61
|
+
* clones — a repo the developer already has must never be cloned twice.
|
|
62
|
+
*/
|
|
63
|
+
export async function planFor({ manifest, root, repos, scanRoots, jobs = 8 }) {
|
|
64
|
+
const dirs = findGitDirs(scanRoots, 3);
|
|
65
|
+
const candidates = await readOrigins(dirs, jobs);
|
|
66
|
+
const plans = await planAdoptions(manifest, root, repos, candidates);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
plans,
|
|
70
|
+
moves: plans.filter((p) => p.action === 'move'),
|
|
71
|
+
parks: plans.filter((p) => p.action === 'park'),
|
|
72
|
+
refused: plans.filter((p) => p.action === 'refuse'),
|
|
73
|
+
inPlace: plans.filter((p) => p.action === 'in-place'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Perform the moves and repair the paths that pointed at the old locations.
|
|
79
|
+
*
|
|
80
|
+
* Every successful move is appended to `.talea.json`, both as an audit trail
|
|
81
|
+
* and so `--fix-paths` can repair the config again later. That matters because
|
|
82
|
+
* a running Claude Code process rewrites ~/.claude.json when it exits and can
|
|
83
|
+
* revert the repair — and by then the repo is in place, so a plain re-run would
|
|
84
|
+
* find nothing to do.
|
|
85
|
+
*/
|
|
86
|
+
export async function applyMoves(root, moves, parks = [], manifest) {
|
|
87
|
+
const results = [];
|
|
88
|
+
const warnedAboutClaude = moves.length > 0 && claudeMaybeRunning();
|
|
89
|
+
|
|
90
|
+
for (const plan of moves) {
|
|
91
|
+
const res = executeMove(plan);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
fail(`${c.bold(plan.repo.name)}\n ${c.dim(res.message)}`);
|
|
94
|
+
results.push({ plan, ok: false });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
ok(
|
|
98
|
+
`${c.bold(plan.repo.name)} ${c.dim(shorten(plan.from))} ${icon.arrow} ${c.dim(path.relative(root, plan.to))}`,
|
|
99
|
+
);
|
|
100
|
+
const repairs = repairPaths(root, plan);
|
|
101
|
+
results.push({ plan, ok: true, repairs });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Second copies move only once the winner is actually in place. Ordering
|
|
105
|
+
// alone does not achieve that — a move that failed leaves the catalogue path
|
|
106
|
+
// empty, and parking on top of that strands one copy and hides the other, so
|
|
107
|
+
// the winner's arrival is checked rather than assumed.
|
|
108
|
+
const parked = [];
|
|
109
|
+
const failedMoves = new Set(
|
|
110
|
+
results.filter((r) => !r.ok).map((r) => path.resolve(r.plan.to)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
for (const plan of parks) {
|
|
114
|
+
const winner = path.resolve(plan.keeping);
|
|
115
|
+
if (failedMoves.has(winner) || !existsSync(winner)) {
|
|
116
|
+
warn(
|
|
117
|
+
`${c.bold(plan.repo.name)} second copy left where it is — ` +
|
|
118
|
+
`${path.relative(root, plan.keeping) || plan.keeping} is not in place\n ${c.dim(shorten(plan.from))}`,
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The spot is chosen now, not at plan time: two copies of one repo planned
|
|
124
|
+
// in the same run would otherwise be handed the identical path, and the
|
|
125
|
+
// -2 suffixing would never fire.
|
|
126
|
+
const res = executeMove({ ...plan, to: parkingSpot(root, plan.repo, manifest) });
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
fail(`${c.bold(plan.repo.name)} second copy\n ${c.dim(res.message)}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
parked.push({ ...plan, to: res.to ?? plan.to });
|
|
132
|
+
const landed = parked[parked.length - 1];
|
|
133
|
+
ok(
|
|
134
|
+
`${c.bold(plan.repo.name)} ${c.dim(shorten(plan.from))} ${icon.arrow} ${c.dim(path.relative(root, landed.to))}`,
|
|
135
|
+
);
|
|
136
|
+
plain(
|
|
137
|
+
` ${c.dim(glyph.pending)} second copy — ${c.bold(path.relative(root, plan.keeping) || plan.keeping)} is the one in use`,
|
|
138
|
+
);
|
|
139
|
+
plain(
|
|
140
|
+
` ${c.dim(glyph.pending)} ${
|
|
141
|
+
plan.holds.length
|
|
142
|
+
? c.yellow(`holds ${plan.holds.join(' and ')}`)
|
|
143
|
+
: c.dim('fully redundant — every commit is in the copy above')
|
|
144
|
+
}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const done = results.filter((r) => r.ok);
|
|
149
|
+
if (done.length) {
|
|
150
|
+
const state = loadState(root);
|
|
151
|
+
const at = new Date().toISOString();
|
|
152
|
+
saveState(root, {
|
|
153
|
+
...state,
|
|
154
|
+
adopted: [
|
|
155
|
+
...(state.adopted ?? []),
|
|
156
|
+
...done.map((r) => ({ repo: r.plan.repo.name, from: r.plan.from, to: r.plan.to, at })),
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { results, parked, warnedAboutClaude };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Which of the rewritten files are tracked by git, and so now show a diff. */
|
|
165
|
+
async function trackedAmong(files) {
|
|
166
|
+
const tracked = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const { code } = await git(['ls-files', '--error-unmatch', '--', path.basename(file)], {
|
|
169
|
+
cwd: path.dirname(file),
|
|
170
|
+
});
|
|
171
|
+
if (code === 0) tracked.push(file);
|
|
172
|
+
}
|
|
173
|
+
return tracked;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Report the places that still name the old location and that this tool will
|
|
178
|
+
* not touch, so "nothing was missed" is a statement the developer can check
|
|
179
|
+
* rather than take on trust.
|
|
180
|
+
*/
|
|
181
|
+
async function reportLeftovers(root, applied) {
|
|
182
|
+
const rewritten = applied.results
|
|
183
|
+
.filter((r) => r.ok)
|
|
184
|
+
.flatMap((r) => r.repairs?.rewritten?.map((h) => h.file) ?? []);
|
|
185
|
+
|
|
186
|
+
const tracked = await trackedAmong(rewritten);
|
|
187
|
+
if (tracked.length) {
|
|
188
|
+
plain('');
|
|
189
|
+
warn(
|
|
190
|
+
`${tracked.length} rewritten file${tracked.length > 1 ? 's are' : ' is'} tracked by git — you now have a diff to review:`,
|
|
191
|
+
);
|
|
192
|
+
for (const file of tracked) plain(c.dim(` ${path.relative(root, file)}`));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const moved = applied.results.filter((r) => r.ok).map((r) => r.plan);
|
|
196
|
+
const leftovers = moved.flatMap((plan) => unfixablePaths(plan.from));
|
|
197
|
+
if (!leftovers.length) return;
|
|
198
|
+
|
|
199
|
+
const needsYou = leftovers.filter((l) => l.actionable);
|
|
200
|
+
const informational = leftovers.filter((l) => !l.actionable);
|
|
201
|
+
|
|
202
|
+
if (needsYou.length) {
|
|
203
|
+
plain('');
|
|
204
|
+
heading('Still pointing at the old location — these need you');
|
|
205
|
+
for (const l of needsYou) {
|
|
206
|
+
plain(` ${c.yellow('!')} ${c.bold(l.what)} ${c.dim(l.detail ?? '')}`);
|
|
207
|
+
plain(` ${c.dim(l.why)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (informational.length) {
|
|
212
|
+
plain('');
|
|
213
|
+
plain(c.dim(' Left alone on purpose:'));
|
|
214
|
+
for (const l of informational) plain(c.dim(` ${l.what} — ${l.why}`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Re-apply the path repair for moves already recorded, moving nothing.
|
|
220
|
+
* The escape hatch for a repair that was reverted underneath us.
|
|
221
|
+
*/
|
|
222
|
+
export function refixPaths(root) {
|
|
223
|
+
const history = loadState(root).adopted ?? [];
|
|
224
|
+
if (!history.length) return { count: 0 };
|
|
225
|
+
for (const entry of history) {
|
|
226
|
+
plain(` ${c.bold(entry.repo)} ${c.dim(`${shorten(entry.from)} ${glyph.arrow} ${path.relative(root, entry.to)}`)}`);
|
|
227
|
+
repairPaths(root, { repo: { name: entry.repo }, from: entry.from, to: entry.to });
|
|
228
|
+
for (const l of unfixablePaths(entry.from)) {
|
|
229
|
+
plain(
|
|
230
|
+
` ${l.actionable ? c.yellow('!') : c.dim('·')} ${l.what} ${c.dim(l.detail ?? '')} ${c.dim(`— ${l.why}`)}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { count: history.length };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Fix the absolute paths that pointed at the repo's old home. */
|
|
238
|
+
function repairPaths(root, plan) {
|
|
239
|
+
const done = [];
|
|
240
|
+
|
|
241
|
+
const sessions = moveClaudeSessions(plan.from, plan.to);
|
|
242
|
+
if (sessions.changed) {
|
|
243
|
+
done.push(
|
|
244
|
+
sessions.merged
|
|
245
|
+
? `Claude history merged (${sessions.moved} moved, ${sessions.kept} already there)`
|
|
246
|
+
: 'Claude session history and memory',
|
|
247
|
+
);
|
|
248
|
+
} else if (sessions.error) {
|
|
249
|
+
done.push(c.yellow(`Claude history not moved: ${sessions.error}`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const settings = rekeyClaudeJson(plan.from, plan.to);
|
|
253
|
+
if (settings.changed) {
|
|
254
|
+
done.push(`~/.claude.json project entry${settings.note ? ` ${c.dim(`(${settings.note})`)}` : ''}`);
|
|
255
|
+
} else if (settings.error) {
|
|
256
|
+
done.push(c.yellow(`~/.claude.json not updated: ${settings.error}`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const rewritten = [];
|
|
260
|
+
for (const hit of findConfigHits([root, plan.to], plan.from)) {
|
|
261
|
+
const res = rewriteConfigFile(hit.file, plan.from, plan.to);
|
|
262
|
+
if (res.changed) {
|
|
263
|
+
rewritten.push(hit);
|
|
264
|
+
done.push(`${path.relative(root, hit.file)} ${c.dim(`(${hit.count} path${hit.count > 1 ? 's' : ''})`)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const line of done) plain(` ${c.dim(glyph.pending)} ${line}`);
|
|
269
|
+
|
|
270
|
+
// Some of those config files are committed. Say so — the developer now has a
|
|
271
|
+
// diff to review, and finding it by surprise later is worse.
|
|
272
|
+
return { done, rewritten };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// os.homedir(), not process.env.HOME: HOME is unset on Windows, and
|
|
276
|
+
// String.replace(undefined, '~') would replace the literal text "undefined".
|
|
277
|
+
const shorten = (p) => {
|
|
278
|
+
const home = os.homedir();
|
|
279
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Turn `--from a,b` into absolute paths.
|
|
284
|
+
*
|
|
285
|
+
* Empty segments are dropped BEFORE resolving: `path.resolve('')` is the
|
|
286
|
+
* current directory, so a trailing comma or `--from ""` would otherwise add
|
|
287
|
+
* cwd to the remembered scan paths and quietly hunt for repos to relocate
|
|
288
|
+
* there on every later run.
|
|
289
|
+
*/
|
|
290
|
+
export function parseFromPaths(from) {
|
|
291
|
+
return (Array.isArray(from) ? from : [from])
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.flatMap((s) => String(s).split(','))
|
|
294
|
+
.map((s) => s.trim())
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
.map((s) => path.resolve(expandHome(s)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function run(opts) {
|
|
300
|
+
const { root, manifest, state } = requireWorkspace();
|
|
301
|
+
|
|
302
|
+
if (opts['fix-paths']) {
|
|
303
|
+
heading('Repairing paths for repos already adopted');
|
|
304
|
+
const { count } = refixPaths(root);
|
|
305
|
+
if (!count) {
|
|
306
|
+
plain(c.dim(' Nothing recorded — no repo has been adopted in this workspace.'));
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
requireCatalogue(manifest);
|
|
312
|
+
|
|
313
|
+
// The whole catalogue, not this machine's selection: a stray checkout is
|
|
314
|
+
// worth moving into place whether or not this machine had signed up for it.
|
|
315
|
+
const repos = selectRepos(manifest, opts, manifest.repos);
|
|
316
|
+
|
|
317
|
+
const extra = parseFromPaths(opts.from);
|
|
318
|
+
|
|
319
|
+
const remembered = state.scanPaths ?? [];
|
|
320
|
+
const scanRoots = [...new Set([root, ...remembered, ...extra])];
|
|
321
|
+
|
|
322
|
+
heading(opts.apply ? 'Adopting existing checkouts' : 'Adopting existing checkouts (dry run)');
|
|
323
|
+
info(
|
|
324
|
+
`searching ${scanRoots.length} location${scanRoots.length > 1 ? 's' : ''}, ` +
|
|
325
|
+
`${repos.length} repo${repos.length > 1 ? 's' : ''} in scope`,
|
|
326
|
+
);
|
|
327
|
+
for (const dir of scanRoots) plain(` ${c.dim(shorten(dir))}`);
|
|
328
|
+
|
|
329
|
+
const { moves, parks, refused, inPlace } = await planFor({
|
|
330
|
+
manifest,
|
|
331
|
+
root,
|
|
332
|
+
repos,
|
|
333
|
+
scanRoots,
|
|
334
|
+
jobs: opts.jobs ?? 8,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (inPlace.length) {
|
|
338
|
+
plain('');
|
|
339
|
+
skip(`${inPlace.length} already in the right place`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!moves.length && !parks.length && !refused.length) {
|
|
343
|
+
plain(`\n${c.dim('Nothing to adopt.')}`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (moves.length && !opts.apply) {
|
|
348
|
+
plain('');
|
|
349
|
+
for (const p of moves) {
|
|
350
|
+
const mark = p.confidence === 'name' ? c.yellow(glyph.maybe) : c.green(glyph.arrow);
|
|
351
|
+
plain(
|
|
352
|
+
` ${mark} ${c.bold(p.repo.name)}\n` +
|
|
353
|
+
` from ${c.dim(shorten(p.from))}\n` +
|
|
354
|
+
` to ${c.dim(path.relative(root, p.to))}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (moves.some((p) => p.confidence === 'name')) {
|
|
358
|
+
plain('');
|
|
359
|
+
warn(
|
|
360
|
+
`${c.yellow('~')} matched on repo name only — the remote host is not one the catalogue lists.\n` +
|
|
361
|
+
` Check those remotes before applying.`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (parks.length && !opts.apply) {
|
|
367
|
+
plain('');
|
|
368
|
+
for (const p of parks) {
|
|
369
|
+
plain(
|
|
370
|
+
` ${c.yellow('⇉')} ${c.bold(p.repo.name)} ${c.dim('— second copy')}\n` +
|
|
371
|
+
` from ${c.dim(shorten(p.from))}\n` +
|
|
372
|
+
` to ${c.dim(path.relative(root, p.to))}\n` +
|
|
373
|
+
` keep ${c.dim(path.relative(root, p.keeping) || p.keeping)}` +
|
|
374
|
+
(p.holds.length ? `\n ${c.yellow(`holds ${p.holds.join(' and ')}`)}` : ''),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
plain('');
|
|
378
|
+
info(
|
|
379
|
+
c.dim(`Second copies move into ${DUPLICATES_DIR}/ rather than being deleted — everything\n` +
|
|
380
|
+
' in them is kept, and nothing outside the tree is left behind.'),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (refused.length) {
|
|
385
|
+
plain('');
|
|
386
|
+
for (const p of refused) {
|
|
387
|
+
warn(`${c.bold(p.repo.name)} left alone — ${p.reason}\n ${c.dim(shorten(p.from))}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!opts.apply) {
|
|
392
|
+
const bits = [];
|
|
393
|
+
if (moves.length) bits.push(`move ${moves.length} into place`);
|
|
394
|
+
if (parks.length) bits.push(`park ${parks.length} second cop${parks.length === 1 ? 'y' : 'ies'}`);
|
|
395
|
+
plain(`\n${c.dim(bits.length ? `Re-run with --apply to ${bits.join(' and ')}.` : 'Nothing to apply.')}`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (extra.length) {
|
|
400
|
+
saveState(root, { ...state, scanPaths: [...new Set([...remembered, ...extra])] });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
plain('');
|
|
404
|
+
const applied = await applyMoves(root, moves, parks, manifest);
|
|
405
|
+
const okCount = applied.results.filter((r) => r.ok).length;
|
|
406
|
+
summary({
|
|
407
|
+
ok: okCount + applied.parked.length,
|
|
408
|
+
skipped: inPlace.length,
|
|
409
|
+
failed: applied.results.length - okCount + (parks.length - applied.parked.length),
|
|
410
|
+
okLabel: 'relocated',
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (applied.parked.length) {
|
|
414
|
+
plain('');
|
|
415
|
+
info(
|
|
416
|
+
`${applied.parked.length} second cop${applied.parked.length === 1 ? 'y' : 'ies'} parked in ` +
|
|
417
|
+
`${c.bold(DUPLICATES_DIR)}/ ${c.dim('— nothing was deleted.')}`,
|
|
418
|
+
);
|
|
419
|
+
plain(c.dim(` Review them, then remove the folder yourself when you are happy:`));
|
|
420
|
+
plain(c.dim(` rm -rf ${path.join(root, DUPLICATES_DIR)}`));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await reportLeftovers(root, applied);
|
|
424
|
+
|
|
425
|
+
if (applied.warnedAboutClaude) {
|
|
426
|
+
plain('');
|
|
427
|
+
warn(
|
|
428
|
+
'A Claude Code process is running. It rewrites ~/.claude.json when it exits,\n' +
|
|
429
|
+
' which can revert the settings fix above. Re-run `talea adopt` after quitting it\n' +
|
|
430
|
+
' if the project entry looks wrong.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (okCount) {
|
|
435
|
+
plain(`\n${c.dim('To sweep anything this did not cover:')}`);
|
|
436
|
+
for (const r of applied.results.filter((x) => x.ok)) {
|
|
437
|
+
plain(c.dim(` grep -rl "${r.plan.from}" ${root} ~ 2>/dev/null`));
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { defaultBranch, groupDir, repoGroup, repoUrl, saveState } from '../config.js';
|
|
5
|
+
import { dropDocs } from '../docs.js';
|
|
6
|
+
import { clone, defaultJobs, isMissingRemote, pooled } from '../git.js';
|
|
7
|
+
import { board } from '../live.js';
|
|
8
|
+
import { c, heading, icon, ok, plain, summary, warn } from '../log.js';
|
|
9
|
+
import { chooseRepos } from '../select.js';
|
|
10
|
+
import { requireCatalogue, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
|
|
11
|
+
import { applyMoves, parseFromPaths, planFor } from './adopt.js';
|
|
12
|
+
|
|
13
|
+
export const help = `
|
|
14
|
+
${c.bold('talea clone')} — clone what is missing, and nothing else
|
|
15
|
+
|
|
16
|
+
${c.dim('talea clone')} clone every repo this machine keeps
|
|
17
|
+
${c.dim('talea clone -g nonstopio')} only that owner
|
|
18
|
+
${c.dim('talea clone -r eklavya')} a single repo, whether or not it is selected
|
|
19
|
+
${c.dim('talea clone --pick')} re-open the checklist first
|
|
20
|
+
|
|
21
|
+
Options
|
|
22
|
+
-g, --group <names> comma-separated groups
|
|
23
|
+
-r, --repo <names> comma-separated repo names
|
|
24
|
+
--pick choose what this machine keeps before cloning
|
|
25
|
+
--protocol <p> ssh (default) or https
|
|
26
|
+
--from <path> also search here for existing checkouts (repeatable)
|
|
27
|
+
--no-adopt skip the existing-checkout check and only clone
|
|
28
|
+
-j, --jobs <n> parallel clones (default: one per core, 6-12)
|
|
29
|
+
|
|
30
|
+
Before cloning anything, the workspace is checked for repos that are already on
|
|
31
|
+
disk in the wrong place — cloned by hand, or moved by a catalogue change. Those
|
|
32
|
+
are ${c.bold('moved')} into place, keeping every branch and uncommitted change, instead of
|
|
33
|
+
being cloned again. Pass ${c.dim('--from ~/Desktop')} to look outside the workspace too;
|
|
34
|
+
folders you name are remembered.
|
|
35
|
+
|
|
36
|
+
Already-cloned repos are left completely alone, so re-running is safe. A repo
|
|
37
|
+
the server will not hand over — renamed, deleted, or never granted to your
|
|
38
|
+
account — is reported and skipped, not failed.
|
|
39
|
+
|
|
40
|
+
${c.dim('talea sync')} does this and then fast-forwards. This command is the half you want
|
|
41
|
+
when you are on a slow connection and do not care about merging yet.
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Relocate checkouts that are already on disk but in the wrong place.
|
|
46
|
+
*
|
|
47
|
+
* The workspace itself is always searched — that is how a catalogue change that
|
|
48
|
+
* moves a repo between groups reaches everyone who already cloned it. Anywhere
|
|
49
|
+
* else is only searched when it has been named explicitly, because moving
|
|
50
|
+
* repositories found by guesswork is not ours to do.
|
|
51
|
+
*
|
|
52
|
+
* Returns the repos to keep OUT of the clone set. Anything left where it is
|
|
53
|
+
* must not be cloned as well, or a fresh copy lands beside the developer's
|
|
54
|
+
* existing checkout — the one outcome this whole module exists to prevent.
|
|
55
|
+
*/
|
|
56
|
+
export async function adoptInPlace({ manifest, root, state, repos, opts }) {
|
|
57
|
+
const extra = parseFromPaths(opts.from);
|
|
58
|
+
const scanRoots = [...new Set([root, ...(state.scanPaths ?? []), ...extra])];
|
|
59
|
+
|
|
60
|
+
// Remembered before anything moves. `applyMoves` appends to the same state
|
|
61
|
+
// file, so writing this afterwards from a stale snapshot would wipe the
|
|
62
|
+
// `adopted` move log — the exact record `talea adopt --fix-paths` replays.
|
|
63
|
+
if (extra.length) {
|
|
64
|
+
saveState(root, { ...state, scanPaths: [...new Set([...(state.scanPaths ?? []), ...extra])] });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { moves, parks, refused } = await planFor({ manifest, root, repos, scanRoots, jobs: opts.jobs });
|
|
68
|
+
|
|
69
|
+
// A name-only match means the remote host is not one the catalogue lists, so
|
|
70
|
+
// it is probably right but not certainly. This runs unattended, so it only
|
|
71
|
+
// relocates certain matches; the rest are named and left for `talea adopt`,
|
|
72
|
+
// where they are shown before anything moves.
|
|
73
|
+
const certain = moves.filter((m) => m.confidence === 'exact');
|
|
74
|
+
const unsure = moves.filter((m) => m.confidence !== 'exact');
|
|
75
|
+
|
|
76
|
+
const skip = new Map();
|
|
77
|
+
for (const m of unsure) skip.set(m.repo.name, 'its remote host is not one the catalogue lists');
|
|
78
|
+
for (const r of refused) skip.set(r.repo.name, r.reason);
|
|
79
|
+
|
|
80
|
+
if (certain.length || parks.length) {
|
|
81
|
+
const bits = [];
|
|
82
|
+
if (certain.length) bits.push(`${certain.length} to move into place`);
|
|
83
|
+
if (parks.length) bits.push(`${parks.length} second cop${parks.length === 1 ? 'y' : 'ies'} to park`);
|
|
84
|
+
heading(`Existing checkouts: ${bits.join(', ')}`);
|
|
85
|
+
|
|
86
|
+
const applied = await applyMoves(root, certain, parks, manifest);
|
|
87
|
+
for (const r of applied.results.filter((x) => !x.ok)) skip.set(r.plan.repo.name, 'its move failed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (skip.size) {
|
|
91
|
+
plain('');
|
|
92
|
+
for (const [name, reason] of skip) warn(`${c.bold(name)} left alone — ${reason}`);
|
|
93
|
+
plain(c.dim(' Not cloned either, so nothing lands beside it. Run `talea adopt` to sort it out.'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { skip };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clone the entries that are not on disk. Shared with `sync`, which clones the
|
|
101
|
+
* missing before fast-forwarding the rest — one command for "make this machine
|
|
102
|
+
* match the list".
|
|
103
|
+
*
|
|
104
|
+
* Mutates and returns `counts` so a caller can fold cloning and syncing into a
|
|
105
|
+
* single summary.
|
|
106
|
+
*/
|
|
107
|
+
export async function cloneMissing({ manifest, root, entries, protocol, jobs, counts }) {
|
|
108
|
+
const view = board(
|
|
109
|
+
entries.map(({ repo }) => ({
|
|
110
|
+
id: repo.name,
|
|
111
|
+
group: groupDir(manifest, repoGroup(repo)),
|
|
112
|
+
label: repo.name,
|
|
113
|
+
})),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await pooled(entries, jobs, async ({ repo, dir }) => {
|
|
117
|
+
const url = repoUrl(manifest, repo, protocol);
|
|
118
|
+
const branch = defaultBranch(repo);
|
|
119
|
+
view.set(repo.name, 'busy', branch ? `cloning ${branch} …` : 'cloning …');
|
|
120
|
+
|
|
121
|
+
// Anything already sitting at the destination belongs to the developer, not
|
|
122
|
+
// to us. A folder with no .git could be hand-made notes, a half-finished
|
|
123
|
+
// checkout, or a repo whose .git was moved — none of which we may delete.
|
|
124
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
125
|
+
counts.skipped++;
|
|
126
|
+
view.set(
|
|
127
|
+
repo.name,
|
|
128
|
+
'warn',
|
|
129
|
+
`${path.relative(root, dir)} already exists and is not a git repo, leaving it untouched`,
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
mkdirSync(path.dirname(dir), { recursive: true });
|
|
135
|
+
const res = await clone(url, dir, branch ?? undefined);
|
|
136
|
+
|
|
137
|
+
// Only retry on the specific failure this fallback is for: the catalogue
|
|
138
|
+
// naming a branch the remote no longer has, which happens whenever a repo
|
|
139
|
+
// is renamed from master to main after the last discover. Any other failure
|
|
140
|
+
// (auth, network, disk) must be reported as itself.
|
|
141
|
+
const branchMissing =
|
|
142
|
+
branch && /remote branch .* not found|could not find remote branch/i.test(res.stderr);
|
|
143
|
+
|
|
144
|
+
if (res.code !== 0 && branchMissing) {
|
|
145
|
+
// Safe to clear: we got here having confirmed the path was empty, so the
|
|
146
|
+
// only thing here is the partial directory this failed clone just made.
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
const retry = await clone(url, dir, undefined);
|
|
149
|
+
if (retry.code === 0) {
|
|
150
|
+
counts.ok++;
|
|
151
|
+
view.set(repo.name, 'skip', `cloned on the default branch (no ${branch} on origin)`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
counts.failed++;
|
|
155
|
+
view.set(repo.name, 'fail', 'clone failed');
|
|
156
|
+
view.note(repo.name, retry.stderr.split('\n')[0] ?? 'clone failed');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (res.code !== 0 && isMissingRemote(res.stderr)) {
|
|
161
|
+
// The catalogue lists it, the server does not hand it over. Not a broken
|
|
162
|
+
// workspace and not something a retry mends — skip it, clone the rest.
|
|
163
|
+
counts.skipped++;
|
|
164
|
+
view.set(repo.name, 'skip', 'origin is gone or not granted to you, not cloned');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (res.code !== 0) {
|
|
169
|
+
counts.failed++;
|
|
170
|
+
view.set(repo.name, 'fail', 'clone failed');
|
|
171
|
+
view.note(repo.name, res.stderr.split('\n')[0] ?? 'clone failed');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
counts.ok++;
|
|
176
|
+
view.set(
|
|
177
|
+
repo.name,
|
|
178
|
+
'ok',
|
|
179
|
+
`${icon.arrow} ${branch ? c.cyan(branch) : c.dim('default')} ${c.dim(path.relative(root, dir))}`,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
view.stop();
|
|
184
|
+
return counts;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Group docs, for the folders repos live in. Shared by `clone` and `sync`. */
|
|
188
|
+
export function writeDocs(manifest, root, repos) {
|
|
189
|
+
const docs = dropDocs(manifest, root, new Set(repos.map((r) => repoGroup(r))));
|
|
190
|
+
if (!docs.written.length) return;
|
|
191
|
+
heading('Workspace docs');
|
|
192
|
+
for (const file of docs.written) ok(`CLAUDE.md ${c.dim(`→ ${path.relative(root, file)}`)}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function run(opts) {
|
|
196
|
+
const { root, manifest, state } = requireWorkspace();
|
|
197
|
+
requireCatalogue(manifest);
|
|
198
|
+
|
|
199
|
+
const protocol = opts.protocol ?? state.protocol ?? 'ssh';
|
|
200
|
+
const jobs = opts.jobs ?? defaultJobs();
|
|
201
|
+
|
|
202
|
+
const { repos: chosen } = await chooseRepos({ manifest, root, state, opts });
|
|
203
|
+
const repos = selectRepos(manifest, opts, chosen);
|
|
204
|
+
|
|
205
|
+
const adoption =
|
|
206
|
+
opts.adopt === false
|
|
207
|
+
? { skip: new Map() }
|
|
208
|
+
: await adoptInPlace({ manifest, root, state, repos, opts });
|
|
209
|
+
|
|
210
|
+
writeDocs(manifest, root, repos);
|
|
211
|
+
|
|
212
|
+
const entries = withPaths(manifest, root, repos);
|
|
213
|
+
const todo = entries.filter((e) => !e.cloned && !adoption.skip.has(e.repo.name));
|
|
214
|
+
const already = entries.filter((e) => e.cloned).length;
|
|
215
|
+
|
|
216
|
+
heading(`Cloning into ${root}`);
|
|
217
|
+
// A repo left alone counts against the run: without this, a clone that
|
|
218
|
+
// quietly skipped a stranded repo would still exit 0.
|
|
219
|
+
const counts = { ok: 0, skipped: already, failed: adoption.skip.size, okLabel: 'cloned' };
|
|
220
|
+
|
|
221
|
+
if (!todo.length) {
|
|
222
|
+
summary(counts);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
plain('');
|
|
227
|
+
await cloneMissing({ manifest, root, entries: todo, protocol, jobs, counts });
|
|
228
|
+
summary(counts);
|
|
229
|
+
|
|
230
|
+
if (counts.failed) {
|
|
231
|
+
plain(c.dim('\nFailed clones are usually SSH access. Run `talea doctor` to check.'));
|
|
232
|
+
}
|
|
233
|
+
}
|