git-pasek/hooks/register.js
Kod moda: pasek nad promptem, kliknięcie z listą plików, pytanie przed git push na main, powiadomienie po turze dłuższej niż 30 s. Pytanie przed pushem to dopasowanie tekstu, nie pełna blokada: twardą daje ochrona gałęzi na GitHubie.
// git-pasek: nad polem promptu gałąź Git i liczba zmienionych plików,
// a przed `git push` na main pytanie „Wypchnąć?”.
// Od Claude Code 2.1.295: kliknięcie paska pokazuje zmienione pliki (Button z dziećmi),
// a po długiej turze przychodzi powiadomienie systemowe ($.ui.notify).
// Przykład z filmu YT-046 (Claude Code Mods od podstaw). W filmie ten kod pisze Claude.
// Stan paska, wspólny dla hooków niżej
let galaz = ''
let zmienione = 0
let pliki = []
// Od ilu sekund tury warto zawołać Cię powiadomieniem
const DLUGA_TURA_MS = 30_000
export function register(on) {
// Start sesji i każde przeładowanie moda: pierwszy odczyt
on('session.start', async ($, e, next) => {
const wynik = await next(e)
await odswiez($)
return wynik
})
// Po każdej turze Claude pliki mogły się zmienić
// a po długiej turze powiadomienie: możesz odejść od komputera
on('turn.complete', async ($, e, next) => {
const wynik = await next(e)
if (e.agentId) return wynik
await odswiez($)
if (!e.isAborted && e.durationMs >= DLUGA_TURA_MS && galaz) {
const tekst = `Gotowe po ${Math.round(e.durationMs / 1000)} s · ${galaz} · zmienione: ${zmienione}`
try {
const wyslane = await $.ui.notify(tekst, { title: 'git-pasek' })
// Powiadomienia wyłączone albo terminal ich nie ma: zostaje dymek w terminalu
if (!wyslane.isSent) $.ui.toast(tekst)
} catch {
$.ui.toast(tekst)
}
}
return wynik
})
// Pasek nad polem promptu
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (!galaz) return next(e)
const { Box, Button, Text } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
paddingX: 1,
children: [
// Cały wiersz to jeden przycisk; key stały, bo tekst (liczba) się zmienia
Button({
key: 'git-pasek',
plain: true,
onPress: () => $.ui.toast(listaPlikow()),
children: [
Text({ color: 'cyan', bold: true, children: '⎇ ' + galaz }),
Text({ dimColor: true, children: ' · zmienione pliki: ' }),
Text({ color: zmienione > 0 ? 'yellow' : 'green', bold: true, children: String(zmienione) }),
],
}),
],
})
})
// git push na main: najpierw pytanie. Gałąź czytamy tuż przed pushem, bo Claude mógł
// ją zmienić w tej samej turze, a push na main da się zrobić też z innej gałęzi.
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!/\bgit\s+push\b/.test(e.command)) return next(e)
await odswiez($)
// To dopasowanie tekstu, nie pełna ochrona: alias gita albo push.default w konfiguracji je ominą.
// Twardą blokadę daje reguła ochrony gałęzi po stronie serwera (GitHub, GitLab).
const naMain = galaz === 'main' || /\bgit\s+push\b[^;&|]*(?:[\s:/]main\b|\s--(?:all|mirror)\b)/.test(e.command)
if (!naMain) return next(e)
let odp = 'Nie'
try {
odp = await $.ui.ask('Wypchnąć main? ' + e.command,
['Tak, wypchnij', 'Nie'])
} catch {
// Pytanie zamknięte albo nikt nie odpowie (claude -p)
}
if (odp !== 'Tak, wypchnij') {
return { deny: 'Użytkownik odmówił: bez push na main.' }
}
return next(e)
}).catch(async ($, e, next) => {
// Hook padł przed decyzją: lepiej nie wypychać
if (next.called) return next(e)
return { deny: 'Strażnik git push nie zadziałał, push wstrzymany.' }
})
}
async function odswiez($) {
const b = await $.process.run(['git', 'branch', '--show-current'])
if (b.exitCode !== 0) {
galaz = ''
$.ui.invalidate('ui.render')
return
}
galaz = b.stdout.trim() || '(odłączony HEAD)'
const s = await $.process.run(['git', 'status', '--porcelain'])
pliki = s.stdout.split('\n').filter((l) => l.trim()).map((l) => l.slice(3))
zmienione = pliki.length
$.ui.invalidate('ui.render')
}
function listaPlikow() {
if (pliki.length === 0) return 'Brak zmian'
const reszta = pliki.length > 5 ? ` i ${pliki.length - 5} więcej` : ''
return 'Zmienione: ' + pliki.slice(0, 5).join(', ') + reszta
}
git-pasek/tests/register.test.ts
10 testów moda, bez sieci i logowania: cd git-pasek && claude plugin test.
import { describe, expect, test } from 'claude-code/testing'
// Udawany git: gałąź main, dwa zmienione pliki
function udawanyGit(on: any) {
on('process.run', ($: any, e: any) => {
const polecenie = JSON.stringify(e)
if (polecenie.includes('--show-current')) return { value: { stdout: 'main\n', stderr: '', exitCode: 0 } }
return { value: { stdout: ' M app.py\n?? notatki.md\n', stderr: '', exitCode: 0 } }
})
}
describe('register', () => {
test('git push na main bez odpowiedzi nie idzie', async ($, on) => {
udawanyGit(on)
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'wypchnięte' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin main' } as any)
expect(r.deny).toContain('odmówił')
})
test('inne polecenia przechodzą bez pytania', async ($, on) => {
udawanyGit(on)
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'ok' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const r: any = await $.tool.call({ tool: 'Bash', command: 'ls' } as any)
expect(r.result).toBe('ok')
})
test('pasek pokazuje gałąź i liczbę zmienionych plików', async ($, on) => {
udawanyGit(on)
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const ui = await $.ui.mount({
plugin: 'git-pasek', surface: 'terminal', component: 'AbovePrompt',
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
} as any)
expect(await ui.find({ type: 'Text', text: /main/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /^2$/ })).toBeDefined()
await ui.unmount()
})
})
// Od Claude Code 2.1.295: przycisk z dziećmi i powiadomienie systemowe
describe('push na main: gałąź sprawdzana tuż przed pushem', () => {
test('przełączenie na main w tej samej turze: i tak pyta', async ($, on) => {
let galaz = 'feature'
on('process.run', ($: any, e: any) => {
const polecenie = JSON.stringify(e)
if (polecenie.includes('--show-current')) return { value: { stdout: galaz + '\n', stderr: '', exitCode: 0 } }
return { value: { stdout: '', stderr: '', exitCode: 0 } }
})
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'wypchnięte' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
galaz = 'main'
const r: any = await $.tool.call({ tool: 'Bash', command: 'git push' } as any)
expect(r.deny).toContain('odmówił')
})
test('push na main z innej gałęzi: też pyta', async ($, on) => {
on('process.run', ($: any, e: any) => {
const polecenie = JSON.stringify(e)
if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
return { value: { stdout: '', stderr: '', exitCode: 0 } }
})
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'wypchnięte' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin HEAD:main' } as any)
expect(r.deny).toContain('odmówił')
})
test('push --all z innej gałęzi: też pyta', async ($, on) => {
on('process.run', ($: any, e: any) => {
const polecenie = JSON.stringify(e)
if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
return { value: { stdout: '', stderr: '', exitCode: 0 } }
})
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'wypchnięte' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const r: any = await $.tool.call({ tool: 'Bash', command: 'git push --all origin' } as any)
expect(r.deny).toContain('odmówił')
})
test('pełny refspec refs/heads/main: też pyta', async ($, on) => {
on('process.run', ($: any, e: any) => {
const polecenie = JSON.stringify(e)
if (polecenie.includes('--show-current')) return { value: { stdout: 'feature\n', stderr: '', exitCode: 0 } }
return { value: { stdout: '', stderr: '', exitCode: 0 } }
})
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('tool.call', () => ({ result: 'wypchnięte' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const r: any = await $.tool.call({ tool: 'Bash', command: 'git push origin HEAD:refs/heads/main' } as any)
expect(r.deny).toContain('odmówił')
})
})
describe('nowości 2.1.295', () => {
test('kliknięcie paska pokazuje zmienione pliki', async ($, on) => {
udawanyGit(on)
const dymki: string[] = []
on('ui.toast', ($: any, e: any) => { dymki.push(e.text); return { value: undefined } })
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
const ui = await $.ui.mount({
plugin: 'git-pasek', surface: 'terminal', component: 'AbovePrompt',
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
} as any)
await ui.press({ key: 'git-pasek' } as any)
expect(dymki.join(' ')).toContain('app.py')
expect(dymki.join(' ')).toContain('notatki.md')
await ui.unmount()
})
test('długa tura: powiadomienie z gałęzią i liczbą zmian', async ($, on) => {
udawanyGit(on)
const powiadomienia: any[] = []
on('ui.notify', ($: any, e: any) => { powiadomienia.push(e); return { value: { isSent: true, channel: 'iterm2' } } })
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('turn.complete', () => ({ text: '' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
await $.turn.complete({ answer: 'ok', durationMs: 45_000, isAborted: false, turnId: 't1', reason: 'answer' } as any)
expect(powiadomienia.length).toBe(1)
expect(powiadomienia[0].text).toContain('main')
expect(powiadomienia[0].text).toContain('zmienione: 2')
})
test('krótka tura: bez powiadomienia', async ($, on) => {
udawanyGit(on)
const powiadomienia: any[] = []
on('ui.notify', ($: any, e: any) => { powiadomienia.push(e); return { value: { isSent: true, channel: 'iterm2' } } })
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
on('turn.complete', () => ({ text: '' }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
await $.turn.complete({ answer: 'ok', durationMs: 5_000, isAborted: false, turnId: 't2', reason: 'answer' } as any)
expect(powiadomienia.length).toBe(0)
})
})