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

ยามเฝ้า 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ถ้าไม่มีใครตอบได้ (เช่น claude -p) คำสั่งที่ทำให้งานหายจะถูกปฏิเสธไว้ก่อน โฟลเดอร์ .claude-backup/ มี .gitignore ของตัวเองจึงไม่ถูก commit Mod 15 บล็อกคำสั่งทำลายระบบ ส่วน Mod นี้ดูแลงานในโปรเจกต์
hooks/register.tsx 289 lines1/**
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