@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.
@@ -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
+ }