geneseed 4.7.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/SHIPPED.md +1 -1
- package/adapters/claude-code/README.md +3 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
- package/adapters/opencode/README.md +5 -4
- package/adapters/opencode/plugins/geneseed-guard.js +565 -36
- package/bin/geneseed-hook.mjs +9 -3
- package/docs/concepts/enforcement-table.md +1 -1
- package/docs/concepts/exclusions.md +20 -1
- package/docs/concepts/hooks.md +11 -3
- package/docs/concepts/host-matrix.md +3 -3
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-doctor.md +4 -2
- package/docs/understand/a-day.md +6 -6
- package/docs/understand/design-choices-gates.md +2 -2
- package/docs/understand/enforced-vs-asked.md +4 -4
- package/docs/understand/harness.md +1 -1
- package/docs/understand/machine-claude.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/docs/web-ui.md +3 -1
- package/harness.config.json +1 -1
- package/js/README.md +2 -1
- package/js/build/args.mjs +4 -1
- package/js/build/driver.mjs +11 -7
- package/js/build/emit-claude.mjs +4 -3
- package/js/build/emit-opencode.mjs +2 -1
- package/js/build/generate.mjs +13 -4
- package/js/build/render.mjs +4 -0
- package/js/build/source.mjs +4 -1
- package/js/cli-table.json +45 -0
- package/js/hosts/gitref.mjs +47 -1
- package/js/hosts/hooks.mjs +19 -42
- package/js/hosts/installs.mjs +20 -0
- package/js/hosts/loss.mjs +522 -0
- package/js/hosts/settings.mjs +78 -77
- package/js/inspect/checks-probe.mjs +18 -6
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/status.mjs +3 -1
- package/js/inspect/validate.mjs +6 -4
- package/js/maintain/uninstall.mjs +5 -2
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +20 -3
- package/js/web/api.mjs +4 -2
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
- package/src/laws/universal.md +17 -17
- package/src/skills/worktree.md +1 -1
- package/web/dist/assets/{Activity-Dj0izJwj.js → Activity-B-XbKlzu.js} +1 -1
- package/web/dist/assets/{ActivityDetail-wjq5W02K.js → ActivityDetail-8oq0fycD.js} +1 -1
- package/web/dist/assets/{Dashboard-UMbgHbzF.js → Dashboard-C4EMz5mf.js} +2 -2
- package/web/dist/assets/{Docs-D3ABE_ds.js → Docs-mq4XDQK_.js} +1 -1
- package/web/dist/assets/FilterInput-Ikvi-pS9.js +1 -0
- package/web/dist/assets/{GreenhouseView-C238LWBD.js → GreenhouseView-D1WOtq3n.js} +1 -1
- package/web/dist/assets/{Installs-XSm0a_zQ.js → Installs-DrqPaLDM.js} +1 -1
- package/web/dist/assets/{JournalView-BUj4Ff-6.js → JournalView-BYpASU-L.js} +1 -1
- package/web/dist/assets/Laws-A3PFwiPD.js +3 -0
- package/web/dist/assets/{Library-CkaPVUYU.js → Library-BCaqPf61.js} +1 -1
- package/web/dist/assets/{Loops-CexwOARr.js → Loops-Bx3HnlUA.js} +1 -1
- package/web/dist/assets/{Markdown-DrXLM6TA.js → Markdown-BK38dn4x.js} +1 -1
- package/web/dist/assets/{OperatorHudView-D5qTQat0.js → OperatorHudView-ChQPM8JE.js} +1 -1
- package/web/dist/assets/{Personal-B_Vx9a7a.js → Personal-DELm61DF.js} +1 -1
- package/web/dist/assets/{Tabs-YZbGqWPW.js → Tabs-DcMq5IvC.js} +1 -1
- package/web/dist/assets/{activity-dcg5vF9j.js → activity-D4w9t8fE.js} +1 -1
- package/web/dist/assets/{drawable-BoQnwVpr.js → drawable-BI2_tF3o.js} +1 -1
- package/web/dist/assets/{index-BoG1M8Xy.css → index-CHpydQJg.css} +1 -1
- package/web/dist/assets/{index-BmbWeoTM.js → index-r2jRgf6l.js} +2 -2
- package/web/dist/assets/{react-DRhoX0kS.js → react-Ci4wQD4a.js} +2 -2
- package/web/dist/assets/{usePoll-BmR631BB.js → usePoll-BcywUo55.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/src/pages/Laws.jsx +53 -5
- package/web/dist/assets/FilterInput-BSfpStOZ.js +0 -1
- package/web/dist/assets/Laws-yGBJFKgY.js +0 -3
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* js/hosts/loss.mjs — Deletion Is Deliberate, calibrated to REAL loss.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The git gate used to ask on every destructive-LOOKING form: `reset --hard`
|
|
5
|
+
* on a clean tree, `branch -D` of a branch already on its remote, a force push to a feature
|
|
6
|
+
* branch. A gate that asks when nothing can be lost teaches the user to click through it, and
|
|
7
|
+
* then it protects nothing (user decision, 2026-10-17). So the boundary now asks only when the
|
|
8
|
+
* command would destroy something nothing else holds:
|
|
9
|
+
*
|
|
10
|
+
* - a protected branch (`main`, `master`, a remote's default) deleted or force-pushed, local
|
|
11
|
+
* or remote;
|
|
12
|
+
* - `branch -D x` while x carries a commit no remote ref and no HEAD reaches;
|
|
13
|
+
* - uncommitted work discarded (`reset --hard`, `checkout -f`/`-- <paths>`, `switch -f`/
|
|
14
|
+
* `--discard-changes`, `restore` beyond `--staged` alone, `clean -f`) — and only when the
|
|
15
|
+
* tree actually holds such work (for `clean`, what `git clean -n` says it would remove);
|
|
16
|
+
* - only where a protected branch is concerned (user ruling, 2026-10-17): `checkout <paths>`
|
|
17
|
+
* without `--` and `git rm -f` over changed files while a protected branch is checked out, and
|
|
18
|
+
* `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
|
|
19
|
+
* holds — the same forms on a feature branch pass;
|
|
20
|
+
* - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
|
|
21
|
+
* - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of anything git
|
|
22
|
+
* cannot bring back: untracked, ignored or modified files, a repository's own `.git`, or
|
|
23
|
+
* any path outside a git repository.
|
|
24
|
+
*
|
|
25
|
+
* FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
|
|
26
|
+
* name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`) carrying a removal,
|
|
27
|
+
* a git that fails, times out or is missing — asks, and the reason says it could not verify.
|
|
28
|
+
* "Not proven safe" is an ask; only "proven safe" passes.
|
|
29
|
+
*
|
|
30
|
+
* THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
|
|
31
|
+
* files are untracked, and whether a branch's commits are reachable elsewhere are not readable
|
|
32
|
+
* off disk at any sane cost (the index is a binary format with stat caches; reachability walks
|
|
33
|
+
* compressed objects and packs). So `git` itself answers, through `git()` below — `shell:false`,
|
|
34
|
+
* a timeout, no optional locks (`git status` must never take `index.lock` under the user), no
|
|
35
|
+
* fsmonitor, no terminal prompt. It is reached ONLY after `LOSS_CANDIDATE_RE` matched a
|
|
36
|
+
* destructive-looking form and the parse below found one in program position: an ordinary
|
|
37
|
+
* command never gets here (`js/hosts/hooks.mjs` tests the regex first), so the ~14 ms hook
|
|
38
|
+
* budget is untouched for everything else. Refs, the remote default branch and the stash are
|
|
39
|
+
* still read off disk (`./gitref.mjs`), which costs nothing. One ordinary form does pay: a plain
|
|
40
|
+
* `git checkout <x>` while a protected branch is checked out runs `git rev-parse` to tell a
|
|
41
|
+
* branch switch from a path discard (the branch is read off disk first, so a feature branch
|
|
42
|
+
* never pays it).
|
|
43
|
+
*
|
|
44
|
+
* TWIN: `adapters/opencode/plugins/geneseed-guard.js` carries the same decisions (the plugin is
|
|
45
|
+
* copied whole into an OpenCode install and cannot import this). Keep the two in step;
|
|
46
|
+
* `tests/plugins/guard.test.mjs` drives both through one table of real repositories.
|
|
47
|
+
*/
|
|
48
|
+
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import { lstatSync } from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitref.mjs';
|
|
52
|
+
|
|
53
|
+
// ---- loss twin: BEGIN — adapters/opencode/plugins/geneseed-guard.js carries everything down to
|
|
54
|
+
// the END marker verbatim, with `export ` dropped (tests/plugins/guard.test.mjs diffs the two).
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The cheap prefilter: a command that matches neither alternative never reaches the parser or
|
|
58
|
+
* a spawn. A git destructive VERB anywhere after `git` on the same line, or a removal program
|
|
59
|
+
* as a word at a command boundary. Deliberately loose — the parse decides; this only has to be
|
|
60
|
+
* a superset of what the parse acts on.
|
|
61
|
+
*/
|
|
62
|
+
export const LOSS_CANDIDATE_RE =
|
|
63
|
+
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
|
|
64
|
+
|
|
65
|
+
class Unverified extends Error {}
|
|
66
|
+
const unverified = (why) => { throw new Unverified(why); };
|
|
67
|
+
|
|
68
|
+
const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
69
|
+
// cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
|
|
70
|
+
const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
|
|
71
|
+
const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
|
|
72
|
+
const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
|
|
73
|
+
// Programs that run ANOTHER command from their arguments or stdin: what they would remove is
|
|
74
|
+
// not on this command line in a form the parse can read, so a removal word inside one asks.
|
|
75
|
+
const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
|
|
76
|
+
'eval', 'xargs', 'find', 'parallel', 'watch', 'invoke-expression', 'iex']);
|
|
77
|
+
|
|
78
|
+
const OPAQUE = '\u0000';
|
|
79
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `git -C <dir> …` — THE spawn. Returns `{ status, stdout, stderr }`; throws `Unverified` when
|
|
83
|
+
* git could not run to completion (missing, timed out, output too large), so the caller asks.
|
|
84
|
+
*/
|
|
85
|
+
function git(dir, args) {
|
|
86
|
+
/** @type {Record<string, string | undefined>} */
|
|
87
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
|
|
88
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE;
|
|
89
|
+
const r = spawnSync('git', ['-C', dir, '-c', 'core.fsmonitor=false', ...args], {
|
|
90
|
+
encoding: 'utf8', shell: false, windowsHide: true, timeout: GIT_TIMEOUT_MS,
|
|
91
|
+
maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], env,
|
|
92
|
+
});
|
|
93
|
+
if (r.error || r.status === null) {
|
|
94
|
+
unverified(`git ${args[0]} did not finish (${r.error ? /** @type {any} */ (r.error).code || r.error.message : 'killed'})`);
|
|
95
|
+
}
|
|
96
|
+
return r;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const firstLine = (s) => String(s || '').trim().split(/\r?\n/)[0] || 'no output';
|
|
100
|
+
|
|
101
|
+
/** A raw token with its quotes removed, or `OPAQUE`-marked when the shell would rewrite it. */
|
|
102
|
+
function word(raw) {
|
|
103
|
+
const opaque = /[`$]|^~|[{}]/.test(raw);
|
|
104
|
+
const s = raw.replace(/"([^"]*)"?|'([^']*)'?/g, (_, a, b) => a ?? b ?? '');
|
|
105
|
+
return opaque ? `${OPAQUE}${s}` : s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The segment's words, redirections dropped (`2>/dev/null`, `> out.log`) — they are not operands. */
|
|
109
|
+
function words(rawTokens) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
112
|
+
const t = rawTokens[i];
|
|
113
|
+
if (/^(?:\d*|&)(?:>>?|<)&?$/.test(t)) { i += 1; continue; } // a bare operator: drop its target too
|
|
114
|
+
if (/^(?:\d*|&)(?:>>?|<)/.test(t)) continue;
|
|
115
|
+
out.push(word(t));
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `p` resolved against `dir`. On Windows a Git Bash `/c/…` path maps to `C:/…`; any other POSIX root cannot. */
|
|
121
|
+
function resolveIn(dir, p) {
|
|
122
|
+
if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
|
|
123
|
+
let q = p;
|
|
124
|
+
if (process.platform === 'win32') {
|
|
125
|
+
const m = /^\/([a-zA-Z])(\/.*)?$/.exec(q);
|
|
126
|
+
if (m) q = `${m[1]}:${m[2] || '/'}`;
|
|
127
|
+
else if (/^\/(?!\/)/.test(q)) unverified(`${p} is a POSIX path this host cannot map`);
|
|
128
|
+
}
|
|
129
|
+
if (!dir && !path.isAbsolute(q)) unverified('the working directory is unknown after a `cd` the gate cannot follow');
|
|
130
|
+
return path.resolve(dir || '', q);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function repoRoot(dir) {
|
|
134
|
+
if (!dir) unverified('the working directory is unknown after a `cd` the gate cannot follow');
|
|
135
|
+
return gitRootOf(dir) || unverified(`${dir} is not inside a git repository`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Ask if `git status` shows a tracked change (staged or not) under `paths` (all when empty). */
|
|
139
|
+
function dirty(dir, paths, act) {
|
|
140
|
+
for (const p of paths) if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
|
|
141
|
+
repoRoot(dir);
|
|
142
|
+
const r = git(dir, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...paths]);
|
|
143
|
+
if (r.status !== 0) unverified(`git status failed: ${firstLine(r.stderr)}`);
|
|
144
|
+
const first = r.stdout.split('\0').find(Boolean);
|
|
145
|
+
return first ? `${act} would discard uncommitted changes (${first.slice(3)})` : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isCluster = (a) => /^-[a-zA-Z]+$/.test(a);
|
|
149
|
+
const hasShort = (a, c) => isCluster(a) && a.includes(c);
|
|
150
|
+
|
|
151
|
+
/** `git push` — asks only when a protected branch would be force-pushed or deleted. */
|
|
152
|
+
function pushAct(rest, dir) {
|
|
153
|
+
let force = false; let del = false; let all = false; let mirror = false; let prune = false;
|
|
154
|
+
const pos = [];
|
|
155
|
+
for (let i = 0; i < rest.length; i++) {
|
|
156
|
+
const a = rest[i];
|
|
157
|
+
if (a === '--') { pos.push(...rest.slice(i + 1)); break; }
|
|
158
|
+
if (['-o', '--push-option', '--repo', '--receive-pack', '--exec'].includes(a)) { i += 1; continue; }
|
|
159
|
+
if (a.startsWith('--')) {
|
|
160
|
+
if (/^--force(?:-with-lease)?(?:=|$)/.test(a)) force = true;
|
|
161
|
+
else if (a === '--delete') del = true;
|
|
162
|
+
else if (a === '--all' || a === '--branches') all = true;
|
|
163
|
+
else if (a === '--mirror') mirror = true;
|
|
164
|
+
else if (a === '--prune') prune = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (isCluster(a)) { if (a.includes('f')) force = true; if (a.includes('d')) del = true; continue; }
|
|
168
|
+
pos.push(a);
|
|
169
|
+
}
|
|
170
|
+
const specs = pos.slice(1);
|
|
171
|
+
const forcedSpec = (s) => force || del || s.startsWith('+') || s.startsWith(':');
|
|
172
|
+
if (!force && !del && !mirror && !prune && !specs.some(forcedSpec)) return null;
|
|
173
|
+
if (mirror) return '`git push --mirror` force-pushes and deletes every remote branch to match this repository';
|
|
174
|
+
if (prune) return '`git push --prune` deletes every remote branch with no local counterpart';
|
|
175
|
+
if (all && force) return 'a forced `git push --all` force-pushes every branch, protected ones included';
|
|
176
|
+
const root = repoRoot(dir);
|
|
177
|
+
const protectedSet = protectedBranches(root);
|
|
178
|
+
const verb = del ? 'delete' : 'force-push';
|
|
179
|
+
let targets;
|
|
180
|
+
if (specs.length) {
|
|
181
|
+
targets = [];
|
|
182
|
+
for (const spec of specs) {
|
|
183
|
+
if (spec.includes(OPAQUE)) unverified(`the refspec \`${spec.replace(OPAQUE, '')}\` expands in the shell`);
|
|
184
|
+
if (!forcedSpec(spec)) continue;
|
|
185
|
+
const s = spec.replace(/^\+/, '');
|
|
186
|
+
const colon = s.indexOf(':');
|
|
187
|
+
if (!del && colon === 0) {
|
|
188
|
+
const dst = s.slice(1).replace(/^refs\/heads\//, '');
|
|
189
|
+
if (protectedSet.has(dst)) return `\`git push ${spec}\` would delete the protected branch ${dst}`;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
targets.push(del || colon < 0 ? s : s.slice(colon + 1));
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
targets = defaultPushTargets(dir);
|
|
196
|
+
}
|
|
197
|
+
for (let t of targets) {
|
|
198
|
+
if (t === 'HEAD' || t === '@') t = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
|
|
199
|
+
t = t.replace(/^refs\/heads\//, '');
|
|
200
|
+
if (t.startsWith('refs/')) continue; // a tag or another namespace, not a branch
|
|
201
|
+
if (/[*?]/.test(t)) unverified(`the refspec target \`${t}\` is a pattern`);
|
|
202
|
+
if (protectedSet.has(t)) return `\`git push\` would ${verb} the protected branch ${t}`;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* What a refspec-less forced push targets: the current branch (`simple`/`current`) and its
|
|
209
|
+
* upstream (`upstream`). `matching` pushes every branch and a `remote.*.push` rewrites the
|
|
210
|
+
* target — neither is something this read can bound, so both ask.
|
|
211
|
+
*/
|
|
212
|
+
function defaultPushTargets(dir) {
|
|
213
|
+
const cur = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
|
|
214
|
+
const esc = cur.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
215
|
+
const r = git(dir, ['config', '--get-regexp', `^(push\\.default|remote\\..*\\.push|branch\\.${esc}\\.merge)$`]);
|
|
216
|
+
if (r.status !== 0 && r.status !== 1) unverified(`git config failed: ${firstLine(r.stderr)}`);
|
|
217
|
+
const targets = [cur];
|
|
218
|
+
for (const line of r.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
219
|
+
const [key, value = ''] = line.split(/\s+/, 2);
|
|
220
|
+
if (/^remote\..*\.push$/i.test(key)) unverified(`${key} rewrites what a bare push targets`);
|
|
221
|
+
if (/^push\.default$/i.test(key) && value === 'matching') unverified('push.default=matching pushes every branch');
|
|
222
|
+
if (/\.merge$/i.test(key)) targets.push(value);
|
|
223
|
+
}
|
|
224
|
+
return targets;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The checked-out branch when it is protected, null when HEAD is detached or unreadable, and
|
|
229
|
+
* undefined when it is a known unprotected branch — read off disk, so a feature branch passes the
|
|
230
|
+
* protected-branch-only forms below without running git (user ruling, 2026-10-17).
|
|
231
|
+
*/
|
|
232
|
+
function protectedHead(dir) {
|
|
233
|
+
const cur = currentBranch(dir);
|
|
234
|
+
return cur && !protectedBranches(repoRoot(dir)).has(cur) ? undefined : cur;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `lost` on the protected branch `cur`; an unknown HEAD cannot be cleared as unprotected, so it asks. */
|
|
238
|
+
function onProtected(cur, lost) {
|
|
239
|
+
if (!lost) return null;
|
|
240
|
+
if (!cur) unverified('HEAD is detached or unreadable, so the gate cannot tell whether a protected branch is checked out');
|
|
241
|
+
return `${lost} on the protected branch ${cur}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `branch -f`, `checkout -B`, `switch -C` moving `name` to `start` (HEAD when absent): stops when
|
|
246
|
+
* `name` is protected and its old tip holds a commit neither `start` nor any remote ref reaches.
|
|
247
|
+
* A branch that does not exist yet is created, which loses nothing.
|
|
248
|
+
*/
|
|
249
|
+
function forceMove(dir, name, start, act) {
|
|
250
|
+
if (name === undefined) return null; // git refuses the command on its own
|
|
251
|
+
if (name.includes(OPAQUE)) unverified(`the branch name \`${name.replace(OPAQUE, '')}\` expands in the shell`);
|
|
252
|
+
if (!protectedBranches(repoRoot(dir)).has(name)) return null;
|
|
253
|
+
const tip = git(dir, ['rev-parse', '-q', '--verify', `refs/heads/${name}`]);
|
|
254
|
+
if (tip.status === 1) return null;
|
|
255
|
+
if (tip.status !== 0) unverified(`cannot read the branch ${name}: ${firstLine(tip.stderr)}`);
|
|
256
|
+
const to = start ?? 'HEAD';
|
|
257
|
+
if (to.includes(OPAQUE)) unverified(`the start point \`${to.replace(OPAQUE, '')}\` expands in the shell`);
|
|
258
|
+
const r = git(dir, ['rev-list', '-n1', `refs/heads/${name}`, '--not', to, '--remotes']);
|
|
259
|
+
if (r.status !== 0) unverified(`cannot compare ${name} with ${to}: ${firstLine(r.stderr)}`);
|
|
260
|
+
return r.stdout.trim() ? `${act} ${name} would strand commits no remote and not ${to} holds` : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `git checkout` with no `--` and no force: `-B <protected>` force-moves it; otherwise, on a
|
|
265
|
+
* protected branch, positionals naming files (the first one is not a commit, or follows one)
|
|
266
|
+
* discard their changes. A lone commit-ish is a branch switch, which refuses to overwrite work.
|
|
267
|
+
*/
|
|
268
|
+
function checkoutAct(rest, dir) {
|
|
269
|
+
const pos = [];
|
|
270
|
+
let reset;
|
|
271
|
+
for (let i = 0; i < rest.length; i++) {
|
|
272
|
+
const a = rest[i];
|
|
273
|
+
if (a === '-b' || a === '--orphan') return null; // a new branch: additive
|
|
274
|
+
if (a === '-B') { reset = rest[i + 1]; i += 1; continue; }
|
|
275
|
+
if (a.startsWith('--pathspec-from-file')) unverified('checkout reads its paths from a file');
|
|
276
|
+
if (!a.startsWith('-')) pos.push(a);
|
|
277
|
+
}
|
|
278
|
+
if (reset !== undefined) return forceMove(dir, reset, pos[0], '`git checkout -B`');
|
|
279
|
+
if (!pos.length) return null;
|
|
280
|
+
const cur = protectedHead(dir);
|
|
281
|
+
if (cur === undefined) return null;
|
|
282
|
+
let paths = pos;
|
|
283
|
+
if (!pos[0].includes(OPAQUE) && git(dir, ['rev-parse', '-q', '--verify', `${pos[0]}^{commit}`]).status === 0) {
|
|
284
|
+
if (pos.length === 1) return null;
|
|
285
|
+
paths = pos.slice(1);
|
|
286
|
+
}
|
|
287
|
+
return onProtected(cur, dirty(dir, paths, '`git checkout <paths>`'));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `git branch` — a forced delete asks for a protected branch or one whose commits live nowhere else. */
|
|
291
|
+
function branchAct(rest, dir) {
|
|
292
|
+
const opts = rest.filter((a) => a.startsWith('-'));
|
|
293
|
+
const del = opts.some((a) => a === '--delete' || a === '-D' || hasShort(a, 'd') || hasShort(a, 'D'));
|
|
294
|
+
const force = opts.some((a) => a === '--force' || a === '-D' || hasShort(a, 'f') || hasShort(a, 'D'));
|
|
295
|
+
if (!force) return null; // an un-forced delete refuses an unmerged branch on its own
|
|
296
|
+
const names = rest.filter((a) => !a.startsWith('-'));
|
|
297
|
+
if (!del) {
|
|
298
|
+
// `branch -f <name> [<start>]`; a forced rename or copy (-M, -C) is another form.
|
|
299
|
+
if (opts.some((a) => a === '--move' || a === '--copy' || [...'mMcC'].some((c) => hasShort(a, c)))) return null;
|
|
300
|
+
return forceMove(dir, names[0], names[1], '`git branch -f`');
|
|
301
|
+
}
|
|
302
|
+
if (opts.some((a) => a === '-r' || a === '--remotes')) return null; // a remote-tracking ref; `fetch` restores it
|
|
303
|
+
const root = repoRoot(dir);
|
|
304
|
+
const protectedSet = protectedBranches(root);
|
|
305
|
+
for (const n of names) {
|
|
306
|
+
if (n.includes(OPAQUE)) unverified(`the branch name \`${n.replace(OPAQUE, '')}\` expands in the shell`);
|
|
307
|
+
if (protectedSet.has(n)) return `\`git branch -D ${n}\` deletes the protected branch ${n}`;
|
|
308
|
+
// Empty output: every commit of `n` is reachable from a remote ref or from HEAD.
|
|
309
|
+
const r = git(dir, ['rev-list', '-n1', `refs/heads/${n}`, '--not', '--remotes', 'HEAD']);
|
|
310
|
+
if (r.status !== 0) unverified(`cannot read the branch ${n}: ${firstLine(r.stderr)}`);
|
|
311
|
+
if (r.stdout.trim()) return `\`git branch -D ${n}\` deletes commits no remote and no HEAD holds`;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
|
|
317
|
+
function cleanAct(rest, dir) {
|
|
318
|
+
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
|
|
319
|
+
if (!forced) return null;
|
|
320
|
+
const dry = [];
|
|
321
|
+
for (const a of rest) {
|
|
322
|
+
if (a.includes(OPAQUE)) unverified(`\`${a.replace(OPAQUE, '')}\` expands in the shell`);
|
|
323
|
+
if (a === '--force' || a === '--interactive') continue;
|
|
324
|
+
if (isCluster(a)) {
|
|
325
|
+
const kept = a.replace(/[fin]/g, '');
|
|
326
|
+
if (kept !== '-') dry.push(kept);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
dry.push(a);
|
|
330
|
+
}
|
|
331
|
+
repoRoot(dir);
|
|
332
|
+
const r = git(dir, ['clean', '-n', ...dry]);
|
|
333
|
+
if (r.status !== 0) unverified(`git clean -n failed: ${firstLine(r.stderr)}`);
|
|
334
|
+
const first = firstLine(r.stdout);
|
|
335
|
+
return r.stdout.trim() ? `\`git clean\` would delete untracked files (${first.replace(/^Would remove /, '')})` : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One `git …` segment: the verdict for its verb, or null. */
|
|
339
|
+
function gitAct(args, dir, always) {
|
|
340
|
+
let d = dir;
|
|
341
|
+
let i = 0;
|
|
342
|
+
let redirected = false;
|
|
343
|
+
for (; i < args.length; i++) {
|
|
344
|
+
const a = args[i];
|
|
345
|
+
// A `-C` the gate cannot resolve leaves the directory unknown; only a destructive verb asks for it.
|
|
346
|
+
if (a === '-C') { try { d = resolveIn(d, args[i + 1] ?? OPAQUE); } catch { d = null; } i += 1; continue; }
|
|
347
|
+
if (a === '-c') { i += 1; continue; }
|
|
348
|
+
if (/^--(?:git-dir|work-tree)(?:=|$)/.test(a)) { redirected = true; if (!a.includes('=')) i += 1; continue; }
|
|
349
|
+
if (a.startsWith('-')) continue;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
const verb = args[i];
|
|
353
|
+
const rest = args.slice(i + 1);
|
|
354
|
+
const act = () => {
|
|
355
|
+
switch (verb) {
|
|
356
|
+
case 'reset':
|
|
357
|
+
return rest.includes('--hard') ? dirty(d, [], '`git reset --hard`') : null;
|
|
358
|
+
case 'checkout': {
|
|
359
|
+
const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
|
|
360
|
+
if (forced) return forced;
|
|
361
|
+
const dd = rest.indexOf('--');
|
|
362
|
+
if (dd < 0) return checkoutAct(rest, d);
|
|
363
|
+
const paths = rest.slice(dd + 1);
|
|
364
|
+
return paths.length ? dirty(d, paths, '`git checkout --`') : null;
|
|
365
|
+
}
|
|
366
|
+
case 'switch': {
|
|
367
|
+
const c = rest.findIndex((a) => a === '-C' || a === '--force-create');
|
|
368
|
+
const pos = c < 0 ? [] : rest.slice(c + 1).filter((a) => !a.startsWith('-'));
|
|
369
|
+
return (c >= 0 && forceMove(d, pos[0], pos[1], '`git switch -C`'))
|
|
370
|
+
|| (rest.some((a) => a === '--force' || a === '--discard-changes' || hasShort(a, 'f'))
|
|
371
|
+
? dirty(d, [], '`git switch --discard-changes`') : null);
|
|
372
|
+
}
|
|
373
|
+
case 'rm': {
|
|
374
|
+
// Only a forced removal discards changes: git refuses a modified file without -f, and
|
|
375
|
+
// `--cached` or `-n` keep the file. Protected branch only (user ruling, 2026-10-17).
|
|
376
|
+
if (!rest.some((a) => a === '--force' || hasShort(a, 'f'))) return null;
|
|
377
|
+
if (rest.some((a) => a === '--cached' || a === '--dry-run' || hasShort(a, 'n'))) return null;
|
|
378
|
+
if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('git rm reads its paths from a file');
|
|
379
|
+
const paths = rest.filter((a) => !a.startsWith('-'));
|
|
380
|
+
const cur = paths.length ? protectedHead(d) : undefined;
|
|
381
|
+
return cur === undefined ? null : onProtected(cur, dirty(d, paths, '`git rm -f`'));
|
|
382
|
+
}
|
|
383
|
+
case 'restore': {
|
|
384
|
+
const staged = rest.some((a) => a === '--staged' || hasShort(a, 'S'));
|
|
385
|
+
const worktree = rest.some((a) => a === '--worktree' || hasShort(a, 'W'));
|
|
386
|
+
if (staged && !worktree) return null; // unstaging only: the change stays in the tree
|
|
387
|
+
if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('restore reads its paths from a file');
|
|
388
|
+
const paths = [];
|
|
389
|
+
for (let j = 0; j < rest.length; j++) {
|
|
390
|
+
const a = rest[j];
|
|
391
|
+
if (a === '-s' || a === '--source') { j += 1; continue; }
|
|
392
|
+
if (a === '--') continue;
|
|
393
|
+
if (!a.startsWith('-')) paths.push(a);
|
|
394
|
+
}
|
|
395
|
+
return paths.length ? dirty(d, paths, '`git restore`') : null;
|
|
396
|
+
}
|
|
397
|
+
case 'clean':
|
|
398
|
+
return cleanAct(rest, d);
|
|
399
|
+
case 'stash': {
|
|
400
|
+
const sub = rest.find((a) => !a.startsWith('-'));
|
|
401
|
+
if (sub !== 'drop' && sub !== 'clear') return null;
|
|
402
|
+
return stashExists(repoRoot(d)) ? `\`git stash ${sub}\` deletes stashed work` : null;
|
|
403
|
+
}
|
|
404
|
+
case 'reflog':
|
|
405
|
+
return always && rest.includes('expire') ? '`git reflog expire` deletes the reflog recovery depends on' : null;
|
|
406
|
+
case 'gc':
|
|
407
|
+
return always && rest.some((a) => a.startsWith('--prune'))
|
|
408
|
+
? '`git gc --prune` deletes the unreachable commits recovery depends on' : null;
|
|
409
|
+
case 'branch':
|
|
410
|
+
return branchAct(rest, d);
|
|
411
|
+
case 'push':
|
|
412
|
+
return pushAct(rest, d);
|
|
413
|
+
default:
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
const verdict = act();
|
|
418
|
+
if (verdict && redirected) return `${verdict} (and --git-dir/--work-tree points it at a repository the gate did not read)`;
|
|
419
|
+
return verdict;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** One removal segment: asks unless every operand is tracked AND unmodified in a git work tree. */
|
|
423
|
+
function rmAct(prog, args, dir) {
|
|
424
|
+
const targets = [];
|
|
425
|
+
let opts = true;
|
|
426
|
+
for (let i = 0; i < args.length; i++) {
|
|
427
|
+
const a = args[i];
|
|
428
|
+
if (opts && a === '--') { opts = false; continue; }
|
|
429
|
+
if (opts && /^-(?:path|literalpath)$/i.test(a)) { targets.push(args[i + 1] ?? OPAQUE); i += 1; continue; }
|
|
430
|
+
const bound = opts && /^-(?:path|literalpath)[:=](.+)$/i.exec(a);
|
|
431
|
+
if (bound) { targets.push(bound[1]); continue; }
|
|
432
|
+
if (opts && /^-(?:filter|include|exclude)(?:[:=]|$)/i.test(a)) unverified(`\`${prog} ${a}\` selects files the gate cannot list`);
|
|
433
|
+
if (opts && a.length > 1 && a.startsWith('-')) continue;
|
|
434
|
+
if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
|
|
435
|
+
targets.push(a);
|
|
436
|
+
}
|
|
437
|
+
const groups = new Map();
|
|
438
|
+
for (const t of targets) {
|
|
439
|
+
const shown = t.replace(OPAQUE, '');
|
|
440
|
+
if (t.includes(OPAQUE) || /[*?[\],]/.test(t)) unverified(`\`${shown}\` expands in the shell, so the gate cannot list what it names`);
|
|
441
|
+
const abs = resolveIn(dir, t);
|
|
442
|
+
if (abs.split(/[\\/]+/).includes('.git')) return `\`${prog} ${shown}\` deletes git's own data`;
|
|
443
|
+
// Nothing there, nothing lost: `rm -f build.log` on a clean tree is routine cleanup.
|
|
444
|
+
try { lstatSync(abs); } catch (e) {
|
|
445
|
+
if (e && e.code === 'ENOENT') continue;
|
|
446
|
+
unverified(`cannot read ${shown}: ${e && e.code}`);
|
|
447
|
+
}
|
|
448
|
+
const root = gitRootOf(abs);
|
|
449
|
+
if (!root) return `\`${prog} ${shown}\` deletes a path outside any git repository, which nothing could restore`;
|
|
450
|
+
if (path.resolve(root) === path.resolve(abs)) return `\`${prog} ${shown}\` deletes a whole repository or worktree`;
|
|
451
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
452
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
453
|
+
groups.get(root).push({ shown, rel });
|
|
454
|
+
}
|
|
455
|
+
const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
|
|
456
|
+
for (const [root, list] of groups) {
|
|
457
|
+
const r = git(root, ['ls-files', '-z', '-t', '--cached', '--others', '--modified', '--directory',
|
|
458
|
+
'--', ...list.map((x) => `:(literal)${x.rel}`)]);
|
|
459
|
+
if (r.status !== 0) unverified(`git ls-files failed: ${firstLine(r.stderr)}`);
|
|
460
|
+
const entries = r.stdout.split('\0').filter(Boolean).map((e) => ({ tag: e[0], p: fold(e.slice(2)) }));
|
|
461
|
+
for (const { shown, rel } of list) {
|
|
462
|
+
const key = fold(rel);
|
|
463
|
+
const mine = entries.filter((e) => e.p === key || e.p.startsWith(`${key}/`) || e.p === `${key}/`);
|
|
464
|
+
if (!mine.length) return `\`${prog} ${shown}\` deletes something git does not track, which nothing could restore`;
|
|
465
|
+
const bad = mine.find((e) => e.tag !== 'H');
|
|
466
|
+
if (bad) {
|
|
467
|
+
const what = bad.tag === '?' ? 'untracked or ignored' : 'modified or otherwise not restorable';
|
|
468
|
+
return `\`${prog} ${shown}\` deletes ${what} files (${bad.p}), which git cannot restore`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
|
|
476
|
+
const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
|
|
480
|
+
* `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
|
|
481
|
+
* `;` or a heredoc body is never a segment). Returns the reason it would lose data, or null
|
|
482
|
+
* when it provably would not. What it cannot settle — a glob, a git that fails — is a "could not
|
|
483
|
+
* verify" reason, never a throw. A BUG still throws, on purpose: `guardGate` turns it into a
|
|
484
|
+
* `gate-error` (an ask on Claude, a warning on Bob) and the OpenCode guard logs and allows it —
|
|
485
|
+
* each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
|
|
486
|
+
* `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
|
|
487
|
+
* — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
|
|
488
|
+
*/
|
|
489
|
+
export function lossReason(segments, cwd, { always = true } = {}) {
|
|
490
|
+
let dir = cwd;
|
|
491
|
+
try {
|
|
492
|
+
for (const raw of segments) {
|
|
493
|
+
let w = words(raw);
|
|
494
|
+
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
495
|
+
const wrapper = progOf(w[0]);
|
|
496
|
+
w = w.slice(1);
|
|
497
|
+
// A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
|
|
498
|
+
if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
|
|
499
|
+
}
|
|
500
|
+
if (!w.length) continue;
|
|
501
|
+
const prog = progOf(w[0]);
|
|
502
|
+
const args = w.slice(1);
|
|
503
|
+
if (CD_PROGS.has(prog)) {
|
|
504
|
+
const target = args.find((a) => !a.startsWith('-') && !/^\/d$/i.test(a));
|
|
505
|
+
try { dir = target && target !== '-' ? resolveIn(dir, target) : null; } catch { dir = null; }
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
let verdict = null;
|
|
509
|
+
if (prog === 'git') verdict = gitAct(args, dir, always);
|
|
510
|
+
else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
|
|
511
|
+
else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
|
|
512
|
+
unverified(`\`${prog}\` runs a command the gate cannot read`);
|
|
513
|
+
}
|
|
514
|
+
if (verdict) return verdict;
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
} catch (e) {
|
|
518
|
+
if (!(e instanceof Unverified)) throw e;
|
|
519
|
+
return `the gate could not verify what this command would lose (${e.message})`;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// ---- loss twin: END
|