SLOPSHOPPER

git-guard-th

ยามเฝ้า Git: ถามก่อนทิ้งงานที่ยังไม่ commit (เสนอ stash ให้), กัน force push, สำรองไฟล์ก่อน rm -rf, ตรวจ Conventional Commits และสลับ branch อย่างปลอดภัย

newguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-guard-th
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gitguard ⎿ git-guard-th: 🛡️ Git Guard ✅ เฝ้าอยู่ ⎿ git-guard-th: ถามก่อนคำสั่ง git อันตราย: เปิด ⎿ git-guard-th: สำรองก่อน rm: เปิด (session นี้สำรองไป 0 รายการ) ⎿ git-guard-th: Conventional Commits: ปิด ⎿ git-guard-th: ⎿ git-guard-th: 📦 ยังไม่มี stash จาก Git Guard ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 53: Git Guard TH

ยามเฝ้า Git และไฟล์ในโปรเจกต์: ก่อน Claude รัน git reset --hard / checkout . / restore / clean -f ตอนมีงานค้าง จะถามพร้อมทางเลือก "เก็บเข้า stash ก่อน", git push --force เสนอเปลี่ยนเป็น --force-with-lease, branch -D และ stash drop ต้องยืนยัน, rm ไฟล์ในโปรเจกต์จะสำรองไว้ใน .claude-backup/ ก่อน (ข้าม node_modules, dist), ตรวจ Conventional Commits ได้ และสลับ branch อย่างปลอดภัย

คำสั่ง

คำสั่งทำอะไร
/gitguardสถานะ + stash ที่ Git Guard เก็บให้
/gitguard pause <นาที>พักการเฝ้าชั่วคราว
`/gitguard guard on\off`ถามก่อนคำสั่ง git อันตราย (ค่าเริ่มต้น: เปิด)
`/gitguard backup on\off`สำรองก่อน rm (ค่าเริ่มต้น: เปิด)
`/gitguard convention on\off`บังคับ Conventional Commits (ค่าเริ่มต้น: ปิด)
/gswitch <branch> [new]สลับ branch โดยถามเรื่องงานค้างก่อน

ติดตั้ง

/plugin install git-guard-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. แยกแยะคำสั่ง git อันตราย และอ่านข้อความ commit (รวม heredoc)
  2. เรียก git / คัดลอกไฟล์ผ่าน $.process.run
  3. ถามผ่าน $.ui.ask: ไม่มีคนตอบ = ไม่อนุญาต
  4. ด่านตรวจหน้า Bash
  5. /gswitch

หมายเหตุ

ถ้าไม่มีใครตอบได้ (เช่น claude -p) คำสั่งที่ทำให้งานหายจะถูกปฏิเสธไว้ก่อน โฟลเดอร์ .claude-backup/ มี .gitignore ของตัวเองจึงไม่ถูก commit Mod 15 บล็อกคำสั่งทำลายระบบ ส่วน Mod นี้ดูแลงานในโปรเจกต์

