@ericrisco/rsc 3.0.7 → 3.0.8
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 +22 -11
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/onboarding-apply.js +27 -24
- package/scripts/lib/onboarding.js +19 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +92 -13
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +211 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/worktree-reaper.mjs +109 -3
package/targets/ship-guard.mjs
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Enforces the "close the feature before you leave it" rule at the one deterministic
|
|
7
7
|
// moment it matters: when a Bash command tries to switch to the trunk (main/master)
|
|
8
|
-
// or merge into it.
|
|
8
|
+
// or merge into it. A merge run ON the feature branch (`git merge origin/main`) brings
|
|
9
|
+
// the trunk in — it leaves nothing behind, so it is not judged (team simulation D10);
|
|
10
|
+
// a merge counts only where it lands on a trunk checkout (`git -C ../main merge feat`).
|
|
11
|
+
// rsc's own knowledge commits (📥/📝 docs(auto)) are not the person's unpushed work. If the current feature branch has uncommitted changes or commits
|
|
9
12
|
// that were never pushed, the guard DENIES the command and tells the agent to run
|
|
10
13
|
// `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
|
|
11
14
|
// hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
|
|
@@ -14,7 +17,7 @@
|
|
|
14
17
|
// and FAIL-OPEN — any ambiguity (detached HEAD, no repo, git error) allows the command.
|
|
15
18
|
// Opt out per project with .rsc/.no-ship-guard.
|
|
16
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
20
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
18
21
|
import { spawnSync } from 'node:child_process';
|
|
19
22
|
|
|
20
23
|
const root = process.argv[2] || process.cwd();
|
|
@@ -62,14 +65,42 @@ try {
|
|
|
62
65
|
if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
|
|
63
66
|
|
|
64
67
|
// Does this command try to land on / move to the trunk?
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// Global options may sit before the subcommand (`git -C . switch main`, `git -c k=v merge`), and the
|
|
69
|
+
// command may be handed to a shell (`bash -c "git switch main"`): the segments judged are the ones the
|
|
70
|
+
// shell really runs, quoted text blanked (shell-unwrap.mjs, shared with the other guards — E2E defect
|
|
71
|
+
// 15). Without that sibling, the raw command as before.
|
|
72
|
+
const GOPTS = String.raw`(?:(?:-[Cc]\s+\S+|--\S+)\s+)*`;
|
|
73
|
+
const TRUNK = new RegExp(String.raw`\bgit\s+${GOPTS}(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b`);
|
|
74
|
+
const MERGE = new RegExp(String.raw`\bgit\s+${GOPTS}merge\b`);
|
|
75
|
+
const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
|
|
76
|
+
const judged = SH ? SH.expand(command).map((s) => s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')) : [command];
|
|
68
77
|
|
|
69
|
-
const
|
|
70
|
-
const r = spawnSync('git', ['-C',
|
|
78
|
+
const gitIn = (dir, ...args) => {
|
|
79
|
+
const r = spawnSync('git', ['-C', dir, ...args], { windowsHide: true, encoding: 'utf8' });
|
|
71
80
|
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
72
81
|
};
|
|
82
|
+
const git = (...args) => gitIn(root, ...args);
|
|
83
|
+
|
|
84
|
+
// A merge lands on the trunk only where the checkout it runs in IS on the trunk. Here that means a
|
|
85
|
+
// `git -C <dir> merge` aimed at another checkout; a merge in this one runs on the feature branch
|
|
86
|
+
// (checked below), and `git switch main && git merge x` is already caught by its switch.
|
|
87
|
+
function mergesIntoTrunk(segment) {
|
|
88
|
+
if (!MERGE.test(segment)) return false;
|
|
89
|
+
const m = segment.match(/\bgit\s+(?:-c\s+\S+\s+)*-C\s+(\S+)/);
|
|
90
|
+
if (!m) return false;
|
|
91
|
+
const dir = isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
|
|
92
|
+
const b = gitIn(dir, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
93
|
+
return b === 'main' || b === 'master';
|
|
94
|
+
}
|
|
95
|
+
if (!judged.some((s) => TRUNK.test(s) || mergesIntoTrunk(s))) allow();
|
|
96
|
+
|
|
97
|
+
// Commits in a range that are the person's: rsc's own knowledge commits are left out.
|
|
98
|
+
const RSC_OWN = /^(?:📥|📝) docs\(auto\)/u;
|
|
99
|
+
function ownCommits(range) {
|
|
100
|
+
const log = git('log', '--format=%s', range);
|
|
101
|
+
if (log === null) return null;
|
|
102
|
+
return log.split('\n').filter((s) => s && !RSC_OWN.test(s)).length;
|
|
103
|
+
}
|
|
73
104
|
|
|
74
105
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
75
106
|
// Not on a feature branch (already trunk, detached, or git failed) → nothing to guard.
|
|
@@ -86,13 +117,13 @@ if (dirty && dirty.length > 0) {
|
|
|
86
117
|
// 2) Commits exist but were never pushed.
|
|
87
118
|
const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}');
|
|
88
119
|
if (upstream) {
|
|
89
|
-
const ahead =
|
|
120
|
+
const ahead = ownCommits(`${upstream}..HEAD`);
|
|
90
121
|
if (ahead && Number(ahead) > 0) {
|
|
91
122
|
deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
|
|
92
123
|
}
|
|
93
124
|
} else {
|
|
94
125
|
// No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
|
|
95
|
-
const aheadOfTrunk =
|
|
126
|
+
const aheadOfTrunk = ownCommits('main..HEAD') ?? ownCommits('master..HEAD');
|
|
96
127
|
if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
|
|
97
128
|
deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
|
|
98
129
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Each returns the text to say, or ''. None throws: a session start is never worth breaking.
|
|
13
13
|
// Standalone siblings under `.rsc/`: trunk-policy.mjs, worktree-reaper.mjs, session-memory-core.mjs.
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
|
-
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { basename, join, sep } from 'node:path';
|
|
17
17
|
|
|
18
18
|
const git = (root, args) => {
|
|
@@ -92,13 +92,23 @@ export async function relocateOldWorktrees(root) {
|
|
|
92
92
|
|
|
93
93
|
const MARK = '.team-safe-3';
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
// Which language to say it in. The profile's `language:` frontmatter field when there is one, then the
|
|
96
|
+
// locale the assistant runs under, and English when neither says: this banner reached English-speaking
|
|
97
|
+
// users in Spanish. The other always-on texts are English and ask the agent to relay them in the
|
|
98
|
+
// person's language; this one keeps a Spanish version because it was written for Spanish users first.
|
|
99
|
+
export function announcementLanguage(root, env = process.env) {
|
|
96
100
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
const profile = readFileSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'), 'utf8');
|
|
102
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(profile)?.[1] || '';
|
|
103
|
+
const declared = /^(?:language|lang|idioma)\s*:\s*["']?([^"'\r\n]+)/mi.exec(front)?.[1]?.trim().toLowerCase();
|
|
104
|
+
if (declared) return /^(es|spa|español|espanol|castellano|spanish)\b/.test(declared) ? 'es' : 'en';
|
|
105
|
+
} catch { /* no profile: fall through to the locale */ }
|
|
106
|
+
const locale = String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
|
|
107
|
+
return /^es([_.-]|$)/.test(locale) ? 'es' : 'en';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ANNOUNCEMENT = {
|
|
111
|
+
es: `
|
|
102
112
|
===== rsc 3.0 · equipo seguro por defecto =====
|
|
103
113
|
ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
|
|
104
114
|
1. Si la rama principal está abierta (proyectos sencillos), el agente trabaja en ella sin preguntar.
|
|
@@ -108,9 +118,65 @@ ACTION: díselo a la persona en pocas líneas, una vez, antes de su petición:
|
|
|
108
118
|
worktree en .worktrees/<rama>/. Para no hacerlo: «no uses worktrees».
|
|
109
119
|
3. El agente elige solo entre FTD (lo sencillo) y SDD (lo grande o complejo) y lo dice; se puede
|
|
110
120
|
pedir el otro.
|
|
111
|
-
4. 01-TOOLS/ y 02-DOCS/ viajan al equipo por la
|
|
112
|
-
las PRs. Para apagarlo: rsc knowledge-sync off.
|
|
121
|
+
4. Las herramientas (01-TOOLS/) y la wiki (02-DOCS/wiki/, attachments/) viajan al equipo por la
|
|
122
|
+
rama rsc/knowledge y llegan a la principal dentro de las PRs. Para apagarlo: rsc knowledge-sync off.
|
|
113
123
|
==============================================
|
|
114
|
-
|
|
124
|
+
`,
|
|
125
|
+
en: `
|
|
126
|
+
===== rsc 3.0 · team-safe by default =====
|
|
127
|
+
ACTION: tell the person in a few lines, once, before their request, in their language:
|
|
128
|
+
1. If the default branch is open (simple projects), the agent works on it without asking.
|
|
129
|
+
If it is closed (long-lived code, CI or a team), it asks before each change: this branch,
|
|
130
|
+
a new one, or unlock? It never opens a branch on its own. To change it: "unlock main" or
|
|
131
|
+
"lock main" (rsc main unlock | lock).
|
|
132
|
+
2. If another assistant session is working in this same folder, new work goes to a worktree in
|
|
133
|
+
.worktrees/<branch>/. To turn that off: "don't use worktrees" (rsc isolation off).
|
|
134
|
+
3. The agent picks between FTD (simple work) and SDD (large or complex work) and says which; you
|
|
135
|
+
can ask for the other.
|
|
136
|
+
4. Tools (01-TOOLS/) and the wiki (02-DOCS/wiki/, attachments/) travel to the team on the
|
|
137
|
+
rsc/knowledge branch and reach the default branch inside pull requests. Off: rsc knowledge-sync off.
|
|
138
|
+
==========================================
|
|
139
|
+
`,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function teamSafeAnnouncement(root, env = process.env) {
|
|
143
|
+
try {
|
|
144
|
+
const mark = join(root, '.rsc', MARK);
|
|
145
|
+
if (existsSync(mark)) return '';
|
|
146
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
147
|
+
writeFileSync(mark, `${new Date().toISOString()}\n`);
|
|
148
|
+
return ANNOUNCEMENT[announcementLanguage(root, env)];
|
|
149
|
+
} catch { return ''; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The harness only reaches a teammate through what is committed: `.rsc.json` (the decision), the
|
|
154
|
+
* Claude settings that wire the guards and hooks, the bootstrap a clone runs. Field tests 3.0.7 and
|
|
155
|
+
* 3.0.8: five sessions each, and none of those files was ever committed — «team-safe by default»
|
|
156
|
+
* that never left the machine. Said at session start while `.rsc.json` is untracked in a repo with a
|
|
157
|
+
* remote; at most once a week, so it informs without nagging (P7). The agent proposes, never commits
|
|
158
|
+
* on its own.
|
|
159
|
+
*/
|
|
160
|
+
const HARNESS_FILES = ['.rsc.json', '.claude/settings.json', '.claude/rsc-bootstrap.mjs', '.gitignore'];
|
|
161
|
+
const COMMIT_MARK = '.harness-commit-offered';
|
|
162
|
+
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
163
|
+
|
|
164
|
+
export function harnessCommitNotice(root, now = Date.now()) {
|
|
165
|
+
try {
|
|
166
|
+
const run = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
167
|
+
if (!existsSync(join(root, '.rsc.json'))) return '';
|
|
168
|
+
if (!run(['remote'])) return '';
|
|
169
|
+
if (run(['ls-files', '--', '.rsc.json'])) return '';
|
|
170
|
+
const mark = join(root, '.rsc', COMMIT_MARK);
|
|
171
|
+
if (existsSync(mark) && now - Date.parse(readFileSync(mark, 'utf8').trim()) < WEEK_MS) return '';
|
|
172
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
173
|
+
writeFileSync(mark, `${new Date(now).toISOString()}\n`);
|
|
174
|
+
const files = HARNESS_FILES.filter((f) => existsSync(join(root, f)));
|
|
175
|
+
return '===== rsc · harness not committed =====\n' +
|
|
176
|
+
`The harness files (${files.join(', ')}) are not in git, so teammates and new clones get no guards, ` +
|
|
177
|
+
'hooks or decisions. ACTION: tell the person in one line, in their language, and offer to commit ' +
|
|
178
|
+
'them with their next change (on the branch this project uses). Do not commit them on your own.\n' +
|
|
179
|
+
'=======================================\n';
|
|
115
180
|
} catch { return ''; }
|
|
116
181
|
}
|
|
182
|
+
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
|
|
18
18
|
// rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
|
|
19
|
-
import { existsSync, realpathSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
19
|
+
import { existsSync, realpathSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, chmodSync, copyFileSync } from 'node:fs';
|
|
20
20
|
import { join, resolve, dirname, basename, relative, sep } from 'node:path';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
22
|
|
|
@@ -50,6 +50,14 @@ export const REGENERABLE = [
|
|
|
50
50
|
// directory name means something only as a directory.
|
|
51
51
|
export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
|
|
52
52
|
|
|
53
|
+
// rsc's OWN state inside a worktree: hook markers, knowledge-sync's index, the memory journal. It is
|
|
54
|
+
// in no commit by design, so the contents check read it as work that "would be lost" and refused
|
|
55
|
+
// every landed worktree a session had ever worked in (team simulation D8) — the default cleanup
|
|
56
|
+
// never ran. It does not block. The one part of it that is history — the session journal — is moved
|
|
57
|
+
// to the project's store before the directory goes (`adoptJournal`), never dropped.
|
|
58
|
+
export const RSC_STATE = ['.rsc/', '02-DOCS/raw/worklog/.rsc-memory/'];
|
|
59
|
+
const isRscState = (path) => RSC_STATE.some((prefix) => path === prefix || path === prefix.slice(0, -1) || path.startsWith(prefix));
|
|
60
|
+
|
|
53
61
|
// Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
|
|
54
62
|
const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
|
|
55
63
|
|
|
@@ -251,7 +259,7 @@ export function contentOutsideHistory(wtPath) {
|
|
|
251
259
|
// does not exist — the exact failure the `raw` comment above exists to prevent (P6).
|
|
252
260
|
if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
|
|
253
261
|
if (code === '??' || code === '!!') {
|
|
254
|
-
if (!isRegenerable(path)) outside.push(path);
|
|
262
|
+
if (!isRegenerable(path) && !isRscState(path)) outside.push(path);
|
|
255
263
|
} else dirty.push(path);
|
|
256
264
|
}
|
|
257
265
|
return { dirty, outside, readable: true };
|
|
@@ -355,6 +363,11 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
|
|
|
355
363
|
return { removed: false, reason: refusal(candidate) };
|
|
356
364
|
}
|
|
357
365
|
|
|
366
|
+
// The journal first: if it cannot be carried home, the directory stays (fail towards keeping).
|
|
367
|
+
try { adoptJournal(root, target); } catch (err) {
|
|
368
|
+
return { removed: false, reason: `its session journal could not be moved to the project, so nothing was removed: ${err.message}` };
|
|
369
|
+
}
|
|
370
|
+
|
|
358
371
|
const removal = git(root, ['worktree', 'remove', '--force', target]);
|
|
359
372
|
if (!removal.ok) {
|
|
360
373
|
return { removed: false, reason: `git refused to remove it: ${removal.err || removal.out}` };
|
|
@@ -379,6 +392,96 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
|
|
|
379
392
|
};
|
|
380
393
|
}
|
|
381
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Carry a worktree's memory journal into the project's store before the worktree is removed.
|
|
397
|
+
*
|
|
398
|
+
* Since sessions inside `.worktrees/` journal into the project's store directly, this is the net for
|
|
399
|
+
* what older versions (and any session that ran before that fix) left inside the worktree. Merge by
|
|
400
|
+
* file name, the newer record wins, nothing in the project is ever overwritten by something older.
|
|
401
|
+
* The project's store is the same one the memory picks: the ignored wiki worklog when it is in use,
|
|
402
|
+
* `.rsc/memory` otherwise.
|
|
403
|
+
*/
|
|
404
|
+
export function adoptJournal(root, worktreePath) {
|
|
405
|
+
const sources = [join(worktreePath, '.rsc', 'memory'), join(worktreePath, '02-DOCS', 'raw', 'worklog', '.rsc-memory')]
|
|
406
|
+
.filter((dir) => existsSync(dir));
|
|
407
|
+
if (!sources.length) return { moved: [] };
|
|
408
|
+
const worklogStore = join(root, '02-DOCS', 'raw', 'worklog', '.rsc-memory');
|
|
409
|
+
const dest = existsSync(join(worklogStore, 'sessions')) ? worklogStore : join(root, '.rsc', 'memory');
|
|
410
|
+
const stamp = (value) => new Date(value?.timestamps?.updatedAt || value?.updatedAt || value?.approvedAt || value?.startedAt || 0).getTime() || 0;
|
|
411
|
+
const read = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
412
|
+
const moved = [];
|
|
413
|
+
for (const source of sources) {
|
|
414
|
+
for (const kind of ['sessions', 'anchors', 'lessons']) {
|
|
415
|
+
const dir = join(source, kind);
|
|
416
|
+
if (!existsSync(dir)) continue;
|
|
417
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
|
|
418
|
+
const from = join(dir, name);
|
|
419
|
+
const to = join(dest, kind, name);
|
|
420
|
+
if (existsSync(to) && stamp(read(to)) >= stamp(read(from))) continue;
|
|
421
|
+
mkdirSync(join(dest, kind), { recursive: true });
|
|
422
|
+
copyFileSync(from, to);
|
|
423
|
+
chmodSync(to, 0o600);
|
|
424
|
+
moved.push(`${kind}/${name}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { moved };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Branches that are never a cleanup's side effect, whatever their state: the knowledge branch carries
|
|
432
|
+
// 01-TOOLS/ and 02-DOCS/ to the team (knowledge-sync) and is merged into the trunk as a matter of course.
|
|
433
|
+
export const KEEP_BRANCHES = ['rsc/knowledge'];
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Plain local branches whose work has landed: the half of "al fusionar se borra solo" that has no
|
|
437
|
+
* directory. E2E defect 14 (2026-10-07): a branch merged into the default branch — or whose remote
|
|
438
|
+
* branch the forge deleted after the PR, `[gone]` — stayed in `git branch` for ever, landed work
|
|
439
|
+
* looking exactly like live work.
|
|
440
|
+
*
|
|
441
|
+
* Deleted only when ALL hold, each failing towards keeping the branch:
|
|
442
|
+
* - the cleanup is on, and a trunk resolves (the remote tip first, like the worktrees);
|
|
443
|
+
* - it is not the default branch, a trunk name, `rsc/knowledge`, the current branch, or checked out
|
|
444
|
+
* in ANY worktree (`branch -d` would refuse that last one anyway; we do not even ask);
|
|
445
|
+
* - its tip is an ancestor of the trunk — no commit of its own left. Against `origin/<default>`
|
|
446
|
+
* when there is one, so a branch merged locally but not yet pushed keeps its unpushed commits
|
|
447
|
+
* (being ahead of its own upstream then loses nothing: those commits are on the remote trunk);
|
|
448
|
+
* - it really carried work: a commit in its reflog, or an upstream the forge deleted. A branch cut a
|
|
449
|
+
* moment ago is an ancestor of everything and has landed nothing (same rule as `hasLandedWork`).
|
|
450
|
+
* And then git decides: `branch -d`, never -D. A squash-merged branch is `[gone]` but has commits of
|
|
451
|
+
* its own by identity, so it is not an ancestor and stays — its commits remain recoverable.
|
|
452
|
+
*
|
|
453
|
+
* Never throws (it runs from the post-merge hook, like `autoReap`).
|
|
454
|
+
*/
|
|
455
|
+
export function reapMergedBranches(root) {
|
|
456
|
+
const out = { deleted: [], kept: [] };
|
|
457
|
+
try {
|
|
458
|
+
if (!isCleanupEnabled(root)) return out;
|
|
459
|
+
const trunk = resolveTrunk(root);
|
|
460
|
+
if (!trunk) return out;
|
|
461
|
+
const defaultName = trunk.replace(/^origin\//, '');
|
|
462
|
+
const current = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out || null;
|
|
463
|
+
const checkedOut = new Set(listWorktrees(root).map((wt) => wt.branch).filter(Boolean));
|
|
464
|
+
const refs = git(root, ['for-each-ref', '--format=%(refname:short)%00%(upstream:track)', 'refs/heads']);
|
|
465
|
+
if (!refs.ok) return out;
|
|
466
|
+
for (const line of refs.out.split('\n').filter(Boolean)) {
|
|
467
|
+
const [branch, track = ''] = line.split('\0');
|
|
468
|
+
const keep = (reason) => out.kept.push({ branch, reason });
|
|
469
|
+
if (branch === defaultName || isTrunkName(branch) || KEEP_BRANCHES.includes(branch)) continue;
|
|
470
|
+
if (branch === current) { keep('current branch'); continue; }
|
|
471
|
+
if (checkedOut.has(branch)) { keep('checked out in a worktree'); continue; }
|
|
472
|
+
if (!git(root, ['merge-base', '--is-ancestor', `refs/heads/${branch}`, trunk]).ok) continue; // live work
|
|
473
|
+
const gone = track.includes('gone');
|
|
474
|
+
if (!gone && !hasLandedWork(root, { branch })) { keep('never carried a commit'); continue; }
|
|
475
|
+
const del = git(root, ['branch', '-d', branch]);
|
|
476
|
+
if (del.ok) out.deleted.push(branch);
|
|
477
|
+
else keep(`git branch -d refused: ${del.err || del.out}`);
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
out.kept.push({ branch: null, reason: `branch cleanup could not run: ${err.message}` });
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
|
|
382
485
|
/**
|
|
383
486
|
* Every refusal carries the way out, because the person receiving it may not be able to deduce one (P6).
|
|
384
487
|
*
|
|
@@ -401,7 +504,7 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
|
|
|
401
504
|
* - it is silent when there is nothing to do, so the common merge prints nothing.
|
|
402
505
|
*/
|
|
403
506
|
export function autoReap(root) {
|
|
404
|
-
const result = { reaped: [], skipped: [], disabled: false };
|
|
507
|
+
const result = { reaped: [], skipped: [], disabled: false, branches: { deleted: [], kept: [] } };
|
|
405
508
|
try {
|
|
406
509
|
if (!isCleanupEnabled(root)) {
|
|
407
510
|
result.disabled = true;
|
|
@@ -421,6 +524,8 @@ export function autoReap(root) {
|
|
|
421
524
|
if (out.removed) result.reaped.push(candidate.path);
|
|
422
525
|
else result.skipped.push({ path: candidate.path, reason: out.reason });
|
|
423
526
|
}
|
|
527
|
+
// After the worktrees: removing one frees its branch, which may then be a landed plain branch.
|
|
528
|
+
result.branches = reapMergedBranches(root);
|
|
424
529
|
} catch (err) {
|
|
425
530
|
// Swallowed deliberately, and recorded rather than discarded: the merge must survive whatever
|
|
426
531
|
// went wrong in here, but a silent failure that leaves no trace is how this rots unnoticed.
|
|
@@ -605,6 +710,7 @@ if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.
|
|
|
605
710
|
// The unattended entry point, called by the post-merge hook. Prints only what it actually did.
|
|
606
711
|
const out = autoReap(root);
|
|
607
712
|
for (const p of out.reaped) process.stdout.write(`rsc: retired worktree ${p}\n`);
|
|
713
|
+
for (const b of out.branches.deleted) process.stdout.write(`rsc: deleted merged branch ${b}\n`);
|
|
608
714
|
} else if (process.argv[3] === 'reap') {
|
|
609
715
|
const one = process.argv[4];
|
|
610
716
|
const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|