@ajaykumarnpm/talea 0.1.0 → 0.2.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 CHANGED
@@ -79,12 +79,24 @@ A move keeps everything: branches, stashes, the reflog, your uncommitted
79
79
  changes. A re-clone throws all of it away, which is why this tool does not do
80
80
  one.
81
81
 
82
+ **Worktrees come too.** The sibling `<repo>-worktrees/` folder moves alongside
83
+ the repo, and every worktree is re-linked afterwards — the ones that moved and
84
+ the ones that did not. A worktree is never mistaken for a second copy of the
85
+ repo, even though git reports the same `origin` for both.
86
+
82
87
  ```sh
83
88
  talea adopt # show what would move, change nothing
84
89
  talea adopt --from ~/Desktop # look there too; the folder is remembered
85
90
  talea adopt --apply # do it
86
91
  ```
87
92
 
93
+ Matching is on the remote URL. When only the *name* matches — a fork, a mirror,
94
+ or a directory that merely shares a name — it is listed and left alone; `--loose`
95
+ or `-r <repo>` includes it once you have looked. That guard is not theoretical:
96
+ an FVM Flutter SDK cache reports `origin` as `flutter/flutter`, which name-matches
97
+ a personal `flutter` fork, and moving it would break every Flutter project on the
98
+ machine.
99
+
88
100
  If the same repo turns up twice, the copy at the catalogue path wins and the
89
101
  other moves into `.talea-duplicates/` with everything in it. **Nothing is ever
90
102
  deleted.** Clearing that folder is your call.
@@ -95,6 +107,18 @@ repo that moves and loses its history has not really been helped.
95
107
 
96
108
  ---
97
109
 
110
+ ## When another tool owns a checkout
111
+
112
+ Mark it in the catalogue and talea leaves it completely alone:
113
+
114
+ ```json
115
+ { "name": "some-repo", "owner": "someone", "ignore": true }
116
+ ```
117
+
118
+ Without this, two tools that both organise repositories will each drag the same
119
+ checkout back to where it thinks it belongs, on every run. Use it for repos
120
+ inside another workspace manager's tree, vendored checkouts, and SDK caches.
121
+
98
122
  ## What travels, and what does not
99
123
 
100
124
  | | Where it lives | Shared |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajaykumarnpm/talea",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One folder structure for every machine. Clone, adopt and sync your GitHub repos from a manifest you own.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adopt.js CHANGED
@@ -142,6 +142,23 @@ export function catalogueUrls(manifest, repo) {
142
142
  return urls;
143
143
  }
144
144
 