Source 1 files
hooks/register.tsx 289 lines
1/**
2 * ============================================================================
3 * MOD 53: Git Guard TH - ยามเฝ้า Git และไฟล์ของคุณ (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: ประโยคคลาสสิก "เดี๋ยวนะ... งานที่ยังไม่ commit หายไปไหน"
6 *
7 * หน้าที่ของ Mod นี้ (ดักคำสั่ง Bash ที่ Claude จะรัน):
8 * 1. git reset --hard / checkout . / restore / clean -f ตอนมีงานค้าง
9 *    → ถามคุณก่อน พร้อมทางเลือก "เก็บเข้า stash ก่อน" (กดทีเดียวไม่มีอะไรหาย)
10 * 2. git push --force → เสนอเปลี่ยนเป็น --force-with-lease ที่ไม่ทับงานคนอื่น
11 * 3. git branch -D / stash drop / stash clear → ถามยืนยัน
12 * 4. rm -r / rm ไฟล์ในโปรเจกต์ → สำรองไว้ที่ .claude-backup/ ก่อนลบ (ข้ามพวก node_modules)
13 * 5. (เปิดเองได้) ตรวจข้อความ commit ตาม Conventional Commits ไม่ผ่านให้ Claude เขียนใหม่
14 * 6. /gswitch <branch> - สลับ branch โดยถามก่อนว่าจะทำอะไรกับงานที่ค้าง
15 *
16 * ถ้าไม่มีใครตอบได้ (เช่นรันแบบ claude -p) จะปฏิเสธคำสั่งไว้ก่อนเพื่อความปลอดภัย
17 * Mod 15 Cyber Guard บล็อกคำสั่งทำลายระบบ ส่วน Mod นี้ดูแลงานในโปรเจกต์
18 */
19
20import type { EngineInterface, Register } from 'claude-code'
21
22interface Settings {
23  guard: boolean
24  backup: boolean
25  convention: boolean
26}
27
28const REGENERABLE = /(^|[\\/])(node_modules|dist|build|out|\.next|\.nuxt|target|coverage|\.cache|__pycache__|\.venv|venv|\.turbo|\.parcel-cache|tmp|temp)([\\/]|$)/i
29const CONVENTIONAL = /^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([^)]+\))?!?: \S/
30const BACKUP_DIR = '.claude-backup'
31
32let settings: Settings = { guard: true, backup: true, convention: false }
33let pausedUntil = 0
34let isWindows = false
35let backups = 0
36
37// ==========================================================================
38// บทที่ 1: แยกแยะคำสั่ง - คำสั่งยาวที่ต่อด้วย && ; || ให้ดูทีละท่อน
39// ==========================================================================
40type Risk = { kind: 'discard'; what: string } | { kind: 'force'; what: string } | { kind: 'confirm'; what: string }
41
42function segments(command: string): string[] {
43  return command.split(/&&|\|\||;|\n/).map(s => s.trim()).filter(Boolean)
44}
45
46function riskOf(seg: string): Risk | undefined {
47  if (!/^\s*git\b/.test(seg)) return undefined
48  if (/\breset\b.*--hard\b/.test(seg)) return { kind: 'discard', what: 'git reset --hard ทิ้งการแก้ไขที่ยังไม่ commit ทั้งหมด' }
49  if (/\bcheckout\s+(?:\S+\s+)?--\s+\S|\bcheckout\s+\.(?:\s|$)/.test(seg)) return { kind: 'discard', what: 'git checkout ทับไฟล์ด้วยเวอร์ชันล่าสุด การแก้ไขที่ยังไม่ commit จะหาย' }
50  if (/\brestore\b/.test(seg) && !(/--staged\b/.test(seg) && !/--worktree\b/.test(seg))) return { kind: 'discard', what: 'git restore ทิ้งการแก้ไขในไฟล์' }
51  if (/\bclean\s+-\w*f/.test(seg)) return { kind: 'discard', what: 'git clean ลบไฟล์ที่ยังไม่ได้ track (ไฟล์ใหม่ทั้งหมด)' }
52  if (/\bpush\b.*(?:--force(?!-with-lease)\b|\s-f\b|\s\+\S)/.test(seg)) return { kind: 'force', what: 'git push --force ทับประวัติบน remote (งานของคนอื่นอาจหาย)' }
53  if (/\bbranch\s+(?:.*\s)?-D\b/.test(seg)) return { kind: 'confirm', what: 'git branch -D ลบ branch แม้ยังไม่ได้ merge' }
54  if (/\bstash\s+(?:drop|clear)\b/.test(seg)) return { kind: 'confirm', what: 'ลบงานที่เก็บไว้ใน stash ถาวร' }
55  return undefined
56}
57
58// ข้อความ commit: รองรับ -m "..." / -am '...' / heredoc แบบที่ Claude Code ชอบใช้
59// ดูทั้งคำสั่ง (ไม่แยกบรรทัด) เพราะ heredoc กินหลายบรรทัด
60function commitMessage(command: string): string | undefined {
61  if (!/(?:^|&&|;|\|\|)\s*git\b[^\n]*\bcommit\b/.test(command)) return undefined
62  const heredoc = /<<\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1/.exec(command)
63  if (heredoc?.[2]) return heredoc[2].split('\n').find(l => l.trim())?.trim()
64  const m = /(?:\s-[a-zA-Z]*m|\s--message)(?:\s+|=)(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/.exec(command)
65  return (m?.[1] ?? m?.[2] ?? m?.[3])?.split('\n')[0]?.trim()
66}
67
68// rm: เก็บ path ที่จะโดนลบ (เฉพาะแบบตรงๆ ไม่มี * หรือตัวแปร)
69function rmTargets(seg: string): string[] {
70  const m = /^\s*(?:sudo\s+)?rm\s+(.+)$/.exec(seg)
71  if (!m?.[1]) return []
72  const parts = m[1].match(/"[^"]*"|'[^']*'|\S+/g) ?? []
73  return parts
74    .filter(p => !p.startsWith('-'))
75    .map(p => p.replace(/^["']|["']$/g, ''))
76    .filter(p => p && !/[*?$`]/.test(p))
77}
78
79// ==========================================================================
80// บทที่ 2: เรียก git / สำเนาไฟล์ ผ่าน $.process.run (path ส่งทาง env กันคำสั่งแทรก)
81// ==========================================================================
82async function git($: EngineInterface, args: string[]): Promise<{ ok: boolean; out: string }> {
83  const r = await $.process.run(['git', ...args], { timeoutMs: 30_000 }).catch(() => undefined)
84  return { ok: r?.exitCode === 0, out: `${r?.stdout ?? ''}${r?.stderr ?? ''}`.trim() }
85}
86
87async function dirtyCount($: EngineInterface, untrackedOnly = false): Promise<number> {
88  const r = await git($, ['status', '--porcelain'])
89  if (!r.ok) return 0
90  return r.out.split('\n').filter(l => l && (!untrackedOnly || l.startsWith('??'))).length
91}
92
93async function stash($: EngineInterface, now: number): Promise<boolean> {
94  const label = `git-guard ${new Date(now).toISOString().slice(0, 16).replace('T', ' ')}`
95  return (await git($, ['stash', 'push', '--include-untracked', '-m', label])).ok
96}
97
98function join(root: string, rel: string): string {
99  if (/^(?:[A-Za-z]:[\\/]|\/)/.test(rel)) return rel
100  return `${root}${isWindows ? '\\' : '/'}${rel.replace(/^\.[\\/]/, '')}`
101}
102
103async function backup($: EngineInterface, targets: string[], now: number): Promise<string[]> {
104  const root = (await $.session.cwd()).replace(/[\\/]$/, '')
105  const stamp = new Date(now).toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')
106  const dest = join(root, `${BACKUP_DIR}${isWindows ? '\\' : '/'}${stamp}`)
107  const saved: string[] = []
108  for (const t of targets) {
109    const src = join(root, t)
110    // นอกโปรเจกต์ หรือของที่สร้างใหม่ได้ (node_modules, dist ...) ไม่ต้องสำรอง
111    if (REGENERABLE.test(t) || !src.toLowerCase().startsWith(root.toLowerCase()) || !(await $.fs.exists(src))) continue
112    const argv = isWindows
113      ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', 'New-Item -ItemType Directory -Force -Path $env:GG_DEST | Out-Null; Copy-Item -LiteralPath $env:GG_SRC -Destination $env:GG_DEST -Recurse -Force']
114      : ['sh', '-c', 'mkdir -p "$GG_DEST" && cp -R "$GG_SRC" "$GG_DEST/"']
115    const r = await $.process.run(argv, { env: { GG_SRC: src, GG_DEST: dest }, timeoutMs: 60_000 }).catch(() => undefined)
116    if (r?.exitCode === 0) saved.push(t)
117  }
118  if (saved.length) {
119    // ให้ git มองข้ามโฟลเดอร์สำรองเอง โดยไม่ต้องแก้ .gitignore ของโปรเจกต์
120    await $.fs.write(join(root, `${BACKUP_DIR}${isWindows ? '\\' : '/'}.gitignore`), '*\n').catch(() => undefined)
121    backups += saved.length
122  }
123  return saved
124}
125
126// ==========================================================================
127// บทที่ 3: ถามคุณผ่านหน้าต่างของ Claude Code - ตอบไม่ได้ (ปิดหน้าต่าง/-p) = ไม่อนุญาต
128// ==========================================================================
129async function ask($: EngineInterface, question: string, options: string[]): Promise<string | undefined> {
130  try {
131    return await $.ui.ask(question, { header: 'Git Guard', options })
132  } catch {
133    return undefined
134  }
135}
136
137const DENY_NO_ANSWER = 'Git Guard: คำสั่งนี้ทำให้งานหายได้ และไม่มีใครยืนยัน จึงยังไม่รัน ถ้าผู้ใช้ต้องการจริง ให้ผู้ใช้สั่ง /gitguard pause 5 แล้วลองใหม่'
138
139const HELP = `🛡️ Git Guard TH (Mod 53)
140========================================
141/gitguard                   สถานะ + stash/สำรองที่ Git Guard สร้าง
142/gitguard pause <นาที>      พักการเฝ้าชั่วคราว
143/gitguard guard on|off      ถามก่อนคำสั่ง git อันตราย (ตอนนี้: GUARD)
144/gitguard backup on|off     สำรองก่อน rm (ตอนนี้: BACKUP)
145/gitguard convention on|off ตรวจ Conventional Commits (ตอนนี้: CONV)
146/gswitch <branch> [new]     สลับ branch อย่างปลอดภัย`
147
148function onOff(v: boolean): string {
149  return v ? 'เปิด' : 'ปิด'
150}
151
152export const register: Register = on => {
153  on('session.start', async ($, e, next) => {
154    isWindows = /^[A-Za-z]:/.test(e.cwd)
155    settings = { ...settings, ...((await $.store.get('settings')) as Partial<Settings> | undefined) }
156    await $.command.register({ name: 'gitguard', description: 'ยามเฝ้า Git: ถามก่อนทิ้งงาน กัน force push สำรองก่อน rm (/gitguard help)', argumentHint: 'pause <นาที>|guard|backup|convention on|off' })
157    await $.command.register({ name: 'gswitch', description: 'สลับ branch อย่างปลอดภัย ถามก่อนว่าจะทำอะไรกับงานที่ค้าง', argumentHint: '<branch> [new]' })
158    return next(e)
159  })
160
161  // ==========================================================================
162  // บทที่ 4: ด่านตรวจหน้า Bash
163  // ==========================================================================
164  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
165    const now = await $.clock.now()
166    if (now < pausedUntil) return next(e)
167    let command = e.command
168    const segs = segments(command)
169
170    // 4.1 ข้อความ commit
171    if (settings.convention) {
172      const msg = commitMessage(command)
173      if (msg !== undefined && !CONVENTIONAL.test(msg) && !/^(?:Merge|Revert) /.test(msg)) {
174        return { deny: `Git Guard: ข้อความ commit "${msg}" ไม่ตามรูปแบบ Conventional Commits ของโปรเจกต์นี้ ให้เขียนใหม่เป็น <type>(<scope>): <สรุป> โดย type คือ feat fix docs style refactor perf test build ci chore revert เช่น "fix(auth): handle expired token" แล้ว commit ใหม่` }
175      }
176    }
177
178    // 4.2 คำสั่ง git อันตราย
179    if (settings.guard) {
180      for (const seg of segs) {
181        const risk = riskOf(seg)
182        if (!risk) continue
183        if (risk.kind === 'discard') {
184          const n = await dirtyCount($, /\bclean\b/.test(seg))
185          if (n === 0) continue // ไม่มีงานค้าง ไม่มีอะไรหาย
186          const a = await ask($, `${risk.what} — ตอนนี้มีงานค้าง ${n} ไฟล์ จะทำยังไง?`, ['เก็บเข้า stash ก่อน แล้วค่อยรัน (แนะนำ)', 'รันเลย ทิ้งได้', 'ยกเลิก'])
187          if (!a) return { deny: DENY_NO_ANSWER }
188          if (a.startsWith('ยกเลิก')) return { deny: 'ผู้ใช้ยกเลิกคำสั่งนี้ (Git Guard) อย่าลองทิ้งงานด้วยวิธีอื่น ให้ถามผู้ใช้ก่อน' }
189          if (a.startsWith('เก็บ')) {
190            if (!(await stash($, now))) return { deny: 'Git Guard: stash ไม่สำเร็จ จึงยังไม่รันคำสั่งที่ทิ้งงาน' }
191            $.ui.toast('📦 เก็บงานค้างเข้า stash แล้ว (git stash list)')
192          }
193        } else if (risk.kind === 'force') {
194          const a = await ask($, `${risk.what} จะทำยังไง?`, ['ใช้ --force-with-lease แทน (แนะนำ)', 'force push เลย', 'ยกเลิก'])
195          if (!a) return { deny: DENY_NO_ANSWER }
196          if (a.startsWith('ยกเลิก')) return { deny: 'ผู้ใช้ยกเลิก force push (Git Guard)' }
197          if (a.startsWith('ใช้')) command = command.replace(/--force(?!-with-lease)\b|(\s)-f\b/g, (_m, sp: string | undefined) => `${sp ?? ''}--force-with-lease`)
198        } else {
199          const a = await ask($, `${risk.what} ยืนยันไหม?`, ['ทำเลย', 'ยกเลิก'])
200          if (!a) return { deny: DENY_NO_ANSWER }
201          if (a !== 'ทำเลย') return { deny: 'ผู้ใช้ยกเลิกคำสั่งนี้ (Git Guard)' }
202        }
203      }
204    }
205
206    // 4.3 สำรองก่อน rm
207    if (settings.backup) {
208      const targets = segs.flatMap(rmTargets)
209      if (targets.length) {
210        const saved = await backup($, targets, now)
211        if (saved.length) $.ui.toast(`🧚 สำรอง ${saved.join(', ')} ไว้ที่ ${BACKUP_DIR}/ ก่อนลบแล้ว`)
212      }
213    }
214
215    return command === e.command ? next(e) : next({ ...e, command })
216  })
217
218  on('command.run', { command: 'gitguard' }, async ($, e) => {
219    const [sub = '', value = ''] = e.args.trim().split(/\s+/).filter(Boolean)
220    switch (sub) {
221      case '': {
222        const now = await $.clock.now()
223        const stashes = (await git($, ['stash', 'list'])).out.split('\n').filter(l => l.includes('git-guard'))
224        return {
225          text: [
226            `🛡️ Git Guard ${now < pausedUntil ? `⏸️ พักถึง ${new Date(pausedUntil).toLocaleTimeString('th-TH')}` : '✅ เฝ้าอยู่'}`,
227            `  ถามก่อนคำสั่ง git อันตราย: ${onOff(settings.guard)}`,
228            `  สำรองก่อน rm: ${onOff(settings.backup)} (session นี้สำรองไป ${backups} รายการ)`,
229            `  Conventional Commits: ${onOff(settings.convention)}`,
230            '',
231            stashes.length ? `📦 stash ที่ Git Guard เก็บให้ (กู้: git stash pop stash@{n})\n${stashes.slice(0, 10).map(s => `  ${s}`).join('\n')}` : '📦 ยังไม่มี stash จาก Git Guard',
232          ].join('\n'),
233        }
234      }
235      case 'pause': {
236        const min = Number(value || 5)
237        if (!(min > 0 && min <= 120)) return { text: 'วิธีใช้: /gitguard pause 5  (1-120 นาที)' }
238        pausedUntil = (await $.clock.now()) + min * 60_000
239        return { text: `⏸️ พักการเฝ้า ${min} นาที` }
240      }
241      case 'guard':
242      case 'backup':
243      case 'convention': {
244        if (value !== 'on' && value !== 'off') return { text: `วิธีใช้: /gitguard ${sub} on|off` }
245        settings = { ...settings, [sub]: value === 'on' }
246        await $.store.set('settings', settings)
247        return { text: `✅ ${sub}: ${onOff(value === 'on')}` }
248      }
249      default:
250        return { text: HELP.replace('GUARD', onOff(settings.guard)).replace('BACKUP', onOff(settings.backup)).replace('CONV', onOff(settings.convention)) }
251    }
252  })
253
254  // ==========================================================================
255  // บทที่ 5: /gswitch - สลับ branch โดยถามเรื่องงานค้างก่อน
256  // ==========================================================================
257  on('command.run', { command: 'gswitch' }, async ($, e) => {
258    const [branch, flag] = e.args.trim().split(/\s+/).filter(Boolean)
259    if (!branch) return { text: 'วิธีใช้: /gswitch main  หรือ  /gswitch feature/x new' }
260    const n = await dirtyCount($)
261    const notes: string[] = []
262    if (n > 0) {
263      const a = await ask($, `มีงานค้าง ${n} ไฟล์ ก่อนสลับไป ${branch} จะทำยังไง?`, ['stash แล้วสลับ (แนะนำ)', 'commit เป็น WIP แล้วสลับ', 'พาการแก้ไขไปด้วย', 'ยกเลิก'])
264      if (!a || a === 'ยกเลิก') return { text: 'ยกเลิกการสลับ branch' }
265      if (a.startsWith('stash')) {
266        if (!(await stash($, await $.clock.now()))) return { text: '❌ stash ไม่สำเร็จ' }
267        notes.push('📦 เก็บงานค้างเข้า stash แล้ว กลับมาเมื่อไหร่ใช้ git stash pop')
268      } else if (a.startsWith('commit')) {
269        const add = await git($, ['add', '-A'])
270        const c = add.ok ? await git($, ['commit', '-m', 'wip: งานค้างก่อนสลับ branch']) : add
271        if (!c.ok) return { text: `❌ commit ไม่สำเร็จ\n${c.out}` }
272        notes.push('📝 commit งานค้างเป็น WIP แล้ว (แก้ทีหลังด้วย git reset --soft HEAD~1)')
273      }
274    }
275    const r = await git($, flag === 'new' ? ['switch', '-c', branch] : ['switch', branch])
276    if (!r.ok) return { text: `❌ สลับไม่สำเร็จ\n${r.out}\n${notes.join('\n')}` }
277    return { text: [`🔀 อยู่ที่ ${branch} แล้ว`, ...notes].join('\n') }
278  })
279}
280
281/**
282 * ========================================================================
283 * การบ้าน / ต่อยอด
284 * ========================================================================
285 * 1. /gitguard undo - กู้ไฟล์ล่าสุดจาก .claude-backup/
286 * 2. ลบโฟลเดอร์สำรองที่เก่ากว่า 7 วันอัตโนมัติ
287 * 3. เปิด Conventional Commits เองเมื่อเจอไฟล์ commitlint.config.* ในโปรเจกต์
288 */
289