@ericrisco/rsc 2.0.1 → 2.0.3
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +14 -1
- package/scripts/install-apply.js +8 -3
- package/skills/worktrees/SKILL.md +6 -2
- package/targets/worktree-reaper.mjs +54 -4
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
|
|
1
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
3
|
import { join, dirname } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
@@ -84,9 +85,21 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
|
|
|
84
85
|
// different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
|
|
85
86
|
// at install time, and overwriting a husky hook to fix a convenience would be indefensible).
|
|
86
87
|
function worktreeCleanupStatus(root) {
|
|
87
|
-
const hook = join(root, '.git', 'hooks', 'post-merge');
|
|
88
88
|
if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
|
|
89
89
|
if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
|
|
90
|
+
// Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
|
|
91
|
+
// path, so on a repo with its own `core.hooksPath` it found our inert file there and answered
|
|
92
|
+
// `armed` about a hook git would never run — the check confirming the exact failure it exists to
|
|
93
|
+
// catch. `unreachable` is its own state on purpose: `repair` re-runs the same install, so
|
|
94
|
+
// recommending it here would send the reader round the same loop (P6 wants a way out, not a ring).
|
|
95
|
+
const dir = hooksDir(root);
|
|
96
|
+
if (!hooksDirIsOurs(root, dir)) {
|
|
97
|
+
return {
|
|
98
|
+
state: 'unreachable',
|
|
99
|
+
action: `git reads hooks from ${dir}, which belongs to this repo or to another tool (husky, lefthook, a committed dispatcher). rsc will not write there. To arm the cleanup, add \`node "$(git rev-parse --show-toplevel)/.rsc/worktree-reaper.mjs" "$(git rev-parse --show-toplevel)" auto\` to your own post-merge hook.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const hook = join(dir, 'post-merge');
|
|
90
103
|
if (!existsSync(hook)) {
|
|
91
104
|
return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
|
|
92
105
|
}
|
package/scripts/install-apply.js
CHANGED
|
@@ -252,15 +252,20 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
252
252
|
// content — declares a narrower set of paths, and writing a git hook it never agreed to would be
|
|
253
253
|
// acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
|
|
254
254
|
// which is exactly the kind of catch that justifies its existence.
|
|
255
|
+
// The answer used to be discarded, so a hook that could not arm looked exactly like one that
|
|
256
|
+
// did — and the only place that could have noticed, `doctor`, was reading the same wrong path.
|
|
257
|
+
// Reported 2026-09-18. Carried out, never thrown: the harness installing is the point, and the
|
|
258
|
+
// merge hook is a convenience layered on top of it.
|
|
259
|
+
let mergeHook = { installed: false, state: 'skipped' };
|
|
255
260
|
try {
|
|
256
261
|
if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
|
|
257
262
|
mkdirSync(join(cwd, '.rsc'), { recursive: true });
|
|
258
263
|
cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
|
|
259
264
|
const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
|
|
260
|
-
if (existsSync(join(cwd, '.git'))) installMergeHook(cwd);
|
|
261
|
-
} catch {
|
|
265
|
+
if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
|
|
266
|
+
} catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
|
|
262
267
|
ignoreLocalState(cwd, target);
|
|
263
|
-
return { ...state, backup };
|
|
268
|
+
return { ...state, backup, mergeHook };
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
@@ -75,7 +75,11 @@ Run this in order. Each check prevents a class of "lost work" you can't easily u
|
|
|
75
75
|
local HEAD. Stale base = predictable merge pain later. Default: fresh from `origin/<default>`.
|
|
76
76
|
6. **Choose a name** tied to the feature slug — the same `<slug>` the spec and plan use
|
|
77
77
|
(`feat/<slug>`), so the branch, the spec at `02-DOCS/wiki/sdd/specs/<slug>.md`, and the plan at
|
|
78
|
-
`02-DOCS/wiki/sdd/plans/<slug>.md` all line up and are trivially traceable.
|
|
78
|
+
`02-DOCS/wiki/sdd/plans/<slug>.md` all line up and are trivially traceable. A change that is not
|
|
79
|
+
a feature takes the type that fits — `fix/<slug>`, `docs/<slug>`, `chore/<slug>` — from the same
|
|
80
|
+
Conventional Commits vocabulary the commit guard already enforces here. **The prefix is load
|
|
81
|
+
bearing**: the automatic cleanup uses it, with the location, as the two signals that say a
|
|
82
|
+
worktree is ours to remove. A name outside that vocabulary is never swept for you.
|
|
79
83
|
|
|
80
84
|
Only once the tree state is understood and the user's WIP is accounted for do you create anything.
|
|
81
85
|
|
|
@@ -189,7 +193,7 @@ the dial controls verbosity, never whether you check before risking someone's WI
|
|
|
189
193
|
| "There's uncommitted WIP, I'll stash it and switch" | A worktree avoids the stash entirely and can't drop it. Use a worktree; if you must stash, confirm with the user and name the stash. |
|
|
190
194
|
| "I'll branch off local HEAD, fetching is slow" | Stale base = merge conflicts you pay for later. Branch off an up-to-date `origin/<default>` unless the user wants HEAD. |
|
|
191
195
|
| "The worktree's dirty but I'll `remove --force` to clean up" | Force-removing a dirty/unmerged worktree throws away work irreversibly. Resolve or confirm explicitly first; never silent-force. |
|
|
192
|
-
| "I'll name it `wip` / `temp` / `branch2`" | An untraceable name divorces the branch from its spec/plan
|
|
196
|
+
| "I'll name it `wip` / `temp` / `branch2`" | An untraceable name divorces the branch from its spec/plan, and the automatic cleanup will not claim it. Use `<type>/<slug>` — `feat/` for a feature, `fix/`, `docs/`, `chore/` for the rest. |
|
|
193
197
|
| "I'll create the worktree AND start writing code right here" | This skill only isolates. Hand a clean isolated workspace to `implement`; don't blur the two steps. |
|
|
194
198
|
| "Already on a feature branch, I'll make a worktree anyway" | Redundant isolation is just clutter. If the current branch is already isolated and clean, say so and proceed. |
|
|
195
199
|
| "I'll record the worktree path into 02-DOCS so it's tracked" | Isolation is plumbing, not knowledge. No artifact; the branch name traces it. Don't pollute the wiki. |
|
|
@@ -53,8 +53,20 @@ export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
|
|
|
53
53
|
// Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
|
|
54
54
|
const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
|
|
55
55
|
|
|
56
|
-
// Branch shapes
|
|
57
|
-
|
|
56
|
+
// Branch shapes the catalog's isolation produces.
|
|
57
|
+
//
|
|
58
|
+
// `worktrees` documents `feat/<slug>`, and while SDD was the only lane that was the whole set:
|
|
59
|
+
// every isolated branch was a feature. 2.0.0 made FTD the default and FTD names a branch for what
|
|
60
|
+
// it is — a fix is not a feature — so `fix/`, `docs/` and `chore/` fell outside, scored as half a
|
|
61
|
+
// signal, and were never swept, while `ftd` promises in writing that the cleanup is automatic once
|
|
62
|
+
// the branch lands. Found by using it: the two fixes released on 2026-09-18 both had to be removed
|
|
63
|
+
// by hand. These are the Conventional Commits types, which is what the commit guard already
|
|
64
|
+
// enforces on every commit in a project rsc governs — so the branch shape and the commit shape now
|
|
65
|
+
// come from the same vocabulary instead of two that drifted apart.
|
|
66
|
+
//
|
|
67
|
+
// Widening one signal, never the conjunction: a branch outside this vocabulary is still `ambiguous`
|
|
68
|
+
// wherever it sits, and one of these in a directory rsc does not own is still `ambiguous` too.
|
|
69
|
+
const RSC_BRANCH = /^(?:feat|feature|fix|docs|chore|refactor|test|perf|ci|build|style)\//;
|
|
58
70
|
|
|
59
71
|
function git(cwd, args) {
|
|
60
72
|
// 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
|
|
@@ -428,6 +440,41 @@ node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
|
|
|
428
440
|
exit 0
|
|
429
441
|
`;
|
|
430
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Where git will ACTUALLY look for hooks, asked of git rather than assumed.
|
|
445
|
+
*
|
|
446
|
+
* The assumption was `<root>/.git/hooks`, and it is wrong in three ordinary situations at once:
|
|
447
|
+
* a repo that sets `core.hooksPath` (husky v9 writes `.husky/_`, lefthook and committed
|
|
448
|
+
* `.githooks/` dispatchers do the same), a linked worktree where `.git` is a FILE and the mkdir
|
|
449
|
+
* raised ENOTDIR, and a submodule. Reported from a user's repo on 2026-09-18 against 2.0.1: rsc
|
|
450
|
+
* wrote the hook where git does not look, `doctor` read the same wrong path back, and the report
|
|
451
|
+
* said `armed` about something that could never run. Verified there and here that a single
|
|
452
|
+
* `rev-parse` answers all three.
|
|
453
|
+
*
|
|
454
|
+
* `--path-format=absolute` needs git 2.31 (Mar 2021). Older git gets the old path — which is
|
|
455
|
+
* correct whenever `core.hooksPath` is unset, i.e. the case it was always right about.
|
|
456
|
+
*/
|
|
457
|
+
export function hooksDir(root) {
|
|
458
|
+
const r = git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
|
|
459
|
+
if (r.ok && r.out) return r.out;
|
|
460
|
+
return join(real(root), '.git', 'hooks');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Whether that directory is ours to write in. Inside the repository's own git dir: yes, it is
|
|
464
|
+
// private plumbing that is never cloned and never committed. Anywhere else it belongs to the user
|
|
465
|
+
// or to another tool — a committed `.githooks/` is their source tree (writing there would put rsc
|
|
466
|
+
// in their diff, and moving an existing hook aside would rename a TRACKED file), and `.husky/_` is
|
|
467
|
+
// regenerated by husky, so anything we left there disappears on their next install. Report it and
|
|
468
|
+
// let the person decide; that is P4, and it is the same reasoning the comment on this function has
|
|
469
|
+
// claimed since the hook shipped, finally applied instead of only stated.
|
|
470
|
+
export function hooksDirIsOurs(root, dir) {
|
|
471
|
+
const r = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
472
|
+
if (!r.ok || !r.out) return false;
|
|
473
|
+
const gitDir = real(r.out);
|
|
474
|
+
const target = real(dir);
|
|
475
|
+
return target === gitDir || target.startsWith(gitDir + sep);
|
|
476
|
+
}
|
|
477
|
+
|
|
431
478
|
/**
|
|
432
479
|
* Install the trigger. The judgement has existed and been tested for a while; what never existed was
|
|
433
480
|
* something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
|
|
@@ -442,7 +489,10 @@ exit 0
|
|
|
442
489
|
*/
|
|
443
490
|
export function installMergeHook(root) {
|
|
444
491
|
try {
|
|
445
|
-
const dir =
|
|
492
|
+
const dir = hooksDir(root);
|
|
493
|
+
if (!hooksDirIsOurs(root, dir)) {
|
|
494
|
+
return { installed: false, state: 'unreachable', reason: `git reads hooks from ${dir}, which belongs to this repository or to another tool — rsc will not write there.` };
|
|
495
|
+
}
|
|
446
496
|
const hook = join(dir, 'post-merge');
|
|
447
497
|
mkdirSync(dir, { recursive: true });
|
|
448
498
|
if (existsSync(hook)) {
|
|
@@ -461,7 +511,7 @@ export function installMergeHook(root) {
|
|
|
461
511
|
chmodSync(hook, 0o755);
|
|
462
512
|
return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
|
|
463
513
|
} catch (err) {
|
|
464
|
-
return { installed: false, reason: err.message };
|
|
514
|
+
return { installed: false, state: 'failed', reason: err.message };
|
|
465
515
|
}
|
|
466
516
|
}
|
|
467
517
|
|