145
+ /**
146
+ * Is this directory a linked worktree rather than a checkout of its own?
147
+ *
148
+ * A worktree's `.git` is a file holding a pointer, where a real checkout has a
149
+ * directory. The distinction matters because a worktree reports the same
150
+ * `origin` as the repo it belongs to, so by remote alone it is indistinguishable
151
+ * from a second clone — and treating one as a stray copy is how you rename it
152
+ * out from under git and unroot every commit in it.
153
+ */
154
+ export function isLinkedWorktree(dir) {
155
+ try {
156
+ return lstatSync(path.join(dir, '.git')).isFile();
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
145
162
  /**
146
163
  * Which catalogue repo does this remote belong to?
147
164
  *
@@ -250,16 +267,6 @@ async function moveBlockers(dir) {
250
267
  return 'no .git found';
251
268
  }
252
269
 
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
270
  return null;
264
271
  }
265
272
 
@@ -292,37 +299,69 @@ export function canonical(p) {
292
299
  }
293
300
 
294
301
  /**
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:
302
+ * Every linked worktree of this repo, as git currently records it.
299
303
  *
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
+ * `prunable` ones are skipped: git already knows the directory is gone, so
305
+ * there is no path left to repair. `--porcelain` emits a blank-line-separated
306
+ * block per worktree, the first being the main checkout which is the repo
307
+ * being moved, not a link into it.
304
308
  *
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.
309
+ * This used to return only the worktrees *outside* the repo, and a move was
310
+ * refused when there were any. That was wrong: an external worktree does not
311
+ * move, so nothing about it breaks except the pointer in its own `.git` file,
312
+ * and `git worktree repair` exists to rewrite exactly that. Refusing meant a
313
+ * developer who uses worktrees at all could never have their repos organised —
314
+ * and it cascaded, because a refused winner leaves every duplicate of it
315
+ * refused too.
308
316
  */
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
-
317
+ export function linkedWorktrees(porcelain) {
316
318
  return String(porcelain)
317
319
  .split(/\r?\n\s*\r?\n/)
318
320
  .map((block) => lines(block))
319
321
  .filter((block) => block.length && block[0].startsWith('worktree '))
320
- .slice(1) // the main checkout is the thing being moved, not a blocker
322
+ .slice(1) // the main checkout is the thing being moved, not a link into it
321
323
  .filter((block) => !block.some((l) => l === 'prunable' || l.startsWith('prunable ')))
322
- .map((block) => block[0].slice('worktree '.length))
323
- .filter((p) => !inside(p));
324
+ .map((block) => block[0].slice('worktree '.length));
324
325
  }
325
326
 
327
+ /**
328
+ * Where a worktree ends up once the repo has moved.
329
+ *
330
+ * Three cases, and the whole correctness of the repair is getting them right:
331
+ *
332
+ * - inside the repo (`.claude/worktrees/x`) — travelled with the rename.
333
+ * - inside the sibling `<repo>-worktrees/` folder, when that folder was moved
334
+ * alongside — travelled too, to the new sibling.
335
+ * - anywhere else — did not move at all, and must be repaired where it sits.
336
+ */
337
+ export function remapWorktree(p, { from, to, siblings }) {
338
+ const within = (base) => {
339
+ const rel = path.relative(canonical(base), canonical(p));
340
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : null;
341
+ };
342
+
343
+ const nested = within(from);
344
+ if (nested) return path.join(to, nested);
345
+
346
+ if (siblings) {
347
+ const moved = within(siblings.from);
348
+ if (moved) return path.join(siblings.to, moved);
349
+ }
350
+
351
+ return p;
352
+ }
353
+
354
+ /**
355
+ * The sibling folder worktrees are conventionally kept in.
356
+ *
357
+ * `<repo>-worktrees/`, derived from the repo's own path rather than stored
358
+ * anywhere — which is why it has to move when the repo does. Leave it behind
359
+ * and the tooling that creates worktrees starts using a new empty folder next
360
+ * to the moved repo while every existing worktree sits beside the old location.
361
+ * Both halves keep working; the developer now has two.
362
+ */
363
+ export const siblingWorktreeDir = (repoPath) => `${repoPath}-worktrees`;
364
+
326
365
  /**
327
366
  * What a second copy holds that the copy being kept does not.
328
367
  *
@@ -411,6 +450,14 @@ export async function planAdoptions(manifest, root, repos, candidates) {
411
450
  for (const { dir, originUrl } of candidates) {
412
451
  const match = matchRepo(manifest, repos, originUrl);
413
452
  if (!match) continue;
453
+
454
+ // A linked worktree shares its repo's origin, so it arrives here looking
455
+ // exactly like a second clone. It is not one — it is part of the repo, and
456
+ // it travels when the repo moves (see relocateWorktrees). Reporting one as
457
+ // a stray copy produced a refusal line per worktree, which for a developer
458
+ // who uses them buried the real findings under fifteen lines of noise.
459
+ if (isLinkedWorktree(dir)) continue;
460
+
414
461
  const key = match.repo.name;
415
462
  if (!byRepo.has(key)) byRepo.set(key, { repo: match.repo, copies: [] });
416
463
  byRepo.get(key).copies.push({ dir, originUrl, confidence: match.confidence });
@@ -506,38 +553,83 @@ export async function planAdoptions(manifest, root, repos, candidates) {
506
553
  * moved aside, and the developer removes the duplicates area themselves.
507
554
  */
508
555
  /**
509
- * Re-link worktrees that lived inside the repo and moved with it.
556
+ * What git records about this repo's worktrees, read BEFORE it moves.
510
557
  *
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.
558
+ * The ordering is load-bearing. Once the repo has moved, every worktree that
559
+ * travelled with it is reported `prunable` — git looks for it at its recorded
560
+ * location, which is the path that just stopped existing and a prunable
561
+ * entry is indistinguishable from a genuinely dead one. Read after the move
562
+ * and the worktrees that most need repairing are exactly the ones filtered out.
515
563
  */
516
- function repairNestedWorktrees(from, to) {
564
+ export function readWorktrees(dir) {
517
565
  const listed = spawnSync('git', ['worktree', 'list', '--porcelain'], {
518
- cwd: to,
566
+ cwd: dir,
519
567
  encoding: 'utf8',
520
568
  });
521
- if (listed.status !== 0) return;
569
+ return listed.status === 0 ? linkedWorktrees(listed.stdout) : [];
570
+ }
571
+
572
+ /**
573
+ * Bring a repo's worktrees with it, then re-link every one of them.
574
+ *
575
+ * Every link between a repo and a worktree is an absolute path to where the
576
+ * repo used to be, so a rename breaks all of them at once. `git worktree
577
+ * repair` with no arguments cannot help: it looks for each worktree at its
578
+ * recorded path, which is exactly the path that no longer exists. Handing it
579
+ * the new paths is what the argument form is for.
580
+ *
581
+ * The sibling `<repo>-worktrees/` folder moves too. That location is derived
582
+ * from the repo's own path at the moment a worktree is created, never stored,
583
+ * so leaving it behind does not break anything — it silently splits the
584
+ * workflow in half instead, new worktrees appearing next to the moved repo
585
+ * while every existing one stays beside the old location. Splitting something
586
+ * in two and reporting success is worse than refusing.
587
+ *
588
+ * Nothing is deleted and nothing is overwritten: an occupied destination means
589
+ * the folder stays where it is, and the worktrees in it are repaired in place.
590
+ */
591
+ function relocateWorktrees(from, to, recorded) {
592
+ if (!recorded.length) return { repaired: [], siblings: null, stale: 0 };
593
+
594
+ // The sibling folder moves only if it exists, is going somewhere new, and
595
+ // that somewhere is free. Anything else and the worktrees stay put — still
596
+ // correct, just not tidied.
597
+ let siblings = null;
598
+ const fromSiblings = siblingWorktreeDir(from);
599
+ const toSiblings = siblingWorktreeDir(to);
600
+ if (
601
+ existsSync(fromSiblings) &&
602
+ !samePath(fromSiblings, toSiblings) &&
603
+ !existsSync(toSiblings)
604
+ ) {
605
+ try {
606
+ mkdirSync(path.dirname(toSiblings), { recursive: true });
607
+ renameSync(fromSiblings, toSiblings);
608
+ siblings = { from: fromSiblings, to: toSiblings };
609
+ } catch {
610
+ // A cross-device sibling folder, or one being written to. The repo has
611
+ // already moved and is fine; the worktrees are repaired where they are.
612
+ }
613
+ }
522
614
 
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));
615
+ const now = recorded
616
+ .map((p) => remapWorktree(p, { from, to, siblings }))
617
+ .filter((p) => existsSync(p));
529
618
 
530
- if (moved.length) {
531
- spawnSync('git', ['worktree', 'repair', ...moved], { cwd: to, stdio: 'ignore' });
619
+ if (now.length) {
620
+ spawnSync('git', ['worktree', 'repair', ...now], { cwd: to, stdio: 'ignore' });
532
621
  }
622
+ return { repaired: now, siblings, stale: recorded.length - now.length };
533
623
  }
534
624
 
535
625
  export function executeMove(plan) {
536
626
  try {
537
627
  mkdirSync(path.dirname(plan.to), { recursive: true });
628
+ // Read before the rename: see readWorktrees.
629
+ const recorded = readWorktrees(plan.from);
538
630
  renameSync(plan.from, plan.to);
539
- repairNestedWorktrees(plan.from, plan.to);
540
- return { ok: true, to: plan.to };
631
+ const worktrees = relocateWorktrees(plan.from, plan.to, recorded);
632
+ return { ok: true, to: plan.to, worktrees };
541
633
  } catch (err) {
542
634
  if (err.code === 'EXDEV') {
543
635
  const cmd = process.platform === 'win32' ? 'move' : 'mv';
package/src/cli.js CHANGED
@@ -70,6 +70,7 @@ const OPTIONS = {
70
70
  gist: { type: 'string' },
71
71
  apply: { type: 'boolean', default: false },
72
72
  'fix-paths': { type: 'boolean', default: false },
73
+ loose: { type: 'boolean', default: false },
73
74
  // parseArgs has no --no-x negation, so the negative flags are declared
74
75
  // explicitly and inverted below.
75
76
  'no-adopt': { type: 'boolean', default: false },
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  import { expandHome, loadState, saveState } from '../config.js';
6
6
  import { c, fail, glyph, heading, icon, info, ok, plain, skip, summary, warn } from '../log.js';
7
- import { requireCatalogue, requireWorkspace, selectRepos } from '../workspace.js';
7
+ import { adoptable, requireCatalogue, requireWorkspace, selectRepos } from '../workspace.js';
8
8
  import {
9
9
  DUPLICATES_DIR,
10
10
  claudeMaybeRunning,
@@ -34,9 +34,13 @@ called ${c.dim('~/tmp/clone2')} is still recognised as the repo it holds. A matc
34
34
  ${c.bold('moved')}, never re-cloned — the move keeps every branch, stash, reflog entry
35
35
  and uncommitted change exactly as it is.
36
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.
37
+ ${c.bold('Worktrees come too.')} The sibling ${c.dim('<repo>-worktrees/')} folder moves alongside the
38
+ repo, and every worktree there, nested inside the repo, or anywhere else on
39
+ disk — is re-linked afterwards. A worktree that does not move is repaired where
40
+ it sits.
41
+
42
+ A repo that cannot be moved safely (it is itself a linked worktree, an occupied
43
+ destination, another filesystem) is left alone and the reason is printed.
40
44
 
41
45
  When the same repo is found twice, the copy at the catalogue path wins and the
42
46
  other moves into ${c.bold(DUPLICATES_DIR)}/ — never deleted, never left outside the tree.
@@ -52,6 +56,10 @@ Options
52
56
  -r, --repo <names> restrict to repos
53
57
  --from <path> extra folder to search (repeatable, remembered)
54
58
  --apply perform the moves (default is a dry run)
59
+ --loose also move repos matched by name when the remote differs
60
+
61
+ A repo marked ${c.dim('"ignore": true')} in the catalogue is never touched by any command —
62
+ that is how you tell talea another tool owns a checkout.
55
63
  --fix-paths re-repair config for repos already adopted, moving nothing
56
64
  -j, --jobs <n> parallel git calls (default 8)
57
65
  `;
@@ -97,6 +105,23 @@ export async function applyMoves(root, moves, parks = [], manifest) {
97
105
  ok(
98
106
  `${c.bold(plan.repo.name)} ${c.dim(shorten(plan.from))} ${icon.arrow} ${c.dim(path.relative(root, plan.to))}`,
99
107
  );
108
+
109
+ // Said out loud, always. A worktree folder moving is a second directory
110
+ // relocating on the developer's disk, and the one thing worse than not
111
+ // moving it is moving it without saying so.
112
+ const wt = res.worktrees;
113
+ if (wt?.siblings) {
114
+ plain(
115
+ ` ${c.dim(glyph.pending)} worktrees ${c.dim(shorten(wt.siblings.from))} ${icon.arrow} ${c.dim(path.relative(root, wt.siblings.to))}`,
116
+ );
117
+ }
118
+ if (wt?.repaired?.length) {
119
+ plain(
120
+ ` ${c.dim(glyph.pending)} ${wt.repaired.length} worktree${wt.repaired.length > 1 ? 's' : ''} re-linked` +
121
+ (wt.stale ? c.yellow(` (${wt.stale} unreachable, left registered)`) : ''),
122
+ );
123
+ }
124
+
100
125
  const repairs = repairPaths(root, plan);
101
126
  results.push({ plan, ok: true, repairs });
102
127
  }
@@ -312,7 +337,8 @@ export async function run(opts) {
312
337
 
313
338
  // The whole catalogue, not this machine's selection: a stray checkout is
314
339
  // worth moving into place whether or not this machine had signed up for it.
315
- const repos = selectRepos(manifest, opts, manifest.repos);
340
+ // Minus anything marked `ignore` — see `adoptable`.
341
+ const repos = selectRepos(manifest, opts, adoptable(manifest));
316
342
 
317
343
  const extra = parseFromPaths(opts.from);
318
344
 
@@ -344,6 +370,29 @@ export async function run(opts) {
344
370
  return;
345
371
  }
346
372
 
373
+ // A name-only match means the remote host is not one the catalogue lists:
374
+ // the repo NAME matched and the URL did not. That is usually a fork or a
375
+ // mirror and usually right — and when it is wrong it is very wrong. An FVM
376
+ // Flutter SDK cache at ~/SDK/FVM/cache.git has origin flutter/flutter, which
377
+ // name-matches a personal `flutter` fork, and moving it breaks every Flutter
378
+ // project on the machine.
379
+ //
380
+ // `clone` and `sync` already refuse to act on these unattended. `adopt
381
+ // --apply` used to move them after printing a warning nobody had to answer,
382
+ // which made the warning decorative. Now they need saying so: --loose, or
383
+ // naming the repo with -r, which is itself an explicit instruction.
384
+ const named = Boolean(opts.repo);
385
+ const loose = Boolean(opts.loose) || named;
386
+ //
387
+ // Parking is a move too, and the FVM case was a PARK, not a move: the
388
+ // catalogue path was empty, so the SDK cache was second-in-line and headed
389
+ // for .talea-duplicates/. Gating only `moves` would have left the exact
390
+ // directory this guard exists for still being relocated.
391
+ const exact = (p) => p.confidence === 'exact';
392
+ const unsure = [...moves, ...parks].filter((p) => !exact(p));
393
+ const willMove = loose ? moves : moves.filter(exact);
394
+ const willPark = loose ? parks : parks.filter(exact);
395
+
347
396
  if (moves.length && !opts.apply) {
348
397
  plain('');
349
398
  for (const p of moves) {
@@ -354,11 +403,18 @@ export async function run(opts) {
354
403
  ` to ${c.dim(path.relative(root, p.to))}`,
355
404
  );
356
405
  }
357
- if (moves.some((p) => p.confidence === 'name')) {
406
+ if (unsure.length) {
358
407
  plain('');
359
408
  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.`,
409
+ `${c.yellow('~')} ${unsure.length} matched on repo name only — the remote host is not one the\n` +
410
+ ` catalogue lists. Check those remotes: a directory that merely shares a name\n` +
411
+ ` with one of your repos is not one of your repos.`,
412
+ );
413
+ plain(
414
+ c.dim(
415
+ ` --apply leaves them alone. Add ${c.bold('--loose')} to include them, or name one\n` +
416
+ ` with ${c.bold('-r <repo>')} once you have checked it.`,
417
+ ),
362
418
  );
363
419
  }
364
420
  }
@@ -390,8 +446,11 @@ export async function run(opts) {
390
446
 
391
447
  if (!opts.apply) {
392
448
  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'}`);
449
+ if (willMove.length) bits.push(`move ${willMove.length} into place`);
450
+ if (willPark.length) bits.push(`park ${willPark.length} second cop${willPark.length === 1 ? 'y' : 'ies'}`);
451
+ if (unsure.length && !loose) {
452
+ bits.push(`${c.yellow(`leave ${unsure.length} name-only match${unsure.length === 1 ? '' : 'es'} alone`)}`);
453
+ }
395
454
  plain(`\n${c.dim(bits.length ? `Re-run with --apply to ${bits.join(' and ')}.` : 'Nothing to apply.')}`);
396
455
  return;
397
456
  }
@@ -400,13 +459,25 @@ export async function run(opts) {
400
459
  saveState(root, { ...state, scanPaths: [...new Set([...remembered, ...extra])] });
401
460
  }
402
461
 
462
+ if (unsure.length && !loose) {
463
+ plain('');
464
+ for (const p of unsure) {
465
+ warn(
466
+ `${c.bold(p.repo.name)} left alone — matched on name only, not on remote\n` +
467
+ ` ${c.dim(shorten(p.from))}\n` +
468
+ ` ${c.dim(p.originUrl ?? 'no origin')}`,
469
+ );
470
+ }
471
+ plain(c.dim(' Check the remote, then re-run with --loose or -r to include it.'));
472
+ }
473
+
403
474
  plain('');
404
- const applied = await applyMoves(root, moves, parks, manifest);
475
+ const applied = await applyMoves(root, willMove, willPark, manifest);
405
476
  const okCount = applied.results.filter((r) => r.ok).length;
406
477
  summary({
407
478
  ok: okCount + applied.parked.length,
408
479
  skipped: inPlace.length,
409
- failed: applied.results.length - okCount + (parks.length - applied.parked.length),
480
+ failed: applied.results.length - okCount + (willPark.length - applied.parked.length),
410
481
  okLabel: 'relocated',
411
482
  });
412
483
 
@@ -7,7 +7,7 @@ import { clone, defaultJobs, isMissingRemote, pooled } from '../git.js';
7
7
  import { board } from '../live.js';
8
8
  import { c, heading, icon, ok, plain, summary, warn } from '../log.js';
9
9
  import { chooseRepos } from '../select.js';
10
- import { requireCatalogue, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
10
+ import { adoptable, requireCatalogue, requireWorkspace, selectRepos, withPaths } from '../workspace.js';
11
11
  import { applyMoves, parseFromPaths, planFor } from './adopt.js';
12
12
 
13
13
  export const help = `
@@ -64,7 +64,14 @@ export async function adoptInPlace({ manifest, root, state, repos, opts }) {
64
64
  saveState(root, { ...state, scanPaths: [...new Set([...(state.scanPaths ?? []), ...extra])] });
65
65
  }
66
66
 
67
- const { moves, parks, refused } = await planFor({ manifest, root, repos, scanRoots, jobs: opts.jobs });
67
+ const ours = repos.filter((r) => !r.ignore);
68
+ const { moves, parks, refused } = await planFor({
69
+ manifest,
70
+ root,
71
+ repos: ours,
72
+ scanRoots,
73
+ jobs: opts.jobs,
74
+ });
68
75
 
69
76
  // A name-only match means the remote host is not one the catalogue lists, so
70
77
  // it is probably right but not certainly. This runs unattended, so it only
@@ -21,7 +21,7 @@ Options
21
21
  --apply write the file (default prints what would change)
22
22
 
23
23
  ${c.bold('What is preserved')} — a repo already in the catalogue keeps its ${c.dim('default')},
24
- ${c.dim('group')} and ${c.dim('dir')}. Discovery refreshes the facts GitHub owns; the choices are
24
+ ${c.dim('group')}, ${c.dim('dir')} and ${c.dim('ignore')}. Discovery refreshes the facts GitHub owns; the choices are
25
25
  yours and are never overwritten.
26
26
 
27
27
  Auth comes from ${c.dim('gh auth token')}, then ${c.dim('GITHUB_TOKEN')}, then nothing — and nothing is
@@ -85,6 +85,7 @@ export function merge(existing, found) {
85
85
  ...(prior?.group !== undefined ? { group: prior.group } : {}),
86
86
  ...(prior?.dir !== undefined ? { dir: prior.dir } : {}),
87
87
  ...(prior?.url !== undefined ? { url: prior.url } : {}),
88
+ ...(prior?.ignore !== undefined ? { ignore: prior.ignore } : {}),
88
89
  });
89
90
  }
90
91
 
@@ -1,25 +1,29 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import os from 'node:os';
5
+
4
6
  import {
5
7
  STATE_FILE,
6
8
  expandHome,
9
+ findWorkspace,
7
10
  groupDir,
8
11
  loadManifest,
9
12
  loadState,
10
13
  repoGroup,
11
14
  saveState,
12
15
  } from '../config.js';
16
+ import { samePath } from '../adopt.js';
13
17
  import { machineRepos } from '../workspace.js';
14
- import { c, context, heading, info, ok, plain, skip, warn } from '../log.js';
18
+ import { c, context, fail, heading, info, ok, plain, skip, warn } from '../log.js';
15
19
  import { run as discover } from './discover.js';
16
20
  import { run as sync } from './sync.js';
17
21
 
18
22
  export const help = `
19
23
  ${c.bold('talea init')} — set this machine up
20
24
 
21
- ${c.dim('talea init')} use the current folder as the workspace
22
- ${c.dim('talea init ~/Workspace')} use that folder instead
25
+ ${c.dim('talea init')} set up ~/Workspace
26
+ ${c.dim('talea init ~/code')} use that folder as the root instead
23
27
  ${c.dim('talea init --no-clone')} write the config, clone later
24
28
 
25
29
  Creates the workspace, records this machine's preferences in ${STATE_FILE}, asks
@@ -41,8 +45,53 @@ ${c.bold('On your second machine')}, pull the catalogue first:
41
45
  ${c.dim('talea init ~/Workspace')}
42
46
  `;
43
47
 
48
+ /**
49
+ * Where the workspace goes.
50
+ *
51
+ * The default is `~/Workspace`, never the current directory. `init` run from
52
+ * the home folder used to make HOME itself the root, which puts owner folders
53
+ * directly in the home directory and points every later scan at three levels of
54
+ * $HOME. It reads as working, so nobody notices until the tree is already
55
+ * spread out.
56
+ *
57
+ * The name comes from the catalogue, so a team that keeps its checkouts under
58
+ * `src/` or `code/` changes one field rather than telling everyone a flag.
59
+ */
60
+ export function workspaceTarget(positional, manifest) {
61
+ if (positional) return path.resolve(expandHome(positional));
62
+ return path.join(os.homedir(), manifest?.workspace || 'Workspace');
63
+ }
64
+
44
65
  export async function run(opts, positionals = []) {
45
- const target = path.resolve(expandHome(positionals[0] ?? process.cwd()));
66
+ const target = workspaceTarget(positionals[0], loadManifest(null));
67
+
68
+ // The home directory is not a workspace. Every owner folder would land beside
69
+ // Documents and Downloads, and `adopt` would scan three levels of $HOME on
70
+ // every run. Refused rather than warned: by the time the output scrolls past,
71
+ // the folders exist.
72
+ if (samePath(target, os.homedir())) {
73
+ fail('The home directory cannot be the workspace root.');
74
+ console.error(
75
+ `\n Owner folders would land beside Documents and Downloads, and every\n` +
76
+ ` scan would walk three levels of your home directory.\n\n` +
77
+ ` Try: ${c.bold(`talea init ${path.join(os.homedir(), loadManifest(null).workspace || 'Workspace')}`)}`,
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ // A workspace inside a workspace: the upward walk stops at the nearest
83
+ // .talea.json, so both would half-work and which one you got would depend on
84
+ // where you were standing.
85
+ const enclosing = findWorkspace(path.dirname(target));
86
+ if (enclosing) {
87
+ warn(`There is already a talea workspace at ${c.bold(enclosing)}.`);
88
+ plain(
89
+ c.dim(
90
+ ` Nesting one inside another means the one you get depends on which\n` +
91
+ ` folder you run from. Remove ${path.join(enclosing, STATE_FILE)} if it is stale.`,
92
+ ),
93
+ );
94
+ }
46
95
  const stateFile = path.join(target, STATE_FILE);
47
96
  const fresh = !existsSync(stateFile);
48
97
 
@@ -81,6 +81,7 @@ export function run(opts) {
81
81
  r.fork ? c.dim('fork') : '',
82
82
  r.archived ? c.yellow('quiet') : '',
83
83
  r.missing ? c.yellow('not on github') : '',
84
+ r.ignore ? c.yellow('ignored') : '',
84
85
  ]
85
86
  .filter(Boolean)
86
87
  .join(' '),
package/src/workspace.js CHANGED
@@ -46,12 +46,28 @@ export function requireWorkspace() {
46
46
  export function machineRepos(manifest, state) {
47
47
  const chosen = state.selected;
48
48
  if (!Array.isArray(chosen)) {
49
- return manifest.repos.filter((r) => r.default && !r.archived);
49
+ return manifest.repos.filter((r) => r.default && !r.archived && !r.ignore);
50
50
  }
51
51
  const wanted = new Set(chosen.map((n) => n.toLowerCase()));
52
- return manifest.repos.filter((r) => wanted.has(r.name.toLowerCase()));
52
+ return manifest.repos.filter((r) => wanted.has(r.name.toLowerCase()) && !r.ignore);
53
53
  }
54
54
 
55
+ /**
56
+ * Repos talea is allowed to touch at all.
57
+ *
58
+ * `ignore: true` means something else owns that checkout — another workspace
59
+ * manager, a vendored tree, an SDK cache. Without it two tools that both
60
+ * organise repositories will each drag the same checkout back to where it
61
+ * thinks it belongs, on every run, forever. Found with a repo living inside a
62
+ * second workspace manager's tree on the same disk.
63
+ *
64
+ * This is stronger than not selecting it: `adopt` deliberately works over the
65
+ * whole catalogue rather than this machine's selection, because a stray
66
+ * checkout is worth moving whether or not the machine signed up for it. An
67
+ * ignored repo is out of even that.
68
+ */
69
+ export const adoptable = (manifest) => manifest.repos.filter((r) => !r.ignore);
70
+
55
71
  /** Has this machine ever been asked what it wants? */
56
72
  export const hasChosen = (state) => Array.isArray(state.selected);
57
73