@ajaykumarnpm/talea 0.1.0 → 0.2.2
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/CHANGELOG.md +49 -0
- package/README.md +24 -0
- package/package.json +3 -2
- package/src/adopt.js +141 -49
- package/src/cli.js +1 -0
- package/src/commands/adopt.js +83 -12
- package/src/commands/clone.js +9 -2
- package/src/commands/discover.js +2 -1
- package/src/commands/init.js +53 -4
- package/src/commands/list.js +1 -0
- package/src/live.js +10 -1
- package/src/log.js +2 -1
- package/src/theme.js +34 -0
- package/src/workspace.js +18 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.2.2] - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
- the skip marker is read from the commit body too
|
|
11
|
+
|
|
12
|
+
### Chores
|
|
13
|
+
- publish before pushing the tag
|
|
14
|
+
- the pipeline cuts the release, not a laptop
|
|
15
|
+
|
|
16
|
+
## [0.2.1] - 2026-09-21
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- The live block no longer repeats itself down the screen. A row wider than the
|
|
20
|
+
terminal wrapped onto a second line, which threw off the cursor arithmetic the
|
|
21
|
+
block redraws with, so every frame landed a line lower than the last. Rows are
|
|
22
|
+
now cut to the window width.
|
|
23
|
+
|
|
24
|
+
### Chores
|
|
25
|
+
- Releases are published by a GitHub release rather than by pushing a tag, and
|
|
26
|
+
npm authenticates with trusted publishing instead of a stored token.
|
|
27
|
+
|
|
28
|
+
## [0.2.0] - 2026-09-21
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- `ignore: true` in the catalogue marks a checkout that another tool owns. No
|
|
32
|
+
command touches it — not `sync`, not `adopt`, not an explicit selection.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `init` defaults to `~/Workspace` and refuses to turn your home directory into
|
|
36
|
+
a workspace.
|
|
37
|
+
- `adopt` moves a repo's worktrees with it and repairs their links, instead of
|
|
38
|
+
leaving them stranded.
|
|
39
|
+
- A repo that matches only by name is never moved unattended. Say `--loose`, or
|
|
40
|
+
name it with `-r`, to move one anyway.
|
|
41
|
+
|
|
42
|
+
## [0.1.0] - 2026-09-21
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
- First published release: `discover`, `clone`, `sync`, `adopt`, `add`, `rm`,
|
|
46
|
+
`where`, `status` and `doctor`, the live progress block, and the shared
|
|
47
|
+
catalogue with a per-machine selection that never travels with it.
|
|
48
|
+
- Published as `@ajaykumarnpm/talea` — npm's typo-squatting filter refused the
|
|
49
|
+
bare name.
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"src",
|
|
18
18
|
"manifest",
|
|
19
19
|
"templates",
|
|
20
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node --test"
|
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
|
-
*
|
|
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
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
556
|
+
* What git records about this repo's worktrees, read BEFORE it moves.
|
|
510
557
|
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
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
|
|
564
|
+
export function readWorktrees(dir) {
|
|
517
565
|
const listed = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
518
|
-
cwd:
|
|
566
|
+
cwd: dir,
|
|
519
567
|
encoding: 'utf8',
|
|
520
568
|
});
|
|
521
|
-
|
|
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
|
|
524
|
-
.
|
|
525
|
-
.
|
|
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 (
|
|
531
|
-
spawnSync('git', ['worktree', 'repair', ...
|
|
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
|
-
|
|
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 },
|
package/src/commands/adopt.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
361
|
-
` Check those remotes
|
|
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 (
|
|
394
|
-
if (
|
|
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,
|
|
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 + (
|
|
480
|
+
failed: applied.results.length - okCount + (willPark.length - applied.parked.length),
|
|
410
481
|
okLabel: 'relocated',
|
|
411
482
|
});
|
|
412
483
|
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
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
|
package/src/commands/discover.js
CHANGED
|
@@ -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('
|
|
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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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')}
|
|
22
|
-
${c.dim('talea init ~/
|
|
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 =
|
|
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
|
|
package/src/commands/list.js
CHANGED
package/src/live.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
paint,
|
|
20
20
|
spinner,
|
|
21
21
|
statusLine,
|
|
22
|
+
truncVisible,
|
|
22
23
|
visibleWidth,
|
|
23
24
|
} from './log.js';
|
|
24
25
|
|
|
@@ -95,10 +96,18 @@ export function board(items, { live = process.stdout.isTTY } = {}) {
|
|
|
95
96
|
drawn = 0;
|
|
96
97
|
};
|
|
97
98
|
|
|
99
|
+
// One line, one row. A line wider than the window wraps, and then `drawn` —
|
|
100
|
+
// a count of lines, not of rows — climbs back too few rows and the next frame
|
|
101
|
+
// is drawn below the last one instead of over it. That is how the block ends
|
|
102
|
+
// up repeating itself down the screen with half-cleared remnants in between.
|
|
103
|
+
// The last column is left empty: a line filling the row exactly leaves some
|
|
104
|
+
// terminals in a deferred-wrap state that the following newline then spends.
|
|
105
|
+
const fitRow = (l) => truncVisible(l, Math.max(1, (process.stdout.columns || 80) - 1));
|
|
106
|
+
|
|
98
107
|
const draw = () => {
|
|
99
108
|
const body = lines();
|
|
100
109
|
let s = (drawn ? ansi.up(drawn) : '') + ansi.cr;
|
|
101
|
-
for (const l of body) s += ansi.clearLine + l + '\n';
|
|
110
|
+
for (const l of body) s += ansi.clearLine + fitRow(l) + '\n';
|
|
102
111
|
// The previous block may have been taller — clear what is left of it, then
|
|
103
112
|
// put the cursor back under the new one.
|
|
104
113
|
const extra = Math.max(0, drawn - body.length);
|
package/src/log.js
CHANGED
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
padEndVisible,
|
|
18
18
|
spinner,
|
|
19
19
|
stripAnsi,
|
|
20
|
+
truncVisible,
|
|
20
21
|
useColor,
|
|
21
22
|
visibleWidth,
|
|
22
23
|
} from './theme.js';
|
|
23
24
|
|
|
24
|
-
export { ansi, glyph, padEndVisible, paint, spinner, useColor, visibleWidth };
|
|
25
|
+
export { ansi, glyph, padEndVisible, paint, spinner, truncVisible, useColor, visibleWidth };
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Named colours, kept because every command's `help` string and most inline
|
package/src/theme.js
CHANGED
|
@@ -136,3 +136,37 @@ export function centerVisible(s, width) {
|
|
|
136
136
|
|
|
137
137
|
/** Terminal width, clamped to something a human can read across. */
|
|
138
138
|
export const columns = () => Math.max(40, Math.min(process.stdout.columns || 80, 120));
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cut a string to `width` visible columns, leaving its escape codes intact.
|
|
142
|
+
*
|
|
143
|
+
* The live block redraws by counting the lines it wrote and climbing back up
|
|
144
|
+
* that many. A line wider than the window wraps onto two rows, so the count is
|
|
145
|
+
* short by one and every redraw lands a row lower — the block duplicates itself
|
|
146
|
+
* down the screen. Truncating keeps one line to one row.
|
|
147
|
+
*/
|
|
148
|
+
export function truncVisible(s, width) {
|
|
149
|
+
const str = String(s);
|
|
150
|
+
if (width <= 0) return '';
|
|
151
|
+
if (visibleWidth(str) <= width) return str;
|
|
152
|
+
let left = width;
|
|
153
|
+
let out = '';
|
|
154
|
+
// eslint-disable-next-line no-control-regex
|
|
155
|
+
for (const part of str.split(/(\x1b\[[0-9;?]*[A-Za-z])/)) {
|
|
156
|
+
if (!part) continue;
|
|
157
|
+
if (part.startsWith('\x1b[')) {
|
|
158
|
+
out += part;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const chars = [...part];
|
|
162
|
+
if (chars.length <= left) {
|
|
163
|
+
out += part;
|
|
164
|
+
left -= chars.length;
|
|
165
|
+
} else {
|
|
166
|
+
out += chars.slice(0, left).join('');
|
|
167
|
+
left = 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Cut mid-colour, so close it — otherwise the colour bleeds down the screen.
|
|
171
|
+
return out + (useColor ? '\x1b[0m' : '');
|
|
172
|
+
}
|
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
|
|