Hält riskante Shell-Befehle an (rm -rf, git reset --hard, Force-Push, Migrationen) und zeigt dir vorher, was sie ändern würden.

Bevor Claude einen riskanten Shell-Befehl ausführt, hält dieser Mod ihn an und zeigt dir, was er ändern würde. Du entscheidest: Ausführen oder Abbrechen.
| Befehl | Was du siehst |
|---|---|
rm -r, rm -f, rm -rf | Welche Dateien gelöscht würden, mit Anzahl und Größe |
git reset --hard | Dateien mit nicht committeten Änderungen |
git checkout -- ., git restore . | Dateien mit ungestagten Änderungen |
git clean | Ungetrackte Dateien, die verschwinden würden (aus git clean -n) |
git push --force (auch -f, --force-with-lease, +ref) | Commits auf dem Remote, die der Push verwirft |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | Offene Migrationen |
Alle anderen Befehle laufen normal durch. Steht vorher cd ordner &&, pushd/popd oder git -C ordner, misst der Mod in diesem Ordner.
/plugin marketplace add Verselo-Orbit/claude-mods
/plugin install blast-radius-plus@verselo-mods
1 = Ausführen, 2 = Abbrechen. Abbrechen ist vorausgewählt, Enter bricht also ab.1 und 2, solange das Eingabefeld leer ist.$(...), Aliase, eval, bash -c "...", xargs rm, find -delete oder Skripte, die rm aufrufen, erkennt er nicht.rm-Anzahl ist ungefähr, die Liste zeigt die ersten 10 Dateien.git fetch. Ohne Fetch kann er die Commits nicht auflisten.python3 manage.py showmigrations). Das lädt Projektcode, bevor du entscheidest.bash, git, find, du und sleep im PATH.Getestet auf macOS mit Claude Code 2.1.295. Windows: Fix für Windows-Pfade (cd C:\projekt && rm -rf build) eingebaut, auf Windows noch nicht getestet.
Basiert auf Blast Radius von Anthropic (Apache-2.0). Änderungen siehe NOTICE.md.
hooks/blast-radius.mjs 567 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
4//
5// Blast Radius: holds a risky Bash command and shows what it would change.
6//
7// tool.call (Bash): if the command is risky, work out its blast radius, open a
8// pane with Proceed and Cancel, and hold the call until one is pressed.
9// ui.render (Pane): draws the report. If the surface won't place the pane (a
10// narrow terminal), the same report is drawn in the AbovePrompt band instead.
11//
12// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
13// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
14// a button's onPress sets the decision.
15// ponytail: not `$.clock.sleep` - its wait is the one `$` call that still counts
16// against the 10 s budget (see HookBudget in the types), so a hold would die after
17// 10 s. Needs a `sleep` binary on PATH (Git for Windows ships one).
18//
19// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
20// spelled literally, and helpers that take `$` are top-level functions.
21
22const PANE_ID = "blast-radius";
23const POLL_SECONDS = "0.25";
24const HOLD_LIMIT_MS = 10 * 60 * 1000;
25const LIST_MAX = 10;
26
27// The call being held, or null. One at a time: Bash calls in a turn run in order.
28let held = null;
29
30export function register(on) {
31 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
32 const risk = classify(String(e.command ?? ""));
33 if (risk === null) {
34 return next(e);
35 }
36 // One hold at a time. If another risky call is already held (a subagent's,
37 // say), wait until it is answered. `held` is claimed with no await between
38 // the check and the claim, so two waiting calls can't both get through.
39 while (held !== null) {
40 if (next.signal.aborted) {
41 return { deny: "Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
42 }
43 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
44 }
45 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
46 held = mine;
47
48 let opened = { isPlaced: false };
49 let decision;
50 let summary = risk.label;
51 try {
52 // Measure where the command will run: the session folder, moved by any
53 // `cd dir &&` or `git -C dir` earlier in the same command line.
54 const sessionCwd = await $.session.cwd();
55 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
56 mine.report = cwd === null
57 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Ordner ${risk.dir} nicht gefunden. Darum konnte ich nicht messen, was der Befehl ändert.` }
58 : await measure($, risk, cwd);
59 summary = mine.report.summary;
60
61 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
62 if (!opened.isPlaced) {
63 mine.where = "band";
64 }
65 $.ui.invalidate("ui.render");
66
67 const startedAt = await $.clock.now();
68 while (mine.decision === null) {
69 if (next.signal.aborted) {
70 mine.decision = "interrupted";
71 break;
72 }
73 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
74 mine.decision = "timeout";
75 break;
76 }
77 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
78 }
79 } catch {
80 mine.decision = "error"; // anything unexpected refuses the command
81 } finally {
82 decision = mine.decision;
83 // Close this call's pane before releasing the hold, so the next call's
84 // pane can't be the one that gets closed.
85 try {
86 if (opened.isPlaced) {
87 await $.ui.close({ id: PANE_ID });
88 }
89 } catch {
90 // the pane is already gone
91 }
92 if (held === mine) {
93 held = null;
94 }
95 $.ui.invalidate("ui.render");
96 }
97
98 if (decision === "proceed") {
99 $.ui.toast("Blast Radius: wird ausgeführt");
100 return next(e);
101 }
102 const why = {
103 cancel: "the user pressed Cancel (Abbrechen)",
104 timeout: "no answer within 10 minutes",
105 interrupted: "the turn was interrupted",
106 error: "Blast Radius hit an error while holding it",
107 }[decision] ?? "no answer was recorded";
108 return {
109 deny: `Blast Radius held this command and did not run it: ${why}. What it would have done (as shown to the user, in German): "${summary}". Do not retry it unless the user asks you to.`,
110 };
111 });
112
113 on("ui.render", { component: "Pane" }, ($, e, next) => {
114 if (e.requestId !== PANE_ID || held === null || held.report === null) {
115 return next(e);
116 }
117 return draw($.ui.resolve(e), held);
118 });
119
120 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
121 if (held === null || held.report === null || held.where !== "band") {
122 return next(e);
123 }
124 return draw($.ui.resolve(e), held);
125 });
126}
127
128// ---- What counts as risky -------------------------------------------------
129
130// sudo options that take a value, so the value isn't read as the command.
131const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
132// Commands that only read, so a bare word "migrate" in them isn't a migration.
133const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
134
135/** A Windows absolute path: `C:\x`, `C:/x`, `C:` or `\\server\share`. Git-Bash `/c/x` is already POSIX-absolute. */
136export function isWindowsAbsolute(path) {
137 return /^[A-Za-z]:([\\/]|$)/.test(path) || path.startsWith("\\\\");
138}
139
140/** `C:\x\y` -> `/c/x/y`, `\\srv\share` -> `//srv/share`, the way cygpath -u does on Git Bash. */
141export function toPosixPath(path) {
142 const slashed = path.replace(/\\/g, "/");
143 const drive = /^([A-Za-z]):(\/.*)?$/.exec(slashed);
144 return drive ? `/${drive[1].toLowerCase()}${drive[2] ?? "/"}` : slashed;
145}
146
147/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
148export function joinDir(dir, arg) {
149 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/") || isWindowsAbsolute(arg)) {
150 return arg ?? "~";
151 }
152 return dir ? `${dir}/${arg}` : arg;
153}
154
155/** The first risky segment of a shell command, or null. */
156export function classify(command) {
157 let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
158 const scopes = []; // dir to restore when a ( subshell ) closes
159 const pushed = []; // pushd stack, for popd
160 for (const raw of command.split(/&&|\|\||;|\||\n/)) {
161 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
162 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
163 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
164 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
165 for (let k = 0; k < opens; k += 1) {
166 scopes.push(dir);
167 }
168 const risk = classifySegment(raw, dir, pushed);
169 if (risk !== null && risk.cd === undefined) {
170 return risk;
171 }
172 if (risk !== null) {
173 dir = risk.cd; // a cd, pushd or popd moved the folder
174 }
175 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
176 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
177 }
178 }
179 return null;
180}
181
182// Words that can come before the real command without changing what it does.
183const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
184
185/** One segment: a risk, { cd } for a folder change, or null. */
186function classifySegment(segment, dir, pushed) {
187 {
188 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
189 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
190 words.shift(); // leading VAR=value
191 }
192 if (words[0] === "sudo") {
193 words.shift();
194 while (words.length > 0 && words[0].startsWith("-")) {
195 const option = words.shift();
196 if (SUDO_VALUE_OPTIONS.has(option)) {
197 words.shift();
198 }
199 }
200 }
201 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
202 words.shift();
203 }
204 if (words[0] === "nice") {
205 words.shift();
206 if (words[0] === "-n") {
207 words.splice(0, 2);
208 } else if (/^-\d+$/.test(words[0] ?? "")) {
209 words.shift();
210 }
211 }
212 const [first, ...args] = words;
213 if (first === undefined) {
214 return null;
215 }
216 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
217 if (cmd === "cd") {
218 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
219 }
220 if (cmd === "pushd") {
221 pushed.push(dir);
222 return { cd: joinDir(dir, args[0]) };
223 }
224 if (cmd === "popd") {
225 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
226 }
227 if (cmd === "rm" || cmd.endsWith("/rm")) {
228 const flags = args.filter((a) => a.startsWith("-"));
229 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
230 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
231 if (recursive || force) {
232 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
233 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
234 }
235 }
236 if (cmd === "git") {
237 // Git's own options come before the subcommand; -C moves where it runs.
238 let gitDir = dir;
239 let i = 0;
240 while (i < args.length && args[i].startsWith("-")) {
241 if (args[i] === "-C" && i + 1 < args.length) {
242 gitDir = joinDir(gitDir, args[i + 1]);
243 i += 2;
244 } else if (args[i] === "-c" && i + 1 < args.length) {
245 i += 2;
246 } else {
247 i += 1;
248 }
249 }
250 const sub = args[i];
251 const rest = args.slice(i + 1);
252 if (sub === "reset" && rest.includes("--hard")) {
253 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
254 }
255 if (sub === "clean") {
256 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
257 }
258 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
259 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
260 }
261 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
262 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
263 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
264 }
265 }
266 const joined = words.join(" ");
267 if (/\balembic\s+upgrade\b/.test(joined)) {
268 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
269 }
270 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
271 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
272 }
273 if (/\bprisma\s+migrate\b/.test(joined)) {
274 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
275 }
276 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
277 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
278 }
279 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
280 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
281 }
282 }
283 return null;
284}
285
286// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
287// The target is passed as an argument, never as source.
288// `pwd -W` (Git Bash / MSYS only) prints `C:/x`, a folder Windows can spawn in;
289// `/c/x` from `pwd -P` is not, and every measuring step after it would fail.
290// Elsewhere `pwd -W` is an invalid option, so `pwd -P` answers.
291const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
292
293async function resolveDir($, sessionCwd, dir) {
294 if (dir === "-") {
295 return null; // `cd -` depends on the shell's history
296 }
297 if (isWindowsAbsolute(dir)) {
298 dir = await windowsToPosix($, sessionCwd, dir);
299 }
300 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
301 const out = run.stdout.trim();
302 return run.exitCode === 0 && out !== "" ? out : null;
303}
304
305// Git Bash's own cygpath knows mount points; without it, convert by hand.
306async function windowsToPosix($, sessionCwd, dir) {
307 try {
308 const run = await $.process.run(["cygpath", "-u", dir], { cwd: sessionCwd, timeoutMs: 5000 });
309 const out = run.stdout.trim();
310 if (run.exitCode === 0 && out !== "") {
311 return out;
312 }
313 } catch {
314 // no cygpath on this machine
315 }
316 return toPosixPath(dir);
317}
318
319/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
320function tokenize(text) {
321 const words = [];
322 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
323 let m;
324 while ((m = re.exec(text)) !== null) {
325 words.push(m[1] ?? m[2] ?? m[3]);
326 }
327 return words;
328}
329
330// ---- Measuring the blast radius -------------------------------------------
331
332/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
333async function measure($, risk, cwd) {
334 try {
335 if (risk.kind === "rm") {
336 return await measureRm($, risk, cwd);
337 }
338 if (risk.kind === "migrate") {
339 return await measureMigrations($, risk, cwd);
340 }
341 return await measureGit($, risk, cwd);
342 } catch (error) {
343 return { summary: `${risk.label} (konnte ich nicht messen)`, lines: [], note: `Messen fehlgeschlagen: ${String(error?.message ?? error).slice(0, 200)}` };
344 }
345}
346
347// The paths are passed to bash as arguments, never as source, so nothing in
348// them runs. compgen -G expands a glob without command substitution.
349const RM_SCRIPT = `
350shopt -s nullglob dotglob
351paths=()
352for p in "$@"; do
353 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
354 if [[ "$p" == *[*?[]* ]]; then
355 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
356 elif [[ -e "$p" || -L "$p" ]]; then
357 paths+=("$p")
358 fi
359done
360if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
361# A relative path gets ./ in front, so find never reads a name like -delete as an action.
362for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
363files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
364kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
365echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
366find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
367`;
368
369async function measureRm($, risk, cwd) {
370 if (risk.targets.length === 0) {
371 return { summary: "rm ohne Pfade", lines: [], note: "Keine Pfade angegeben." };
372 }
373 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
374 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
375 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
376 if (!found) {
377 return { summary: `löscht nichts: keine Datei passt zu ${risk.targets.join(" ")}`, lines: [], note: "Die Pfade gibt es nicht, rm hat nichts zu löschen." };
378 }
379 if (!files) {
380 return { summary: `löscht ${found} ${found === 1 ? "leeren Pfad" : "leere Pfade"} (keine Dateien darin)`, lines: [], note: `Pfade: ${risk.targets.join(" ")}` };
381 }
382 return {
383 summary: `löscht ${files} ${files === 1 ? "Datei" : "Dateien"} (ca. ${size(bytes)})`,
384 lines: rest.map((l) => l.replace(/^\.\//, "")),
385 more: Math.max(0, files - rest.length),
386 note: `Pfade: ${risk.targets.join(" ")}`,
387 };
388}
389
390async function measureGit($, risk, cwd) {
391 if (risk.kind === "git-push-force") {
392 return await measurePush($, risk, cwd);
393 }
394 if (risk.kind === "git-clean") {
395 const flags = [];
396 const paths = [];
397 for (let i = 0; i < risk.args.length; i += 1) {
398 const a = risk.args[i];
399 if (a === "--") {
400 paths.push(...risk.args.slice(i + 1));
401 break;
402 }
403 if (a === "-e" || a === "--exclude") {
404 flags.push(a, risk.args[i + 1] ?? "");
405 i += 1;
406 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
407 flags.push(a);
408 } else if (/^-[a-zA-Z]+$/.test(a)) {
409 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
410 if (kept !== "-") {
411 flags.push(kept);
412 }
413 } else if (!a.startsWith("-")) {
414 paths.push(a);
415 }
416 }
417 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
418 if (run.exitCode !== 0) {
419 return { summary: "git clean (Probelauf fehlgeschlagen)", lines: [], note: run.stderr.trim().slice(0, 200) };
420 }
421 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
422 return {
423 summary: gone.length === 0 ? "entfernt nichts: keine ungetrackten Dateien betroffen" : `entfernt ${gone.length} ungetrackte ${gone.length === 1 ? "Datei/Ordner" : "Dateien/Ordner"}`,
424 lines: gone.slice(0, LIST_MAX),
425 more: Math.max(0, gone.length - LIST_MAX),
426 note: "Aus git clean -n. Ungetrackte Dateien sind nicht in git und lassen sich nicht wiederherstellen.",
427 };
428 }
429 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
430 if (status.exitCode !== 0) {
431 return { summary: `${risk.label} (hier kein git-Repo?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
432 }
433 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
434 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
435 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
436 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
437 return {
438 summary: lost.length === 0 ? "verwirft nichts: keine offenen Änderungen" : `verwirft Änderungen in ${lost.length} ${lost.length === 1 ? "Datei" : "Dateien"}`,
439 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
440 more: Math.max(0, lost.length - LIST_MAX),
441 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Nicht committete Änderungen lassen sich nicht wiederherstellen.` : "Aus git status --porcelain.",
442 };
443}
444
445async function measurePush($, risk, cwd) {
446 const positional = risk.args.filter((a) => !a.startsWith("-"));
447 const remote = positional[0] ?? "origin";
448 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
449 const spec = (positional[1] ?? "").replace(/^\+/, "");
450 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
451 branch = (branch ?? "").replace(/^refs\/heads\//, "");
452 if (!branch) {
453 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
454 branch = head.stdout.trim();
455 source = "HEAD";
456 } else if (branch === "HEAD") {
457 // `git push origin HEAD` pushes the current branch to its namesake.
458 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
459 branch = head.stdout.trim();
460 source = "HEAD";
461 }
462 source = source || "HEAD";
463 const ref = `${remote}/${branch}`;
464 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
465 if (known.exitCode !== 0) {
466 return { summary: `Force-Push auf ${ref}`, lines: [], note: `Keine lokale Kopie von ${ref}, darum ist unklar, welche Commits der Push verwirft. Vorher git fetch ausführen.` };
467 }
468 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
469 const dropped = log.stdout.split("\n").filter((l) => l !== "");
470 return {
471 summary: dropped.length === 0 ? `Force-Push auf ${ref}: verwirft keine Commits` : `Force-Push auf ${ref}: verwirft ${dropped.length} ${dropped.length === 1 ? "Commit" : "Commits"}`,
472 lines: dropped.slice(0, LIST_MAX),
473 more: Math.max(0, dropped.length - LIST_MAX),
474 note: `Commits auf ${ref}, die ${source} nicht hat (Stand: letzter Fetch).`,
475 };
476}
477
478const MIGRATION_LISTERS = {
479 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
480 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
481 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
482 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
483};
484
485async function measureMigrations($, risk, cwd) {
486 const lister = MIGRATION_LISTERS[risk.tool];
487 if (lister === undefined) {
488 return { summary: "führt Migrationen aus", lines: [], note: "Für dieses Tool kann ich offene Migrationen nicht auflisten." };
489 }
490 let run;
491 try {
492 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
493 } catch (error) {
494 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
495 }
496 if (run.exitCode !== 0) {
497 return { summary: `führt ${risk.label} aus`, lines: [], note: `Offene Migrationen nicht lesbar (${lister.argv.join(" ")} fehlgeschlagen).` };
498 }
499 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
500 return {
501 summary: pending.length === 0 ? `führt ${risk.label} aus: nichts offen` : `wendet ${pending.length} offene ${pending.length === 1 ? "Migration" : "Migrationen"} an`,
502 lines: pending.slice(0, LIST_MAX),
503 more: Math.max(0, pending.length - LIST_MAX),
504 note: `Aus ${lister.argv.join(" ")}.`,
505 };
506}
507
508export function size(bytes) {
509 if (!Number.isFinite(bytes) || bytes < 1024) {
510 return `${bytes || 0} B`;
511 }
512 const units = ["KB", "MB", "GB", "TB"];
513 let n = bytes;
514 let i = -1;
515 while (n >= 1024 && i < units.length - 1) {
516 n /= 1024;
517 i += 1;
518 }
519 return `${n.toFixed(n < 10 ? 1 : 0).replace(".", ",")} ${units[i]}`;
520}
521
522// ---- Drawing --------------------------------------------------------------
523
524function paneRows(report) {
525 return Math.min(24, 11 + report.lines.length + (report.more ? 1 : 0));
526}
527
528// Layout: title, then what happens (the one line to read, bold red), then the
529// command dim, then the list, the note, and the buttons with their hotkeys.
530export function draw(t, state) {
531 const { Box, Text, Button } = t;
532 const { report } = state;
533 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` • ${line}`, wrap: "truncate-end" }));
534 if (report.more) {
535 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} weitere` }));
536 }
537 // The buttons answer the call this pane was drawn for, never whichever one is held now.
538 const decide = (choice) => () => {
539 if (state.decision === null) {
540 state.decision = choice;
541 }
542 };
543 return Box({
544 flexDirection: "column",
545 borderStyle: "round",
546 borderColor: "yellow",
547 paddingX: 1,
548 children: [
549 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}`, wrap: "truncate-end" }),
550 Text({ key: "sum", children: [Text({ dimColor: true, children: "Würde " }), Text({ color: "red", bold: true, children: report.summary })], wrap: "wrap" }),
551 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Befehl " }), Text({ children: state.command })], wrap: "truncate-end" }),
552 list.length > 0 ? Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }) : null,
553 report.note ? Box({ key: "note", marginTop: 1, children: [Text({ dimColor: true, italic: true, children: report.note, wrap: "wrap" })] }) : null,
554 Box({
555 key: "buttons",
556 marginTop: 1,
557 gap: 2,
558 children: [
559 Button({ key: "proceed", label: "Ausführen", hotkey: "1", plain: true, onPress: decide("proceed") }),
560 Button({ key: "cancel", label: "Abbrechen", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
561 Text({ key: "hint", dimColor: true, children: "Claude wartet auf deine Entscheidung" }),
562 ],
563 }),
564 ],
565 });
566}
567