Zeigt, welcher Checkout eines Repos wo läuft und wer daran arbeitet, gibt jedem Worktree einen festen Port, startet und stoppt Dev-Server, räumt gemergte…

Claude-Code-Mod für Repos mit mehreren Checkouts (Hauptordner und git worktrees): Jeder Checkout hat einen festen Platz mit eigenem Port, und man sieht auf einen Blick, welcher Stand wo läuft, wer daran arbeitet und was weg kann. Claude hält sich an diese Plätze, statt jedes Mal neu zu improvisieren.
cd oder git -C aus einer anderen Sitzung heraus). Unter dem Prompt steht eine Zeile, wenn es etwas zu sagen gibt: 🛠 ▶ raupe :5174 · 👥 „Kabelsee-Wertung“ arbeitet in main · 🧹 1 zum Aufräumen. /werkbank öffnet ein Fenster mit allen Checkouts, ihren Ports, dem Stand gegenüber main, offenen Dateien und Knöpfen (Starten, Öffnen, Stopp, Was drin?)./werkbank dev raupe startet Web und API dieses Checkouts im Hintergrund (vite mit --port … --strictPort, API_PORT für beide), stoppt die anderen Server des Repos und öffnet die Vorschau. Ohne Branch startet /werkbank dev den zuletzt geänderten Checkout (jüngster Commit oder zuletzt bearbeitete offene Datei). Server, in deren Checkout gerade eine andere Sitzung arbeitet, bleiben stehen. Ein frischer Worktree bekommt einen Link auf die node_modules von main. Logs: ~/.claude/werkbank/logs/. Die Server laufen weiter, wenn die Sitzung endet.werkbank (lage, dev, stopp, aufraeumen, was) und bei git worktree add ein Hinweis auf den festen Port. Nichts davon steht im Systemprompt, damit der Cache warm bleibt./werkbank aufräumen nimmt auch leere Worktrees mit und sagt zum Rest, was noch drin ist.git worktree remove oder rm -r… auf einen Checkout, in dem eine andere Sitzung arbeitet, und auf den Hauptordner lehnt die Mod ab.main, während eine andere Sitzung im Repo arbeitet, erfährt Claude das mit Namen. Arbeitet sie im selben Checkout, wird angehalten.Andere Sitzungen kennt die Mod aus ~/.claude/sessions/ (Ordner, beschäftigt oder frei); läuft dort auch die Werkbank, weiß sie zudem, in welchem Worktree.
| Befehl | Wirkung | |
|---|---|---|
/werkbank | Fenster öffnen, Lage als Text | |
/werkbank dev [branch] | Dev-Server des Checkouts starten (ohne Branch: der zuletzt geänderte), andere stoppen, Vorschau öffnen | |
| `/werkbank stopp [branch\ | alle]` | Server stoppen |
/werkbank aufräumen | Gemergte und leere Worktrees entfernen, den Rest erklären | |
/werkbank was <branch> | Commits und offene Dateien eines Checkouts | |
/werkbank weg <branch> | Worktree und Branch entfernen, auch ungemergt (nur sauber; der Rückweg wird genannt) |
claude plugin validate werkbank und claude plugin test werkbank. Die Plugin-API für Mods ist Early Access und kann sich ändern.
hooks/register.tsx 649 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 absolut, befehlsOrt, checkoutVon, fmtAlter, istHauptbranch, istServer, istWorktreeAdd, stoppbar, leseCwds, leseLauscher, leseWorktrees,
5 loeschZiele, naechsterPlatz, offenePfade, portsFuer, PUSH, q, startBefehl, zaehleOffen, zielMain, zuletztGeaendert,
6} from './rein'
7
8const SEK = 1000
9const MIN = 60 * SEK
10// So oft wird die Lage still aufgefrischt (Statuszeile); offen das Fenster öfter.
11const AUFFRISCHEN_MS = 20 * SEK
12const FENSTER_MS = 3 * SEK
13// Wie lange eine Sitzung als „arbeitet in Checkout X“ gilt, nachdem Claude dort zuletzt etwas tat.
14const SITZUNG_GILT_MS = 6 * 60 * MIN
15const PANE = 'werkbank'
16const BROWSER = 'mcp__Claude_Browser__preview_start'
17// Für „zuletzt geändert“ reichen die ersten offenen Dateien: mehr stat-Aufrufe bremsen nur.
18const OFFEN_STAT_MAX = 40
19
20type Checkout = {
21 pfad: string
22 name: string
23 branch: string | null
24 head: string
25 haupt: boolean
26 vor: number
27 hinter: number
28 offen: number
29 alter: number
30 /** Jüngste Änderung (ms): letzter Commit oder später bearbeitete offene Datei. */
31 geaendert: number
32 platz: number
33 web: number
34 api: number
35 /** Hatte eigene Commits und die stecken jetzt in main. */
36 gemergt: boolean
37 /** Keine eigenen Commits, nichts offen: ein leerer Worktree. */
38 leer: boolean
39}
40type Server = { pid: number; port: number; cmd: string; pfad: string; art: 'web' | 'api' | 'anderer' }
41type Sitzung = { sid: string; name: string; arbeitet: boolean; checkout?: string; ich: boolean }
42type Lage = { repo: string; name: string; basis: string; checkouts: Checkout[]; server: Server[]; sitzungen: Sitzung[]; at: number }
43type SitzungsEintrag = { repo: string; checkout: string; at: number }
44
45type Zustand = {
46 home: string
47 sid: string
48 /** Das Repo, um das es gerade geht: wo Claude zuletzt arbeitete, sonst cwd. */
49 repo: string | null
50 lage: Lage | null
51 fensterOffen: boolean
52 laedt: Promise<Lage | null> | null
53 repoCache: Map<string, string | null>
54 meinCheckout: string | null
55 aufraeumenNachZug: Set<string>
56 status: string | undefined
57 meldung: string
58 /** Dieser Claude-Prozess und seine Vorfahren: nie stoppen. */
59 vorfahren: Set<number> | null
60}
61
62// ---------- Host ----------
63
64async function lauf($: EngineInterface, argv: string[], cwd?: string, timeoutMs = 15 * SEK) {
65 try {
66 return await $.process.run(argv, { ...(cwd ? { cwd } : {}), timeoutMs })
67 } catch {
68 return { exitCode: -1, stdout: '', stderr: 'nicht startbar' }
69 }
70}
71
72async function git($: EngineInterface, ort: string, args: string[]): Promise<string | null> {
73 const r = await lauf($, ['git', '-C', ort, ...args])
74 return r.exitCode === 0 ? r.stdout : null
75}
76
77/** Der Hauptordner des Repos, in dem `dir` liegt (auch aus einem Worktree heraus). */
78async function repoVon($: EngineInterface, z: Zustand, dir: string): Promise<string | null> {
79 if (z.repoCache.has(dir)) return z.repoCache.get(dir)!
80 let d = dir
81 // Ein Ordner, den es (noch) nicht gibt: am nächsten vorhandenen Elternordner fragen.
82 for (let i = 0; i < 4 && !(await $.fs.exists(d).catch(() => false)); i++) d = d.replace(/\/[^/]+$/, '') || '/'
83 const common = (await git($, d, ['rev-parse', '--path-format=absolute', '--git-common-dir']))?.trim()
84 const repo = common?.endsWith('/.git') ? common.slice(0, -5) : null
85 z.repoCache.set(dir, repo)
86 return repo
87}
88
89async function basisVon($: EngineInterface, repo: string): Promise<string> {
90 for (const b of ['origin/main', 'origin/master', 'main', 'master']) {
91 if (await git($, repo, ['rev-parse', '--verify', '-q', b])) return b
92 }
93 return 'HEAD'
94}
95
96async function plaetze($: EngineInterface, repo: string): Promise<Record<string, number>> {
97 return ((await $.store.get(`plaetze:${repo}`)) as Record<string, number> | undefined) ?? {}
98}
99
100async function lauscher($: EngineInterface): Promise<{ pid: number; port: number; cmd: string; cwd: string }[]> {
101 const r = await lauf($, ['/usr/sbin/lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn'])
102 const liste = leseLauscher(r.stdout)
103 if (!liste.length) return []
104 const pids = [...new Set(liste.map(l => l.pid))].join(',')
105 const cwds = leseCwds((await lauf($, ['/usr/sbin/lsof', '-a', '-d', 'cwd', '-p', pids, '-Fpn'])).stdout)
106 return liste.map(l => ({ ...l, cwd: cwds.get(l.pid) ?? '' }))
107}
108
109/** Laufende Sitzungen aus ~/.claude/sessions, mit dem, was die Mod über sie weiß. */
110async function sitzungen($: EngineInterface, z: Zustand, repo: string, checkouts: string[], now: number): Promise<Sitzung[]> {
111 const ordner = `${z.home}/.claude/sessions`
112 let eintraege: { name: string }[] = []
113 try { eintraege = await $.fs.list(ordner) } catch { return [] }
114 const roh: { pid: number; sessionId: string; cwd: string; status?: string; name?: string }[] = []
115 for (const e of eintraege) {
116 if (!e.name.endsWith('.json')) continue
117 try { roh.push(JSON.parse(await $.fs.read(`${ordner}/${e.name}`))) } catch { /* halb geschrieben */ }
118 }
119 if (!roh.length) return []
120 const lebt = new Set((await lauf($, ['/bin/ps', '-o', 'pid=', '-p', roh.map(r => r.pid).join(',')])).stdout.split('\n').map(s => Number(s.trim())))
121 const aus: Sitzung[] = []
122 for (const r of roh) {
123 if (!lebt.has(r.pid)) continue
124 const e = (await $.store.get(`sitzung:${r.sessionId}`)) as SitzungsEintrag | undefined
125 const checkout = e && e.repo === repo && now - e.at < SITZUNG_GILT_MS && checkouts.includes(e.checkout)
126 ? e.checkout
127 : checkoutVon(r.cwd, checkouts)
128 const ich = r.sessionId === z.sid
129 if (!checkout && !ich) continue
130 aus.push({ sid: r.sessionId, name: r.name || r.sessionId.slice(0, 8), arbeitet: r.status === 'busy', checkout: ich ? z.meinCheckout ?? checkout : checkout, ich })
131 }
132 return aus
133}
134
135async function ladeLage($: EngineInterface, z: Zustand, repo: string): Promise<Lage | null> {
136 const wl = await git($, repo, ['worktree', 'list', '--porcelain'])
137 if (wl === null) return null
138 const wts = leseWorktrees(wl)
139 const basis = await basisVon($, repo)
140 const now = await $.clock.now()
141 // Feste Plätze: verschwundene Checkouts geben ihren frei, neue bekommen den kleinsten freien.
142 const alt = await plaetze($, repo)
143 const pl: Record<string, number> = {}
144 for (const [i, w] of wts.entries()) if (i === 0) pl[w.pfad] = 0; else if (alt[w.pfad] !== undefined) pl[w.pfad] = alt[w.pfad]!
145 for (const w of wts) if (pl[w.pfad] === undefined) pl[w.pfad] = naechsterPlatz(Object.values(pl))
146 if (JSON.stringify(pl) !== JSON.stringify(alt)) await $.store.set(`plaetze:${repo}`, pl)
147
148 const checkouts = await Promise.all(wts.map(async (w, i): Promise<Checkout> => {
149 const [st, lr, ct] = await Promise.all([
150 git($, w.pfad, ['status', '--porcelain']),
151 git($, w.pfad, ['rev-list', '--left-right', '--count', `${basis}...HEAD`]),
152 git($, w.pfad, ['log', '-1', '--format=%ct']),
153 ])
154 const [hinter = 0, vor = 0] = (lr ?? '0 0').trim().split(/\s+/).map(Number)
155 const offen = st === null ? 0 : zaehleOffen(st)
156 const commitZeit = ct ? Number(ct.trim()) * SEK : 0
157 let geaendert = commitZeit
158 for (const f of st === null ? [] : offenePfade(st).slice(0, OFFEN_STAT_MAX)) {
159 const s = await $.fs.stat(`${w.pfad}/${f}`).catch(() => null)
160 if (s && s.mtimeMs > geaendert) geaendert = s.mtimeMs
161 }
162 const key = `ungemergt:${w.pfad}`
163 if (vor > 0) await $.store.set(key, true)
164 const warUngemergt = vor === 0 && (await $.store.get(key)) === true
165 const platz = pl[w.pfad] ?? 0
166 return {
167 pfad: w.pfad, name: i === 0 ? (w.branch ?? 'main') : (w.branch ?? w.pfad.split('/').pop()!), branch: w.branch, head: w.head,
168 haupt: i === 0, vor, hinter, offen, alter: commitZeit ? now - commitZeit : 0, geaendert, platz, ...portsFuer(platz),
169 gemergt: i > 0 && warUngemergt && offen === 0, leer: i > 0 && !warUngemergt && vor === 0 && offen === 0,
170 }
171 }))
172 const pfade = checkouts.map(c => c.pfad)
173 const server: Server[] = []
174 for (const l of await lauscher($)) {
175 const pfad = checkoutVon(l.cwd, pfade)
176 if (!pfad || !istServer(l.cmd)) continue
177 const c = checkouts.find(x => x.pfad === pfad)!
178 server.push({ pid: l.pid, port: l.port, cmd: l.cmd, pfad, art: l.port === c.web ? 'web' : l.port === c.api ? 'api' : 'anderer' })
179 }
180 return {
181 repo, name: repo.split('/').pop() ?? repo, basis, checkouts, server,
182 sitzungen: await sitzungen($, z, repo, pfade, now), at: now,
183 }
184}
185
186async function frischeLage($: EngineInterface, z: Zustand, repo = z.repo): Promise<Lage | null> {
187 // Noch kein Repo im Blick: das des Sitzungsordners, falls es eins gibt.
188 if (!repo && !z.repoCache.has('§cwd')) {
189 z.repoCache.set('§cwd', null)
190 repo = z.repo = await repoVon($, z, await $.session.cwd())
191 }
192 if (!repo) return null
193 if (z.laedt) return z.laedt
194 z.laedt = (async () => {
195 try {
196 const lage = await ladeLage($, z, repo)
197 if (repo === z.repo) {
198 z.lage = lage
199 zeichne($, z)
200 }
201 return lage
202 } finally {
203 z.laedt = null
204 }
205 })()
206 return z.laedt
207}
208
209// ---------- Anzeige ----------
210
211export function statusText(lage: Lage | null): string | undefined {
212 if (!lage) return undefined
213 const teile: string[] = []
214 for (const c of lage.checkouts) {
215 const s = lage.server.find(x => x.pfad === c.pfad && x.art === 'web')
216 if (s) teile.push(`▶ ${c.name} :${s.port}`)
217 }
218 for (const s of lage.sitzungen) {
219 if (s.ich || !s.arbeitet) continue
220 const wo = lage.checkouts.find(c => c.pfad === s.checkout)?.name
221 teile.push(`👥 „${s.name}“ arbeitet${wo ? ` in ${wo}` : ''}`)
222 }
223 const weg = lage.checkouts.filter(c => (c.gemergt || c.leer) && !benutzt(lage, c.pfad)).length
224 if (weg) teile.push(`🧹 ${weg} zum Aufräumen`)
225 return teile.length ? `🛠 ${teile.join(' · ')}` : undefined
226}
227
228function zeichne($: EngineInterface, z: Zustand) {
229 const text = statusText(z.lage)
230 if (text !== z.status) {
231 z.status = text
232 $.ui.status(text)
233 }
234 if (z.fensterOffen) $.ui.invalidate('ui.render')
235}
236
237/** Arbeitet eine andere Sitzung in diesem Checkout? */
238function benutzt(lage: Lage, pfad: string, ausser?: string): Sitzung | undefined {
239 return lage.sitzungen.find(s => !s.ich && s.sid !== ausser && s.checkout === pfad)
240}
241
242function zeile(lage: Lage, c: Checkout): string {
243 const server = lage.server.filter(s => s.pfad === c.pfad)
244 const ports = server.length ? server.map(s => `:${s.port}${s.art === 'api' ? ' (api)' : ''}`).join(' ') : '—'
245 const stand = c.haupt
246 ? (c.hinter ? `${c.hinter} hinter ${lage.basis}` : `= ${lage.basis}`)
247 : c.gemergt ? 'gemergt' : c.leer ? 'leer' : `+${c.vor} Commits${c.hinter ? `, ${c.hinter} hinter main` : ''}`
248 const offen = c.offen ? `, ${c.offen} Dateien offen` : ''
249 const wer = lage.sitzungen.filter(s => s.checkout === c.pfad).map(s => s.ich ? 'diese Sitzung' : `„${s.name}“${s.arbeitet ? ' arbeitet' : ''}`).join(', ')
250 return `${server.some(s => s.art === 'web') ? '▶' : c.gemergt ? '✓' : '○'} ${c.name.padEnd(14)} ${ports.padEnd(16)} ${(stand + offen).padEnd(30)} ${wer || (c.alter ? `zuletzt vor ${fmtAlter(c.alter)}` : '')}`
251}
252
253export function lageText(lage: Lage): string {
254 return [`${lage.name} (Platz-Ports: Web ${lage.checkouts.map(c => `${c.name}=${c.web}`).join(', ')})`, ...lage.checkouts.map(c => zeile(lage, c))].join('\n')
255}
256
257// ---------- Handeln ----------
258
259function finde(lage: Lage, wer?: string): Checkout | undefined {
260 if (!wer) return undefined
261 const w = wer.trim()
262 return lage.checkouts.find(c => c.branch === w || c.name === w || c.pfad === w || c.pfad.endsWith('/' + w) || c.pfad.split('/').pop() === w)
263}
264
265async function nachfahren($: EngineInterface, pid: number): Promise<number[]> {
266 const kinder = (await lauf($, ['/usr/bin/pgrep', '-P', String(pid)])).stdout.split('\n').map(Number).filter(Boolean)
267 const alle: number[] = []
268 for (const k of kinder) alle.push(...await nachfahren($, k), k)
269 return alle
270}
271
272/** Die Prozesskette über dem Befehl: dieser Claude-Prozess und alles darüber. */
273async function vorfahren($: EngineInterface, z: Zustand): Promise<Set<number>> {
274 if (z.vorfahren) return z.vorfahren
275 const r = await lauf($, ['/bin/sh', '-c', 'p=$PPID; while [ "$p" -gt 1 ] 2>/dev/null; do echo "$p"; p=$(/bin/ps -o ppid= -p "$p" | tr -d " "); done'])
276 const pids = new Set(r.stdout.split('\n').map(Number).filter(Boolean))
277 if (pids.size) z.vorfahren = pids
278 return pids
279}
280
281/** Stoppt Server samt Kindern; ein npm darüber, das nur ihn trug, gleich mit. Nie Claude selbst. */
282async function stoppe($: EngineInterface, z: Zustand, alle: readonly Server[]) {
283 const schutz = await vorfahren($, z)
284 const server = alle.filter(s => stoppbar(s) && !schutz.has(s.pid))
285 const ziele = new Set<number>()
286 for (const s of server) {
287 for (const k of await nachfahren($, s.pid)) ziele.add(k)
288 ziele.add(s.pid)
289 const eltern = (await lauf($, ['/bin/ps', '-o', 'ppid=,command=', '-p', String(s.pid)])).stdout.trim()
290 const m = /^(\d+)\s+(.*)$/.exec(eltern)
291 if (m) {
292 const ppid = Number(m[1])
293 const pc = (await lauf($, ['/bin/ps', '-o', 'command=', '-p', String(ppid)])).stdout
294 if (/\bnpm\b|npm-cli|\bsh -c .*(vite|wrangler)/.test(pc)) ziele.add(ppid)
295 }
296 }
297 for (const p of schutz) ziele.delete(p)
298 if (ziele.size) await lauf($, ['/bin/kill', '-TERM', ...[...ziele].map(String)])
299 return server
300}
301
302async function wartePort($: EngineInterface, port: number, sekunden = 40): Promise<boolean> {
303 const r = await lauf($, ['/bin/sh', '-c',
304 `i=0; while [ $i -lt ${sekunden * 2} ]; do /usr/sbin/lsof -nP -iTCP:${port} -sTCP:LISTEN -t >/dev/null 2>&1 && exit 0; sleep 0.5; i=$((i+1)); done; exit 1`],
305 undefined, (sekunden + 5) * SEK)
306 return r.exitCode === 0
307}
308
309async function oeffne($: EngineInterface, url: string): Promise<boolean> {
310 try {
311 if (!(await $.tool.list()).some(t => t.name === BROWSER)) return false
312 const r = await $.tool.call({ tool: BROWSER, url } as never)
313 return r.deny === undefined && !r.isError
314 } catch {
315 return false
316 }
317}
318
319/** Startet den Dev-Server eines Checkouts auf seinem Platz und stoppt die anderen des Repos. */
320async function starte($: EngineInterface, z: Zustand, lage: Lage, c: Checkout, oeffnen: boolean): Promise<string> {
321 // Fremde Server bleiben, wo eine andere Sitzung gerade arbeitet.
322 const andere = await stoppe($, z, lage.server.filter(s => s.pfad !== c.pfad && !benutzt(lage, s.pfad)?.arbeitet))
323 const laeuft = lage.server.some(s => s.pfad === c.pfad && s.port === c.web)
324 const url = `http://localhost:${c.web}/`
325 const gestoppt = andere.length ? ` Gestoppt: ${[...new Set(andere.map(s => `${lage.checkouts.find(x => x.pfad === s.pfad)?.name ?? '?'} :${s.port}`))].join(', ')}.` : ''
326 if (laeuft) {
327 const offen = oeffnen && await oeffne($, url)
328 return `${c.name} läuft schon auf ${url}${offen ? ' (in der Vorschau geöffnet)' : ''}.${gestoppt}`
329 }
330 let scripts: Record<string, string> = {}
331 try { scripts = (JSON.parse(await $.fs.read(`${c.pfad}/package.json`)) as { scripts?: Record<string, string> }).scripts ?? {} } catch { /* kein Node-Projekt */ }
332 const b = startBefehl(scripts, c)
333 if (!b.web) return `${c.name}: kein „dev“-Skript in package.json, nichts zu starten.`
334 // Ein frischer Worktree hat keine node_modules: der Link auf die von main spart npm install.
335 let link = ''
336 if (!c.haupt && !(await $.fs.exists(`${c.pfad}/node_modules`)) && await $.fs.exists(`${lage.repo}/node_modules`)) {
337 await lauf($, ['/bin/ln', '-s', `${lage.repo}/node_modules`, `${c.pfad}/node_modules`])
338 link = ' node_modules von main verlinkt.'
339 }
340 const logs = `${z.home}/.claude/werkbank/logs`
341 const slug = `${lage.name}-${c.platz}`
342 const start = (befehl: string, art: string) =>
343 lauf($, ['/bin/sh', '-c', `mkdir -p ${q(logs)} && cd ${q(c.pfad)} && (nohup sh -c ${q(befehl)} > ${q(`${logs}/${slug}-${art}.log`)} 2>&1 &)`])
344 const apiLaeuft = lage.server.some(s => s.pfad === c.pfad && s.port === c.api)
345 if (b.api && !apiLaeuft) await start(b.api, 'api')
346 await start(b.web, 'web')
347 const ok = await wartePort($, c.web)
348 await frischeLage($, z, lage.repo)
349 if (!ok) {
350 const log = await $.fs.read(`${logs}/${slug}-web.log`).catch(() => '')
351 return `${c.name} startet nicht auf :${c.web}.${gestoppt}${link}\nEnde des Logs:\n${log.trim().split('\n').slice(-15).join('\n')}`
352 }
353 const offen = oeffnen && await oeffne($, url)
354 return `${c.name} läuft auf ${url}${b.api ? ` (API :${c.api})` : ''}${offen ? ', in der Vorschau geöffnet' : ''}.${gestoppt}${link}`
355}
356
357async function stoppeCheckout($: EngineInterface, z: Zustand, lage: Lage, c: Checkout | 'alle'): Promise<string> {
358 const ziel = await stoppe($, z, lage.server.filter(s => c === 'alle' || s.pfad === c.pfad))
359 if (!ziel.length) return 'Da läuft nichts.'
360 await frischeLage($, z, lage.repo)
361 return `Gestoppt: ${ziel.map(s => `${lage.checkouts.find(x => x.pfad === s.pfad)?.name ?? '?'} :${s.port}`).join(', ')}.`
362}
363
364async function wasDrin($: EngineInterface, lage: Lage, c: Checkout): Promise<string> {
365 const log = (await git($, c.pfad, ['log', '--format=- %s (%cr)', `${lage.basis}..HEAD`]))?.trim()
366 const st = (await git($, c.pfad, ['status', '--short']))?.trim()
367 return [
368 `${c.name} (${c.pfad})`,
369 log ? `Commits, die nicht in ${lage.basis} sind:\n${log}` : `Keine eigenen Commits gegenüber ${lage.basis}.`,
370 st ? `Offene Änderungen:\n${st}` : 'Nichts offen.',
371 ].join('\n')
372}
373
374/** Entfernt einen Worktree samt Branch und Server. `auchUngemergt`: Commits gehen verloren (Rückweg wird genannt). */
375async function entferne($: EngineInterface, z: Zustand, lage: Lage, c: Checkout, auchUngemergt: boolean): Promise<string> {
376 if (c.haupt) return `${c.name} ist der Hauptordner, der bleibt.`
377 const s = benutzt(lage, c.pfad)
378 if (s) return `${c.name}: Sitzung „${s.name}“ arbeitet dort, bleibt stehen.`
379 if (c.offen) return `${c.name}: ${c.offen} Dateien offen, bleibt stehen (erst committen oder verwerfen).`
380 if (c.vor > 0 && !auchUngemergt) return `${c.name}: ${c.vor} Commits nicht gemergt, bleibt stehen (/werkbank weg ${c.name} löscht trotzdem).`
381 const server = await stoppe($, z, lage.server.filter(x => x.pfad === c.pfad))
382 const link = await $.fs.stat(`${c.pfad}/node_modules`).catch(() => null)
383 if (link?.isLink) await lauf($, ['/bin/rm', `${c.pfad}/node_modules`])
384 const r = await lauf($, ['git', '-C', lage.repo, 'worktree', 'remove', c.pfad])
385 if (r.exitCode !== 0) return `${c.name}: git worktree remove ging nicht: ${r.stderr.trim().split('\n')[0]}`
386 let zusatz = ''
387 if (c.branch && !istHauptbranch(c.branch)) {
388 const d = await lauf($, ['git', '-C', lage.repo, 'branch', '-D', c.branch])
389 if (d.exitCode === 0) zusatz = c.vor > 0 ? ` Branch gelöscht; zurückholen: git branch ${c.branch} ${c.head.slice(0, 10)}` : ' Branch gelöscht.'
390 }
391 await $.store.delete(`ungemergt:${c.pfad}`)
392 return `${c.name} entfernt${server.length ? ' (Server gestoppt)' : ''}.${zusatz}`
393}
394
395/** Gemergte (und auf Wunsch leere) Worktrees weg; der Rest mit Begründung. */
396async function aufraeumen($: EngineInterface, z: Zustand, lage: Lage, auchLeere: boolean): Promise<string[]> {
397 const aus: string[] = []
398 for (const c of lage.checkouts) {
399 if (c.haupt) continue
400 if (c.gemergt || (auchLeere && c.leer)) aus.push(await entferne($, z, lage, c, false))
401 else if (auchLeere) aus.push(`${c.name}: ${c.vor ? `${c.vor} Commits nicht gemergt` : ''}${c.vor && c.offen ? ', ' : ''}${c.offen ? `${c.offen} Dateien offen` : ''} – bleibt (/werkbank was ${c.name} zeigt, was drin ist)`)
402 }
403 await frischeLage($, z, lage.repo)
404 return aus
405}
406
407// ---------- Das Fenster ----------
408
409function fenster($: EngineInterface, z: Zustand, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
410 const { Box, Text, Button } = $.ui.resolve(e)
411 const lage = z.lage
412 if (!lage) return <Text dimColor>Kein Git-Repo im Blick. Die Werkbank folgt dem Repo, in dem Claude arbeitet.</Text>
413 const melde = (p: Promise<string | string[]>) => void p.then(t => {
414 z.meldung = Array.isArray(t) ? (t.join('\n') || 'Nichts aufzuräumen.') : t
415 $.ui.invalidate('ui.render')
416 })
417 return (
418 <Box flexDirection="column" gap={0}>
419 <Box key="kopf" flexDirection="row" gap={2}>
420 <Text bold>{lage.name}</Text>
421 <Text dimColor>{lage.checkouts[0]?.hinter ? `main ${lage.checkouts[0].hinter} hinter ${lage.basis}` : `main = ${lage.basis}`}</Text>
422 <Button key="auf" dimColor onPress={() => melde(aufraeumen($, z, lage, true))}>Aufräumen</Button>
423 <Button key="stopp" dimColor onPress={() => melde(stoppeCheckout($, z, lage, 'alle'))}>Alle stoppen</Button>
424 </Box>
425 {lage.checkouts.map(c => {
426 const web = lage.server.find(s => s.pfad === c.pfad && s.art === 'web')
427 return (
428 <Box key={c.pfad} flexDirection="row" gap={2}>
429 <Text>{zeile(lage, c)}</Text>
430 {web
431 ? <Button key="oe" variant="primary" onPress={() => melde(oeffne($, `http://localhost:${web.port}/`).then(ok => ok ? `${c.name} geöffnet.` : `http://localhost:${web.port}/`))}>Öffnen</Button>
432 : <Button key="st" onPress={() => melde(starte($, z, lage, c, true))}>Starten</Button>}
433 {web && <Button key="sp" dimColor onPress={() => melde(stoppeCheckout($, z, lage, c))}>Stopp</Button>}
434 {!c.haupt && <Button key="was" dimColor onPress={() => melde(wasDrin($, lage, c))}>Was drin?</Button>}
435 </Box>
436 )
437 })}
438 {z.meldung && <Text key="meldung" dimColor>{z.meldung}</Text>}
439 </Box>
440 )
441}
442
443// ---------- Für Claude ----------
444
445const WERKZEUG_TEXT = [
446 'Die Werkbank des Repos, in dem du arbeitest: jeder Checkout (Hauptordner und git-Worktrees) hat einen festen Port.',
447 'Nutze sie, um Dev-Server zu starten oder zu wechseln, statt `npm run dev` per Bash, neuer launch.json-Einträge oder preview_start mit name.',
448 'aktion: "lage" zeigt Checkouts, Ports, laufende Server und andere Sitzungen; "dev" startet den Server des Checkouts (Branch- oder Ordnername; leer = wo du gerade arbeitest), stoppt die anderen dieses Repos und öffnet die Vorschau;',
449 '"stopp" stoppt ihn ("alle" für alle); "aufraeumen" entfernt gemergte und leere Worktrees samt Branch; "was" zeigt Commits und offene Dateien eines Checkouts.',
450].join(' ')
451
452async function werkzeug($: EngineInterface, z: Zustand, aktion: string, wer?: string): Promise<string> {
453 const lage = await frischeLage($, z)
454 if (!lage) return 'Kein Git-Repo im Blick: erst in einem Repo arbeiten (cd oder git -C).'
455 const c = finde(lage, wer) ?? (wer ? undefined : lage.checkouts.find(x => x.pfad === z.meinCheckout) ?? lage.checkouts[0])
456 if (wer && !c && wer !== 'alle') return `Keinen Checkout „${wer}“ gefunden.\n${lageText(lage)}`
457 if (aktion === 'dev') return starte($, z, lage, c!, true)
458 if (aktion === 'stopp') return stoppeCheckout($, z, lage, wer === 'alle' ? 'alle' : c!)
459 if (aktion === 'aufraeumen') return (await aufraeumen($, z, lage, true)).join('\n') || 'Nichts aufzuräumen.'
460 if (aktion === 'was') return wasDrin($, lage, c!)
461 return lageText(lage)
462}
463
464// ---------- Hooks ----------
465
466/** Merkt, in welchem Checkout Claude gerade arbeitet, auch für die anderen Sitzungen. */
467async function folge($: EngineInterface, z: Zustand, dir: string) {
468 const repo = await repoVon($, z, dir)
469 if (!repo) return
470 if (repo !== z.repo || !z.lage) {
471 z.repo = repo
472 await frischeLage($, z)
473 }
474 const checkout = checkoutVon(dir, z.lage?.checkouts.map(c => c.pfad) ?? []) ?? repo
475 if (checkout === z.meinCheckout) return
476 z.meinCheckout = checkout
477 await $.store.set(`sitzung:${z.sid}`, { repo, checkout, at: await $.clock.now() } satisfies SitzungsEintrag)
478}
479
480
481export const register: Register = on => {
482 const z: Zustand = {
483 home: '', sid: '', repo: null, lage: null, fensterOffen: false, laedt: null, repoCache: new Map(),
484 meinCheckout: null, aufraeumenNachZug: new Set(), status: undefined, meldung: '', vorfahren: null,
485 }
486
487 on('session.start', async ($, e, next) => {
488 const r = await next(e)
489 z.home = (await $.env.get('HOME')) ?? ''
490 z.sid = await $.session.id()
491 await $.command.register({
492 name: 'werkbank',
493 description: 'Checkouts, Ports und Dev-Server des Repos (werkbank)',
494 argumentHint: '[dev [<branch>] | stopp [<branch>|alle] | aufräumen | was <branch> | weg <branch>]',
495 immediate: true,
496 })
497 await $.tool.register({
498 name: 'werkbank',
499 description: WERKZEUG_TEXT,
500 isDeferred: false,
501 inputSchema: {
502 type: 'object',
503 properties: {
504 aktion: { type: 'string', enum: ['lage', 'dev', 'stopp', 'aufraeumen', 'was'] },
505 checkout: { type: 'string', description: 'Branch- oder Ordnername des Checkouts; leer: wo du gerade arbeitest' },
506 },
507 required: ['aktion'],
508 },
509 })
510 z.repo = await repoVon($, z, e.cwd)
511 void frischeLage($, z)
512 $.clock.every(FENSTER_MS, () => {
513 void $.clock.now().then(t => {
514 if (z.repo && (z.fensterOffen || !z.lage || t - z.lage.at >= AUFFRISCHEN_MS)) void frischeLage($, z)
515 })
516 })
517 return r
518 })
519
520 on('tool.call', { tool: 'mcp__werkbank__werkbank' }, async ($, e) => {
521 const i = e as unknown as { aktion?: string; checkout?: string }
522 try {
523 return { result: await werkzeug($, z, i.aktion ?? 'lage', i.checkout || undefined) } as never
524 } catch (err) {
525 return { deny: `werkbank: ${err instanceof Error ? err.message : String(err)}` }
526 }
527 })
528
529 // Wo Claude schreibt, da arbeitet diese Sitzung.
530 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
531 const i = e as unknown as { file_path?: string; notebook_path?: string }
532 const p = i.file_path ?? i.notebook_path
533 if (p && z.home) {
534 try { await folge($, z, p.replace(/\/[^/]*$/, '')) } catch { /* nur Beobachtung */ }
535 }
536 return next(e)
537 })
538
539 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
540 if (e.tool !== 'Bash') return next(e)
541 let ort = ''
542 let hinweis: string | null = null
543 let vorher: Set<string> | null = null
544 try {
545 ort = befehlsOrt(e.command, await $.session.cwd(), z.home)
546 if (/\bgit\b|\bcd\b|\brm\b/.test(e.command)) await folge($, z, ort)
547 const lage = await frischeLage($, z)
548 if (lage) {
549 // Löschschutz: kein Checkout, in dem eine andere Sitzung arbeitet, und nie der Hauptordner.
550 for (const ziel of loeschZiele(e.command, ort, z.home)) {
551 const c = lage.checkouts.find(x => x.pfad === ziel || x.pfad.startsWith(ziel + '/'))
552 if (!c) continue
553 if (c.haupt) return { deny: `werkbank: ${ziel} enthält den Hauptordner von ${lage.name}; das lösche ich nicht.` }
554 const s = benutzt(lage, c.pfad)
555 if (s) return { deny: `werkbank: Im Checkout ${c.name} (${c.pfad}) arbeitet die Sitzung „${s.name}“${s.arbeitet ? ' gerade' : ''}. Nicht löschen; frag Julian.` }
556 }
557 // Merge-Ampel: nach main, während andere Sitzungen im Repo arbeiten.
558 const meins = checkoutVon(ort, lage.checkouts.map(c => c.pfad))
559 const branch = lage.checkouts.find(c => c.pfad === meins)?.branch ?? null
560 if (zielMain(e.command, branch)) {
561 const aktive = lage.sitzungen.filter(s => !s.ich && s.arbeitet)
562 const gleich = aktive.find(s => s.checkout === meins)
563 if (gleich) return { deny: `werkbank: Die Sitzung „${gleich.name}“ arbeitet gerade im selben Checkout (${meins}). Erst abstimmen: frag Julian, ob du jetzt nach main bringen sollst.` }
564 if (aktive.length) {
565 hinweis = `werkbank: Gleichzeitig arbeiten im Repo ${aktive.map(s => `„${s.name}“${s.checkout ? ` (in ${lage.checkouts.find(c => c.pfad === s.checkout)?.name})` : ''}`).join(', ')}. Ihre Änderungen sind davon nicht betroffen; erwähne es Julian, falls sie dieselben Dateien anfassen könnten.`
566 }
567 }
568 if (istWorktreeAdd(e.command)) vorher = new Set(lage.checkouts.map(c => c.pfad))
569 }
570 } catch { /* der Befehl läuft trotzdem */ }
571 const r = await next(e)
572 if (r.deny !== undefined || r.isError || !z.repo) return r
573 const kontext: string[] = hinweis ? [hinweis] : []
574 try {
575 if (vorher || PUSH.test(e.command) || /\bgit\b[^;&|]*\b(merge|worktree|branch|checkout|switch)\b/.test(e.command)) {
576 const lage = await frischeLage($, z)
577 if (lage && vorher) {
578 for (const c of lage.checkouts.filter(x => !vorher!.has(x.pfad))) {
579 kontext.push(`werkbank: Der neue Checkout ${c.name} (${c.pfad}) hat den festen Port ${c.web} (API ${c.api}). Starte seinen Dev-Server mit dem Werkzeug werkbank (aktion "dev", checkout "${c.name}"), nicht per npm run dev oder neuem launch.json-Eintrag.`)
580 }
581 }
582 // Nach einem Push oder Merge: gemergte Worktrees nach diesem Zug wegräumen.
583 if (lage && (PUSH.test(e.command) || /\bgit\b[^;&|]*\bmerge\b/.test(e.command))) z.aufraeumenNachZug.add(lage.repo)
584 }
585 } catch { /* nur Beobachtung */ }
586 return kontext.length ? { ...r, context: [...(r.context ?? []), ...kontext] } : r
587 })
588
589 on('turn.complete', async ($, e, next) => {
590 const r = await next(e)
591 if (e.agentId || !z.aufraeumenNachZug.size) return r
592 const repos = [...z.aufraeumenNachZug]
593 z.aufraeumenNachZug.clear()
594 for (const repo of repos) {
595 // Erst nachsehen, was main jetzt enthält (der Push hat origin/main schon bewegt).
596 const lage = await frischeLage($, z, repo)
597 if (!lage) continue
598 // Auch der Worktree, aus dem diese Sitzung gepusht hat: der Zug ist vorbei.
599 const fertig = lage.checkouts.filter(c => c.gemergt && !benutzt(lage, c.pfad))
600 if (!fertig.length) continue
601 const aus = await aufraeumen($, z, { ...lage, checkouts: [lage.checkouts[0]!, ...fertig] }, false)
602 if (fertig.some(c => c.pfad === z.meinCheckout)) z.meinCheckout = repo
603 const text = `🧹 Werkbank: ${aus.join(' ')}`
604 $.ui.toast(text, { timeoutMs: 8 * SEK })
605 try {
606 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
607 } catch { /* nur Anzeige */ }
608 }
609 return r
610 })
611
612 on('command.run', { command: 'werkbank' }, async ($, e) => {
613 const [was = '', ...rest] = String(e.args ?? '').trim().split(/\s+/)
614 const wer = rest.join(' ') || undefined
615 if (!was) {
616 z.fensterOffen = true
617 await frischeLage($, z)
618 await $.ui.open({ id: PANE, title: 'Werkbank' })
619 return { text: z.lage ? lageText(z.lage) : 'Kein Git-Repo im Blick.' }
620 }
621 const lage = await frischeLage($, z)
622 if (!lage) return { text: 'Kein Git-Repo im Blick: die Werkbank folgt dem Repo, in dem Claude arbeitet, sonst dem Ordner der Sitzung.' }
623 const c = finde(lage, wer)
624 const aktion = was.toLowerCase().replace('ä', 'ae')
625 if (aktion === 'aufraeumen') return { text: (await aufraeumen($, z, lage, true)).join('\n') || 'Nichts aufzuräumen.' }
626 if (aktion === 'stopp') return { text: await stoppeCheckout($, z, lage, wer === 'alle' || !wer ? 'alle' : c ?? 'alle') }
627 if (wer && !c) return { text: `Keinen Checkout „${wer}“ gefunden.\n\n${lageText(lage)}` }
628 // Ohne Angabe startet dev den zuletzt geänderten Checkout: meist der, an dem gerade gearbeitet wird.
629 if (aktion === 'dev' && !c) {
630 const neu = zuletztGeaendert(lage.checkouts)!
631 const wann = neu.geaendert ? ` (zuletzt geändert vor ${fmtAlter(Math.max(0, lage.at - neu.geaendert))})` : ''
632 return { text: `${neu.name}${wann}: ${await starte($, z, lage, neu, true)}` }
633 }
634 const ziel = c ?? lage.checkouts.find(x => x.pfad === z.meinCheckout) ?? lage.checkouts[0]!
635 if (aktion === 'dev') return { text: await starte($, z, lage, ziel, true) }
636 if (aktion === 'was') return { text: await wasDrin($, lage, ziel) }
637 if (aktion === 'weg') return { text: c ? await entferne($, z, lage, c, true) : 'Welchen? /werkbank weg <branch>' }
638 return { text: `„${was}“ kenne ich nicht.\n\n${lageText(lage)}\n\n/werkbank [dev <branch> | stopp [<branch>|alle] | aufräumen | was <branch> | weg <branch>]` }
639 })
640
641 on('ui.close', { id: PANE }, async ($, e, next) => {
642 z.fensterOffen = false
643 z.meldung = ''
644 return next(e)
645 })
646
647 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => fenster($, z, e))
648}
649hooks/rein.ts 207 lines1// Reine Hilfen der Werkbank: Ausgaben von git, lsof und ps lesen, Befehle
2// deuten, Ports vergeben. Kein $ hier, damit alles ohne Host testbar bleibt.
3
4export const WEB_BASIS = 5173
5// main behält 8787 (so steht es in den Projekten); Worktrees ab 8801, weil 8788
6// (Uploader) und 8790 (Kurzlink) lokal schon vergeben sind.
7export const API_HAUPT = 8787
8export const API_BASIS = 8800
9const HAUPTBRANCHES = new Set(['main', 'master'])
10
11export type Worktree = { pfad: string; head: string; branch: string | null }
12export type Lauscher = { pid: number; cmd: string; port: number }
13
14/** `git worktree list --porcelain`: der erste Eintrag ist der Hauptordner. */
15export function leseWorktrees(ausgabe: string): Worktree[] {
16 const aus: Worktree[] = []
17 for (const block of ausgabe.split(/\n\s*\n/)) {
18 const zeilen = block.split('\n')
19 const pfad = zeilen.find(z => z.startsWith('worktree '))?.slice(9)
20 if (!pfad || zeilen.includes('bare')) continue
21 const head = zeilen.find(z => z.startsWith('HEAD '))?.slice(5) ?? ''
22 const ref = zeilen.find(z => z.startsWith('branch '))?.slice(7)
23 aus.push({ pfad, head, branch: ref ? ref.replace(/^refs\/heads\//, '') : null })
24 }
25 return aus
26}
27
28/** `lsof -nP -iTCP -sTCP:LISTEN -Fpcn`: je Prozess und Port ein Eintrag. */
29export function leseLauscher(ausgabe: string): Lauscher[] {
30 const aus: Lauscher[] = []
31 const gesehen = new Set<string>()
32 let pid = 0
33 let cmd = ''
34 for (const zeile of ausgabe.split('\n')) {
35 const art = zeile[0]
36 const wert = zeile.slice(1)
37 if (art === 'p') pid = Number(wert)
38 else if (art === 'c') cmd = wert
39 else if (art === 'n') {
40 const port = Number(/:(\d+)$/.exec(wert)?.[1])
41 const key = `${pid}:${port}`
42 if (pid && port && !gesehen.has(key)) {
43 gesehen.add(key)
44 aus.push({ pid, cmd, port })
45 }
46 }
47 }
48 return aus
49}
50
51// Claude-Code-Sitzungen lauschen selbst auf einem lokalen Port und haben den
52// Projektordner als cwd: sie sind kein Server und dürfen nie gestoppt werden
53// (10.10.: /werkbank dev hat so die eigene Sitzung beendet).
54const KEIN_SERVER = /claude/i
55// Gestoppt wird nur, was nach Dev-Server aussieht (lsof kürzt den Namen auf 15 Zeichen).
56const DEV_SERVER = /^(node|bun|deno|workerd|wrangler|vite|esbuild|npm|pnpm|yarn|python[\d.]*|ruby|php|java|hugo|astro|next-server.*)$/i
57
58export function istServer(cmd: string): boolean {
59 return !KEIN_SERVER.test(cmd)
60}
61
62/** Ob ein Lauscher gestoppt werden darf: auf einem festen Platz-Port oder ein bekannter Dev-Server. */
63export function stoppbar(s: { cmd: string; art: string }): boolean {
64 return istServer(s.cmd) && (s.art !== 'anderer' || DEV_SERVER.test(s.cmd))
65}
66
67/** `lsof -a -d cwd -p … -Fpn`: Prozess → Arbeitsordner. */
68export function leseCwds(ausgabe: string): Map<number, string> {
69 const m = new Map<number, string>()
70 let pid = 0
71 for (const zeile of ausgabe.split('\n')) {
72 if (zeile[0] === 'p') pid = Number(zeile.slice(1))
73 else if (zeile[0] === 'n' && pid) m.set(pid, zeile.slice(1))
74 }
75 return m
76}
77
78/** Die Pfade aus `git status --porcelain` (bei Umbenennung der neue), ohne den node_modules-Link. */
79export function offenePfade(ausgabe: string): string[] {
80 return ausgabe.split('\n')
81 .filter(z => z.trim() && !/^\?\? node_modules\/?$/.test(z))
82 .map(z => z.slice(3).split(' -> ').pop()!.replace(/^"(.*)"$/, '$1'))
83}
84
85/** Der Checkout mit der jüngsten Änderung (Commit oder offene Datei); bei Gleichstand der erste. */
86export function zuletztGeaendert<T extends { geaendert: number }>(checkouts: readonly T[]): T | undefined {
87 let best: T | undefined
88 for (const c of checkouts) if (!best || c.geaendert > best.geaendert) best = c
89 return best
90}
91
92/** `git status --porcelain`: geänderte Dateien, ohne den eigenen node_modules-Link. */
93export function zaehleOffen(ausgabe: string): number {
94 return ausgabe.split('\n').filter(z => z.trim() && !/^\?\? node_modules\/?$/.test(z)).length
95}
96
97export function normalisiere(p: string): string {
98 const teile: string[] = []
99 for (const t of p.split('/')) {
100 if (!t || t === '.') continue
101 if (t === '..') teile.pop()
102 else teile.push(t)
103 }
104 return '/' + teile.join('/')
105}
106
107export function absolut(p: string, cwd: string, home: string): string {
108 const q = p.replace(/^(['"])(.*)\1$/, '$2')
109 if (q === '~' || q.startsWith('~/')) return normalisiere(home + q.slice(1))
110 return normalisiere(q.startsWith('/') ? q : `${cwd}/${q}`)
111}
112
113/** Der Checkout, in dem ein Pfad liegt (längster Treffer), oder undefined. */
114export function checkoutVon(pfad: string, checkouts: readonly string[]): string | undefined {
115 let best: string | undefined
116 for (const c of checkouts) {
117 if ((pfad === c || pfad.startsWith(c + '/')) && (!best || c.length > best.length)) best = c
118 }
119 return best
120}
121
122const GIT = String.raw`git\s+(?:-[Cc]\s+("[^"]+"|'[^']+'|\S+)\s+)*`
123
124/** Wo ein Befehl wirkt: git -C <pfad>, sonst das letzte cd, sonst cwd. */
125export function befehlsOrt(befehl: string, cwd: string, home: string): string {
126 const c = new RegExp(`(?:^|[\\s;&|(])git\\s+-C\\s+("[^"]+"|'[^']+'|\\S+)`).exec(befehl)
127 if (c?.[1]) return absolut(c[1], cwd, home)
128 let ort = cwd
129 for (const cd of befehl.matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) ort = absolut(cd[1]!, ort, home)
130 return ort
131}
132
133export const PUSH = new RegExp(`(^|[\\s;&|(])${GIT}push\\b([^;&|]*)`)
134const MERGE = new RegExp(`(^|[\\s;&|(])${GIT}merge\\b`)
135const WT_ADD = new RegExp(`(^|[\\s;&|(])${GIT}worktree\\s+add\\b`)
136
137/** Geht der Befehl nach main? Push mit main im Ziel, oder ohne Ziel vom Branch main aus. */
138export function zielMain(befehl: string, branchAmOrt: string | null): boolean {
139 const p = PUSH.exec(befehl)
140 if (p) {
141 const rest = (p[3] ?? '').replace(/\s-{1,2}[\w-]+(=\S+)?/g, ' ').trim().split(/\s+/).filter(Boolean)
142 const refspecs = rest.slice(1)
143 if (!refspecs.length) return !!branchAmOrt && HAUPTBRANCHES.has(branchAmOrt)
144 return refspecs.some(r => HAUPTBRANCHES.has(r.split(':').pop()!.replace(/^refs\/heads\//, '')))
145 }
146 return MERGE.test(befehl) && !!branchAmOrt && HAUPTBRANCHES.has(branchAmOrt)
147}
148
149export function istWorktreeAdd(befehl: string): boolean {
150 return WT_ADD.test(befehl)
151}
152
153/** Was ein Befehl löschen will: git worktree remove <pfad>, rm -r… <pfade>. */
154export function loeschZiele(befehl: string, ort: string, home: string): string[] {
155 const aus: string[] = []
156 for (const m of befehl.matchAll(new RegExp(`(?:^|[\\s;&|(])${GIT}worktree\\s+remove\\s+((?:-{1,2}\\S+\\s+)*)("[^"]+"|'[^']+'|[^\\s;&|)]+)`, 'g'))) {
157 aus.push(absolut(m[3]!, m[1] ? absolut(m[1], ort, home) : ort, home))
158 }
159 for (const m of befehl.matchAll(/(?:^|[\s;&|(])rm\s+((?:-\w+\s+)*)([^;&|]+)/g)) {
160 if (!/r/i.test(m[1] ?? '')) continue
161 for (const p of m[2]!.trim().split(/\s+/)) if (p && !p.startsWith('-')) aus.push(absolut(p, ort, home))
162 }
163 return aus
164}
165
166/** Fester Platz je Checkout: der Hauptordner 0, Worktrees der kleinste freie ab 1. */
167export function naechsterPlatz(belegt: Iterable<number>): number {
168 const s = new Set(belegt)
169 let n = 1
170 while (s.has(n)) n++
171 return n
172}
173
174export function portsFuer(platz: number): { web: number; api: number } {
175 return { web: WEB_BASIS + platz, api: platz === 0 ? API_HAUPT : API_BASIS + platz }
176}
177
178export function istHauptbranch(b: string | null): boolean {
179 return !!b && HAUPTBRANCHES.has(b)
180}
181
182/** Shell-sicher in einfache Anführungszeichen. */
183export function q(s: string): string {
184 return `'${s.replace(/'/g, `'\\''`)}'`
185}
186
187/** Wie der Dev-Server startet: vite mit festem Port, sonst PORT. */
188export function startBefehl(scripts: Record<string, string>, ports: { web: number; api: number }): { web?: string; api?: string } {
189 const env = `API_PORT=${ports.api}`
190 const dev = scripts.dev
191 return {
192 web: dev
193 ? /\bvite\b/.test(dev)
194 ? `${env} npm run dev -- --port ${ports.web} --strictPort`
195 : `${env} PORT=${ports.web} npm run dev`
196 : undefined,
197 api: scripts['dev:api'] ? `${env} npm run dev:api` : undefined,
198 }
199}
200
201export function fmtAlter(ms: number): string {
202 const min = Math.round(ms / 60000)
203 if (min < 60) return `${min} min`
204 const std = Math.round(min / 60)
205 return std < 48 ? `${std} h` : `${Math.round(std / 24)} Tagen`
206}
207