@ericrisco/rsc 1.1.6 → 1.2.1
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 +62 -4
- package/manifest.json +834 -2
- package/package.json +3 -2
- package/schema/ecc-originality.json +16 -0
- package/schema/ecc-originality.u64 +0 -0
- package/scripts/build-manifest.js +43 -3
- package/scripts/doctor.js +47 -1
- package/scripts/install-apply.js +148 -31
- package/scripts/lib/capabilities.js +21 -0
- package/scripts/lib/manifest-file.js +4 -1
- package/scripts/originality-check.js +137 -0
- package/scripts/rsc.js +168 -14
- package/skills/ship/SKILL.md +15 -8
- package/skills/worktrees/SKILL.md +30 -22
- package/targets/agent-catalog.js +220 -0
- package/targets/agent-sources.json +48 -0
- package/targets/agents.js +61 -13
- package/targets/claude.js +3 -0
- package/targets/commands.js +212 -0
- package/targets/memory.js +276 -0
- package/targets/opencode-memory-plugin.js +65 -0
- package/targets/session-memory-adapter.mjs +117 -0
- package/targets/session-memory-core.mjs +477 -0
- package/targets/session-memory.mjs +27 -0
- package/targets/session-start.mjs +31 -0
- package/targets/worktree-reaper.mjs +458 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rsc worktree reaper — the deterministic half of "the cleanup is the default".
|
|
3
|
+
//
|
|
4
|
+
// The catalog told agents, in prose, to remove a worktree once its work had landed. Prose is not an
|
|
5
|
+
// executor: `ship` carried the procedure in a paragraph under "park/discard", the block that runs on
|
|
6
|
+
// a direct merge never mentioned it, and nothing at all fired when the merge happened in the forge.
|
|
7
|
+
// So worktrees accumulated, and a list where live work and landed work look identical is a list
|
|
8
|
+
// nobody can act on — least of all the non-technical profile the harness claims to serve.
|
|
9
|
+
//
|
|
10
|
+
// This module owns the one judgement that must never be made by feel: *can this be removed without
|
|
11
|
+
// asking?* Three questions decide it, and every one of them fails towards keeping the directory:
|
|
12
|
+
//
|
|
13
|
+
// provenance — did rsc create it? (derived from the content, never from a registry: P3)
|
|
14
|
+
// integration — does the branch still carry anything the trunk does not have?
|
|
15
|
+
// contents — is there anything inside that was never in git and cannot be regenerated?
|
|
16
|
+
//
|
|
17
|
+
// Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
|
|
18
|
+
// rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
|
|
19
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { join, resolve, dirname, basename, relative, sep } from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
// git reports worktree paths with every symlink resolved; callers hand us whatever they were given.
|
|
24
|
+
// On macOS that alone is enough to make every comparison here fail, because /tmp and /var are
|
|
25
|
+
// symlinks — and a path comparison that silently never matches would turn this whole module into a
|
|
26
|
+
// no-op that looks healthy. Compare real paths on both sides, always.
|
|
27
|
+
function real(p) {
|
|
28
|
+
try { return realpathSync.native(resolve(p)); } catch { return resolve(p); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const OPT_OUT = '.no-worktree-cleanup';
|
|
32
|
+
|
|
33
|
+
// Content that lives outside git and is nevertheless disposable, as a table the test reads rather
|
|
34
|
+
// than conditionals it cannot see. Without this carve-out the default would never be automatic:
|
|
35
|
+
// virtually every worktree has dependencies installed inside it, and asking every single time is how
|
|
36
|
+
// a safeguard gets switched off (P7). The list ages — an ecosystem missing from it sends the
|
|
37
|
+
// worktree down the confirmation path, which is noise, not loss.
|
|
38
|
+
export const REGENERABLE = [
|
|
39
|
+
'node_modules', 'bower_components', 'jspm_packages', '.pnpm-store', '.yarn',
|
|
40
|
+
'venv', '.venv', 'env', '.tox', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
41
|
+
'dist', 'build', 'out', 'target', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.astro',
|
|
42
|
+
'.turbo', '.parcel-cache', '.cache', '.gradle', '.dart_tool', 'Pods', 'vendor',
|
|
43
|
+
'coverage', '.nyc_output',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Junk that is disposable wherever it appears, matched by filename. Kept SEPARATE from the table
|
|
47
|
+
// above, and deliberately tiny, because the two cannot share a matching rule: the first entries are
|
|
48
|
+
// directory names, and matching them against a *filename* is what made `config/env` — a dotenv file
|
|
49
|
+
// full of live credentials — read as build output and get deleted with no confirmation at all. A
|
|
50
|
+
// directory name means something only as a directory.
|
|
51
|
+
export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
|
|
52
|
+
|
|
53
|
+
// Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
|
|
54
|
+
const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
|
|
55
|
+
|
|
56
|
+
// Branch shapes `worktrees` imposes on the isolation it creates.
|
|
57
|
+
const RSC_BRANCH = /^(?:feat|feature)\//;
|
|
58
|
+
|
|
59
|
+
function git(cwd, args) {
|
|
60
|
+
// 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
|
|
61
|
+
// stray files and hands back a truncated-but-successful-looking result. Measured: in the 1.0-1.15 MB
|
|
62
|
+
// band the same unchanged worktree flipped between verdicts across runs.
|
|
63
|
+
const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
64
|
+
// `raw` matters for porcelain output: its leading space is a status code, not padding, and
|
|
65
|
+
// trimming it shifted every path by one character — so the message naming the file about to be
|
|
66
|
+
// lost named a file that does not exist. A refusal has to be true to be actionable (P6).
|
|
67
|
+
return { ok: r.status === 0, out: (r.stdout || '').trim(), raw: r.stdout || '', err: (r.stderr || '').trim() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The MAIN working tree for wherever we are standing.
|
|
72
|
+
*
|
|
73
|
+
* You cannot remove the directory you are inside, and closing a branch from the worktree you built it
|
|
74
|
+
* in is the most common way this is reached — so without this the common case is exactly the one that
|
|
75
|
+
* never gets cleaned. `--show-toplevel` is the wrong question here: inside a linked worktree it
|
|
76
|
+
* answers with that worktree. The common git dir is the only thing that points home.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveMainRoot(cwd = process.cwd()) {
|
|
79
|
+
const probe = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
80
|
+
if (probe.ok && probe.out) {
|
|
81
|
+
const home = real(dirname(probe.out));
|
|
82
|
+
// In the bare-repo layout (`proj/.bare` plus sibling worktrees) the parent of the common dir is
|
|
83
|
+
// not a repository at all, so every git question from there fails and the feature silently does
|
|
84
|
+
// nothing. That layout is one of the two mainstream worktree workflows; fall back to the git dir.
|
|
85
|
+
if (git(home, ['rev-parse', '--git-dir']).ok) return home;
|
|
86
|
+
return real(probe.out);
|
|
87
|
+
}
|
|
88
|
+
const legacy = git(cwd, ['rev-parse', '--git-common-dir']);
|
|
89
|
+
if (legacy.ok && legacy.out) return real(dirname(resolve(cwd, legacy.out)));
|
|
90
|
+
return real(cwd);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is this ref one of the trunk names? Deleting one is never a side effect of a cleanup. */
|
|
94
|
+
export function isTrunkName(branch) {
|
|
95
|
+
return TRUNKS.some((t) => t === branch || t.endsWith(`/${branch}`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The cleanup is on unless this project turned it off. Same shape as the other guards' switches. */
|
|
99
|
+
export function isCleanupEnabled(root) {
|
|
100
|
+
return !existsSync(join(root, '.rsc', OPT_OUT));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The first trunk ref that actually exists, or null when there is nothing to compare against. */
|
|
104
|
+
export function resolveTrunk(root) {
|
|
105
|
+
return TRUNKS.find((ref) => git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).ok) || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every working tree git knows about. The first entry is always the main one. */
|
|
109
|
+
export function listWorktrees(root) {
|
|
110
|
+
const { ok, out } = git(root, ['worktree', 'list', '--porcelain']);
|
|
111
|
+
if (!ok) return [];
|
|
112
|
+
const trees = [];
|
|
113
|
+
let cur = null;
|
|
114
|
+
for (const line of out.split('\n')) {
|
|
115
|
+
if (line.startsWith('worktree ')) {
|
|
116
|
+
cur = { path: real(line.slice('worktree '.length)), branch: null, head: null, locked: false, isMain: trees.length === 0 };
|
|
117
|
+
trees.push(cur);
|
|
118
|
+
} else if (!cur) continue;
|
|
119
|
+
else if (line.startsWith('HEAD ')) cur.head = line.slice('HEAD '.length);
|
|
120
|
+
else if (line.startsWith('branch ')) cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
121
|
+
// A lock is the strongest "do not touch this" git lets a person express. Dropping the line meant
|
|
122
|
+
// the sweep announced locked worktrees as removable and only git's own refusal stopped it.
|
|
123
|
+
else if (line === 'locked' || line.startsWith('locked ')) cur.locked = true;
|
|
124
|
+
}
|
|
125
|
+
// `git worktree list --porcelain` does not quote paths and has no -z form, so a directory whose name
|
|
126
|
+
// contains a newline fabricates an extra entry that inherits the real one's HEAD and branch. It can
|
|
127
|
+
// never be deleted (git refuses a path that is not a working tree), but it would be displayed, and
|
|
128
|
+
// `status --ignored` would be run against whatever it points at. Ask git whether each parsed path
|
|
129
|
+
// really belongs to this repository; a forgery does not.
|
|
130
|
+
const home = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
131
|
+
if (!home.ok || !home.out) return trees;
|
|
132
|
+
return trees.filter((wt) => {
|
|
133
|
+
if (wt.isMain) return true;
|
|
134
|
+
const mine = git(wt.path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
135
|
+
if (!mine.ok || real(mine.out) !== real(home.out)) return false;
|
|
136
|
+
// Belonging to this repository is not enough: any directory *inside* the checkout answers the
|
|
137
|
+
// same. A genuine worktree is its own root, so git's toplevel from there is the path itself.
|
|
138
|
+
const top = git(wt.path, ['rev-parse', '--show-toplevel']);
|
|
139
|
+
return top.ok && real(top.out) === wt.path;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whose worktree is this? Derived, because a list of "worktrees rsc created" is the parallel
|
|
145
|
+
* accounting P3 forbids — and a stale one would authorise deleting something it no longer describes.
|
|
146
|
+
*
|
|
147
|
+
* Two independent signals, and the conjunction is what counts as certainty. Deriving gets it wrong in
|
|
148
|
+
* both directions and only one of them is benign: ours, renamed by hand, is never cleaned (it
|
|
149
|
+
* accumulates, nothing is lost), while one the user made by hand that happens to match the
|
|
150
|
+
* convention would be indistinguishable from ours. So one signal alone is `ambiguous`, not `rsc`.
|
|
151
|
+
*/
|
|
152
|
+
export function provenanceOf(root, wt) {
|
|
153
|
+
const p = wt.path;
|
|
154
|
+
const home = real(root);
|
|
155
|
+
const inside = (dir) => p === dir || p.startsWith(dir + sep);
|
|
156
|
+
const location = inside(join(home, '.worktrees'))
|
|
157
|
+
|| inside(join(home, 'worktrees'))
|
|
158
|
+
|| (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
|
|
159
|
+
const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
|
|
160
|
+
if (location && branch) return 'rsc';
|
|
161
|
+
if (location || branch) return 'ambiguous';
|
|
162
|
+
return 'foreign';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Does the branch still carry anything the trunk does not have?
|
|
167
|
+
*
|
|
168
|
+
* Reachability alone answers "no" for every pull request the forge squashed or rebased — the most
|
|
169
|
+
* common way work lands anywhere — because those rewrite the commit identities. So the question is
|
|
170
|
+
* asked about content, which is what the contract actually says: if merging this branch into the
|
|
171
|
+
* trunk would not change the trunk's tree, the branch adds nothing.
|
|
172
|
+
*
|
|
173
|
+
* Cheapest first, and anything that cannot be answered is answered as "still carries work".
|
|
174
|
+
*/
|
|
175
|
+
/**
|
|
176
|
+
* Did this branch ever carry a commit of its own?
|
|
177
|
+
*
|
|
178
|
+
* A branch cut ten seconds ago adds nothing the trunk does not have, so every content test calls it
|
|
179
|
+
* integrated — and "integrated" was being read as "landed", which turned a live workspace that had
|
|
180
|
+
* merely run an install into a directory to delete. Nothing landed from a branch that never committed.
|
|
181
|
+
*
|
|
182
|
+
* The reflog is git's own record of the ref moving; when it cannot answer, the answer is no, because
|
|
183
|
+
* accumulating a stale worktree costs a directory and the other mistake costs someone's afternoon.
|
|
184
|
+
*/
|
|
185
|
+
export function hasLandedWork(root, wt, trunk) {
|
|
186
|
+
const tip = git(root, ['rev-parse', `${trunk}^{commit}`]);
|
|
187
|
+
if (tip.ok && wt.head && tip.out === wt.head) return false;
|
|
188
|
+
if (!wt.branch) return false;
|
|
189
|
+
const log = git(root, ['reflog', 'show', '--format=%gs', wt.branch]);
|
|
190
|
+
if (!log.ok || !log.out) return false;
|
|
191
|
+
return log.out.split('\n').some((line) => line.startsWith('commit'));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function integrationOf(root, wt, trunk) {
|
|
195
|
+
const head = wt.head || (wt.branch ? git(root, ['rev-parse', wt.branch]).out : null);
|
|
196
|
+
if (!head) return 'unknown';
|
|
197
|
+
if (git(root, ['merge-base', '--is-ancestor', head, trunk]).ok) return 'integrated';
|
|
198
|
+
|
|
199
|
+
const merged = git(root, ['merge-tree', '--write-tree', trunk, head]);
|
|
200
|
+
// A conflict exits non-zero with a tree on stdout; an unsupported flag (git < 2.38) exits non-zero
|
|
201
|
+
// with usage on stderr and nothing usable. Neither is integration, but only the second is ignorance.
|
|
202
|
+
if (!merged.ok && !merged.out) return 'unknown';
|
|
203
|
+
const trunkTree = git(root, ['rev-parse', `${trunk}^{tree}`]);
|
|
204
|
+
if (!merged.ok || !trunkTree.ok) return 'not-integrated';
|
|
205
|
+
return merged.out.split('\n')[0].trim() === trunkTree.out ? 'integrated' : 'not-integrated';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What is inside that git would not miss? Split into what git calls dirty (tracked changes) and what
|
|
210
|
+
* git cannot see at all — untracked and *ignored* files.
|
|
211
|
+
*
|
|
212
|
+
* The second half is the one that matters and the one every "is it clean?" check forgets: a worktree
|
|
213
|
+
* with nothing pending can still hold the only copy of an .env, a local database or a page of notes,
|
|
214
|
+
* and removing the directory takes them with it. `--ignored` is the only way to be told they exist.
|
|
215
|
+
*/
|
|
216
|
+
export function contentOutsideHistory(wtPath) {
|
|
217
|
+
const { ok, raw } = git(wtPath, ['status', '--porcelain', '-z', '-uall', '--ignored=matching']);
|
|
218
|
+
const dirty = [];
|
|
219
|
+
const outside = [];
|
|
220
|
+
if (!ok) return { dirty, outside, readable: false };
|
|
221
|
+
const fields = raw.split('\0');
|
|
222
|
+
for (let i = 0; i < fields.length; i++) {
|
|
223
|
+
const entry = fields[i];
|
|
224
|
+
if (entry.length < 4) continue;
|
|
225
|
+
const code = entry.slice(0, 2);
|
|
226
|
+
const path = entry.slice(3);
|
|
227
|
+
// A rename emits TWO fields: the target, then the bare origin path with no status code. Treating
|
|
228
|
+
// that origin as another entry sliced three characters off it, so the refusal named a file that
|
|
229
|
+
// does not exist — the exact failure the `raw` comment above exists to prevent (P6).
|
|
230
|
+
if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
|
|
231
|
+
if (code === '??' || code === '!!') {
|
|
232
|
+
if (!isRegenerable(path)) outside.push(path);
|
|
233
|
+
} else dirty.push(path);
|
|
234
|
+
}
|
|
235
|
+
return { dirty, outside, readable: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A directory name counts anywhere it is used as a DIRECTORY; a filename counts only as a filename.
|
|
239
|
+
// Matching the table against the last segment made `config/env` — a dotenv full of live credentials —
|
|
240
|
+
// read as a virtualenv and get deleted. Matching it only at position 0 fixed that and broke every
|
|
241
|
+
// monorepo, where the disposable directory is `packages/app/node_modules`. Position is the whole rule.
|
|
242
|
+
function isRegenerable(path) {
|
|
243
|
+
const parts = path.split('/').filter(Boolean);
|
|
244
|
+
// git reports a wholly-ignored directory with a trailing slash and does not expand it, so there the
|
|
245
|
+
// last segment is a directory too. Getting this wrong is what made `node_modules/` itself — the most
|
|
246
|
+
// common entry there is — fail the very carve-out written for it.
|
|
247
|
+
const isDir = path.endsWith('/');
|
|
248
|
+
const dirs = isDir ? parts : parts.slice(0, -1);
|
|
249
|
+
return dirs.some((d) => REGENERABLE.includes(d))
|
|
250
|
+
|| (!isDir && REGENERABLE_FILES.includes(parts[parts.length - 1]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every worktree with a verdict: `safe` (remove without asking), `ask` (confirm first, and the
|
|
255
|
+
* reasons say why), `skip` (not ours, or still carrying work — never offered).
|
|
256
|
+
*
|
|
257
|
+
* Returns nothing at all when the cleanup is off or the trunk cannot be resolved: with no trunk there
|
|
258
|
+
* is no way to tell landed work from live work, and guessing is the one thing this must not do.
|
|
259
|
+
*/
|
|
260
|
+
// No submodule guard here, deliberately. It was written because a submodule *looks* like a worktree,
|
|
261
|
+
// and a planted mutant proved it could never fire: `git worktree list` does not report submodules, so
|
|
262
|
+
// nothing this iterates can ever be one. A guard that cannot fail is the decorative gate P2 exists to
|
|
263
|
+
// forbid — the invariant is structural, and test 32 is what holds it.
|
|
264
|
+
export function classifyWorktrees(root) {
|
|
265
|
+
if (!isCleanupEnabled(root)) return [];
|
|
266
|
+
const trunk = resolveTrunk(root);
|
|
267
|
+
if (!trunk) return [];
|
|
268
|
+
|
|
269
|
+
return listWorktrees(root)
|
|
270
|
+
.filter((wt) => !wt.isMain && wt.path !== real(root))
|
|
271
|
+
.map((wt) => {
|
|
272
|
+
const base = { path: wt.path, branch: wt.branch, verdict: 'skip', reasons: [], details: {} };
|
|
273
|
+
|
|
274
|
+
// A detached worktree has no branch, so half the provenance signal cannot even be asked for and
|
|
275
|
+
// every message about it interpolates `null`. Nothing to reason about; leave it alone.
|
|
276
|
+
if (!wt.branch) return { ...base, reasons: ['detached'] };
|
|
277
|
+
|
|
278
|
+
const provenance = provenanceOf(root, wt);
|
|
279
|
+
if (provenance === 'foreign') return { ...base, reasons: ['foreign'] };
|
|
280
|
+
|
|
281
|
+
if (wt.locked) return { ...base, reasons: ['locked'] };
|
|
282
|
+
|
|
283
|
+
const integration = integrationOf(root, wt, trunk);
|
|
284
|
+
if (integration !== 'integrated') return { ...base, reasons: [integration] };
|
|
285
|
+
if (!hasLandedWork(root, wt, trunk)) return { ...base, reasons: ['nothing-landed'] };
|
|
286
|
+
|
|
287
|
+
const { dirty, outside, readable } = contentOutsideHistory(wt.path);
|
|
288
|
+
if (!readable) return { ...base, reasons: ['unreadable'] };
|
|
289
|
+
|
|
290
|
+
const reasons = [];
|
|
291
|
+
if (dirty.length) reasons.push('dirty');
|
|
292
|
+
if (outside.length) reasons.push('content-outside-history');
|
|
293
|
+
if (provenance === 'ambiguous') reasons.push('provenance-ambiguous');
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
...base,
|
|
297
|
+
verdict: reasons.length ? 'ask' : 'safe',
|
|
298
|
+
reasons,
|
|
299
|
+
details: { dirty, outside },
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Remove one worktree, and its branch only when git will do it the safe way.
|
|
306
|
+
*
|
|
307
|
+
* The branch rule is the recovery net for the one judgement here that is inferred rather than proven:
|
|
308
|
+
* a squash-merged branch is equivalent by content and not by identity, so `branch -d` refuses it. We
|
|
309
|
+
* do not override that refusal. The directory goes, the branch stays, and while the branch exists the
|
|
310
|
+
* commits are recoverable even if the equivalence was judged wrong. Never `-D`.
|
|
311
|
+
*
|
|
312
|
+
* `--force` on the worktree removal is not a relaxation: our own gate above is strictly stronger than
|
|
313
|
+
* git's, because git's refusal ignores ignored files and ours does not. By this point the contents
|
|
314
|
+
* have already been classified as either nothing, or regenerable, or explicitly confirmed.
|
|
315
|
+
*/
|
|
316
|
+
export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
|
|
317
|
+
const target = real(targetPath);
|
|
318
|
+
if (!isCleanupEnabled(root)) {
|
|
319
|
+
return { removed: false, reason: `cleanup is off for this project (.rsc/${OPT_OUT}); remove it by hand with \`git worktree remove\`` };
|
|
320
|
+
}
|
|
321
|
+
if (target === real(root)) {
|
|
322
|
+
return { removed: false, reason: 'that is the main working tree, not a worktree to remove' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const candidate = classifyWorktrees(root).find((c) => c.path === target);
|
|
326
|
+
if (!candidate) {
|
|
327
|
+
return { removed: false, reason: `${target} is not a worktree of this repository, or the trunk could not be resolved` };
|
|
328
|
+
}
|
|
329
|
+
if (candidate.verdict === 'skip') {
|
|
330
|
+
return { removed: false, reason: refusal(candidate) };
|
|
331
|
+
}
|
|
332
|
+
if (candidate.verdict === 'ask' && !confirmed) {
|
|
333
|
+
return { removed: false, reason: refusal(candidate) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const removal = git(root, ['worktree', 'remove', '--force', target]);
|
|
337
|
+
if (!removal.ok) {
|
|
338
|
+
return { removed: false, reason: `git refused to remove it: ${removal.err || removal.out}` };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// `-d` refuses an unmerged branch, which is the recovery net — but it happily deletes the TRUNK,
|
|
342
|
+
// which is an ancestor of everything. A sibling checkout of `main` classified as ambiguous, the user
|
|
343
|
+
// confirmed removing a stray directory, and the trunk ref went with it.
|
|
344
|
+
const branchDeleted = candidate.branch && !isTrunkName(candidate.branch)
|
|
345
|
+
? git(root, ['branch', '-d', candidate.branch]).ok
|
|
346
|
+
: false;
|
|
347
|
+
// No `git worktree prune` here. It has no expiry of its own, so running it while another worktree's
|
|
348
|
+
// volume happens to be unmounted de-registers that worktree immediately — and this project lives on
|
|
349
|
+
// an external drive. `git worktree remove` already clears the entry it owns.
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
removed: true,
|
|
353
|
+
path: target,
|
|
354
|
+
branch: candidate.branch,
|
|
355
|
+
branchDeleted,
|
|
356
|
+
branchKept: Boolean(candidate.branch) && !branchDeleted,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Every refusal carries the way out, because the person receiving it may not be able to deduce one (P6).
|
|
362
|
+
*
|
|
363
|
+
* EVERY reason, not the first one recorded. A worktree that is both dirty and holding an untracked
|
|
364
|
+
* file used to be described by the dirty half alone — so the user read "uncommitted changes in
|
|
365
|
+
* README.md", judged that a nuisance, confirmed, and the confirmation landed on a `production.env`
|
|
366
|
+
* the message never mentioned. The module knew. It just did not say.
|
|
367
|
+
*/
|
|
368
|
+
export function refusal(candidate) {
|
|
369
|
+
const d = candidate.details || {};
|
|
370
|
+
const parts = (candidate.reasons || []).map((reason) => {
|
|
371
|
+
switch (reason) {
|
|
372
|
+
case 'foreign':
|
|
373
|
+
return 'it was not created by rsc, so it is not ours to delete. Remove it yourself with `git worktree remove` if you want it gone.';
|
|
374
|
+
case 'not-integrated':
|
|
375
|
+
return `\`${candidate.branch}\` still carries work that is not in the trunk. Land it (\`ship\`) or discard it deliberately first.`;
|
|
376
|
+
case 'unknown':
|
|
377
|
+
return `whether \`${candidate.branch}\` is integrated could not be determined (old git, or no comparable trunk). Check with \`git log --oneline <trunk>..${candidate.branch}\` and remove it by hand if you are satisfied.`;
|
|
378
|
+
case 'detached':
|
|
379
|
+
return 'it has no branch checked out (detached HEAD), so there is nothing to judge as landed. Left alone.';
|
|
380
|
+
case 'locked':
|
|
381
|
+
return 'it is locked. Unlock it deliberately (`git worktree unlock`) if you really want it gone.';
|
|
382
|
+
case 'nothing-landed':
|
|
383
|
+
return `\`${candidate.branch}\` has never carried a commit, so nothing landed from it — this is a live workspace, not leftovers.`;
|
|
384
|
+
case 'unreadable':
|
|
385
|
+
return 'git could not report the state of that directory; nothing was touched.';
|
|
386
|
+
case 'dirty':
|
|
387
|
+
return `it has uncommitted changes: ${list(d.dirty)}. Commit, stash or discard them first.`;
|
|
388
|
+
case 'content-outside-history':
|
|
389
|
+
return `it holds files that are in no commit and would be lost: ${list(d.outside)}. Move or copy them out first.`;
|
|
390
|
+
case 'provenance-ambiguous':
|
|
391
|
+
return `only half of the rsc convention matches (\`${candidate.branch}\`), so it may be yours rather than ours. Confirm before it is removed.`;
|
|
392
|
+
default:
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}).filter(Boolean);
|
|
396
|
+
return parts.length ? parts.join(' Also: ') : 'nothing was removed.';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Naming what is at risk is the point; naming ten thousand of them is a denial of service against the
|
|
400
|
+
// reader and against the context window. Show enough to recognise, then say how many are left.
|
|
401
|
+
const SHOWN = 5;
|
|
402
|
+
function list(items = []) {
|
|
403
|
+
if (!items.length) return 'none';
|
|
404
|
+
const head = items.slice(0, SHOWN).join(', ');
|
|
405
|
+
return items.length > SHOWN ? `${head} and ${items.length - SHOWN} more` : head;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** One line per candidate, for someone who asked — the CLI names what is at risk. */
|
|
409
|
+
export function describe(candidate) {
|
|
410
|
+
const who = `${candidate.path}${candidate.branch ? ` (${candidate.branch})` : ''}`;
|
|
411
|
+
if (candidate.verdict === 'safe') return ` safe ${who} — landed and empty of anything unsaved`;
|
|
412
|
+
return ` ${candidate.verdict === 'skip' ? 'keep' : 'ask '} ${who} — ${refusal(candidate)}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One line per candidate for the session-start sweep, which nobody asked for.
|
|
417
|
+
*
|
|
418
|
+
* That block is injected into the model's context on every startup, so it is held to a stricter rule
|
|
419
|
+
* than the CLI: paths relative to the repository (P9 — nothing distributed carries machine paths) and
|
|
420
|
+
* **counts instead of filenames**. The files this would otherwise enumerate are, by construction, the
|
|
421
|
+
* ones outside git: the .env, the private key, the client folder — exactly what the user marked as
|
|
422
|
+
* not-for-sharing. Enough to decide whether to look; nothing that leaks by being read.
|
|
423
|
+
*/
|
|
424
|
+
export function summarize(candidate, root) {
|
|
425
|
+
const where = relative(real(root), candidate.path) || candidate.path;
|
|
426
|
+
const who = `${where}${candidate.branch ? ` (${candidate.branch})` : ''}`;
|
|
427
|
+
if (candidate.verdict === 'safe') return ` safe ${who} — landed, nothing unsaved inside`;
|
|
428
|
+
if (candidate.verdict === 'skip') return ` keep ${who} — ${candidate.reasons.join(', ')}`;
|
|
429
|
+
const d = candidate.details || {};
|
|
430
|
+
const why = candidate.reasons.map((r) => {
|
|
431
|
+
if (r === 'dirty') return `${d.dirty.length} uncommitted change(s)`;
|
|
432
|
+
if (r === 'content-outside-history') return `${d.outside.length} file(s) in no commit`;
|
|
433
|
+
if (r === 'provenance-ambiguous') return 'provenance unclear';
|
|
434
|
+
return r;
|
|
435
|
+
}).join(', ');
|
|
436
|
+
return ` ask ${who} — ${why}; ask before removing`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// CLI: `node worktree-reaper.mjs <root> [reap [path]]`. `scripts/rsc.js` is the public entry point;
|
|
440
|
+
// this exists so the materialized copy in .rsc/ is runnable on its own.
|
|
441
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
|
442
|
+
const root = resolve(process.argv[2] || process.cwd());
|
|
443
|
+
const candidates = classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
444
|
+
if (process.argv[3] === 'reap') {
|
|
445
|
+
const one = process.argv[4];
|
|
446
|
+
const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|
|
447
|
+
for (const t of targets) {
|
|
448
|
+
const out = reapWorktree(root, t, { confirmed: Boolean(one) });
|
|
449
|
+
process.stdout.write(out.removed
|
|
450
|
+
? `removed ${t}${out.branchKept ? ` (branch ${out.branch} kept — git will not delete it safely)` : ''}\n`
|
|
451
|
+
: `kept ${t} — ${out.reason}\n`);
|
|
452
|
+
}
|
|
453
|
+
} else if (!candidates.length) {
|
|
454
|
+
process.stdout.write('no worktrees to clean up.\n');
|
|
455
|
+
} else {
|
|
456
|
+
process.stdout.write(candidates.map(describe).join('\n') + '\n');
|
|
457
|
+
}
|
|
458
|
+
}
|