SLOPSHOPPER

git-guard

保管庫のGit運用ルールを強制する: add/commit前の自動pull(ステージは保つ)、add -Aでの削除の巻き込み防止、force push禁止、起動時の遅れ表示

guardstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-guard
› 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(rm -rf build && git push --force origin main) ⎿ Denied by git-guard: git-guard: force push はしない(AGENTS.md「Git 運用」)。拒否されたら取り込んでからやり直す。 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

obsidian-ai-knowledge-lake

Obsidian の保管庫を「AI と共有する自分のナレッジレイク」にして、作業の多くを AI(Claude Code / Codex)に任せられるようにするための一式。 作者が自分の保管庫で実際に運用している仕組みから、個人の中身を抜いて汎用化したもの。

中身

フォルダ何か最初に読むもの
knowledge-lake-kit/保管庫の器。 フォルダ構成、AI への指示書(AGENTS.md)、各フォルダの README、テンプレート、運用の考え方START_HERE.md(人間)/ SETUP_FOR_AI.md(AI)
agent-tooling/AI に持たせる道具。 スキル(/inbox・/prioritize など)、MCP(Gmail 横断・Google Tasks)、Claude Code の mod、フック、CLI、定期実行README.md(人間)/ FOR_AI.md(AI)

使い方

git clone https://github.com/KaiMiyazawa/obsidian-ai-knowledge-lake.git
  1. 保管庫を作る: knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。
  2. 道具を入れる(任意): 保管庫ができて慣れてきたら、保管庫で AI に次のように頼む。 > <クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝って

どちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。

注意

  • 秘密の値(トークン・パスワード・Webhook など)は入っていない。作者の個人情報は伏せ字(you@gmail.com、<MAIN_LIST_ID> など)にしてある。
  • 自分の保管庫は private リポジトリで管理する。 このリポジトリを fork して中身を書き足し、公開したままにしない。
  • 道具は作者の環境(macOS、Claude Code・Codex の特定の版)で作ったもの。そのまま動く保証はなく、利用者の環境に合わせて AI と直しながら使う。

ライセンス

MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。

Source 1 files
hooks/register.ts 138 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// AGENTS.md「Git 運用」を機械的に守らせる。
4// - add / commit の前に fetch し、遅れていれば pull --rebase --autostash する。
5//   autostash はステージを外す(2026-10-05 に確認)ので、ステージ済みのパスは pull 後に add し直す。
6// - add -A / add . / commit -a で削除(サブモジュールのポインタ削除を含む)を巻き込むときは止める。
7// - force push は止める。
8// - 起動時に親と3つのサブモジュールを fetch し、遅れを状態行に出す(pull はしない)。
9
10const SUBMODULES = ['01_diary', '89_profile/github-profile', '99_public']
11const FETCH_TTL_MS = 60 * 1000
12
13const GIT = String.raw`\bgit(?:\s+-C\s+\S+)?\s+`
14const ADD = new RegExp(`${GIT}add\\b`)
15const COMMIT = new RegExp(`${GIT}commit\\b`)
16const ADD_ALL = new RegExp(`${GIT}add\\b[^;&|]*\\s(?:-A|--all|\\.)(?=\\s|$|;|&|\\|)`)
17const COMMIT_ALL = new RegExp(`${GIT}commit\\b[^;&|]*\\s(?:-[a-zA-Z]*a[a-zA-Z]*|--all)(?=\\s|$)`)
18const FORCE_PUSH = new RegExp(`${GIT}push\\b[^;&|]*\\s(?:-f|--force\\S*)(?=\\s|$)`)
19const GIT_ANY = new RegExp(`${GIT}(?:pull|push|commit|fetch|merge|rebase|submodule)\\b`)
20const C_DIR = /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/
21const CD_DIR = /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/
22
23const unquote = (s: string) => s.replace(/^["']|["']$/g, '')
24
25type Sync = { deny: string } | { note?: string }
26
27// モジュールの変数なので、ホットリロードで消える(次の fetch が走るだけ)。
28const fetchedAt = new Map<string, number>()
29
30const git = ($: EngineInterface, cwd: string, args: string[], timeoutMs = 30_000) =>
31  $.process.run(['git', ...args], { cwd, timeoutMs })
32
33const lines = (s: string) => s.split('\0').filter(Boolean)
34
35const behind = async ($: EngineInterface, repo: string, force: boolean) => {
36  const last = fetchedAt.get(repo) ?? 0
37  if (force || Date.now() - last > FETCH_TTL_MS) {
38    const f = await git($, repo, ['fetch', '--quiet'])
39    if (f.exitCode !== 0) return undefined
40    fetchedAt.set(repo, Date.now())
41  }
42  const c = await git($, repo, ['rev-list', '--count', 'HEAD..@{u}'])
43  return c.exitCode === 0 ? Number(c.stdout.trim()) : 0
44}
45
46const sync = async ($: EngineInterface, repo: string): Promise<Sync> => {
47  const n = await behind($, repo, false)
48  if (n === undefined) return { note: 'git-guard: fetch に失敗した(オフライン?)。pull せずに進めた。push 前に取り込む。' }
49  if (n === 0) return {}
50
51  const staged = lines((await git($, repo, ['diff', '--cached', '--name-only', '-z'])).stdout)
52  const unstaged = new Set(lines((await git($, repo, ['diff', '--name-only', '-z'])).stdout))
53  const partial = staged.filter(p => unstaged.has(p))
54  if (partial.length > 0) {
55    return {
56      deny: `git-guard: リモートが${n}コミット進んでいるので取り込みが必要だが、一部だけステージしたファイルがあり、pull --rebase --autostash でステージが崩れる: ${partial.slice(0, 5).join(', ')}。先に git pull --rebase --autostash してからステージし直す。`,
57    }
58  }
59
60  const pull = await git($, repo, ['pull', '--rebase', '--autostash'], 120_000)
61  if (pull.exitCode !== 0) {
62    return {
63      deny: `git-guard: git pull --rebase --autostash が失敗した。git status で状態を確認し、競合は本人と相談して解く(--force や reset --hard はしない)。\n${(pull.stderr || pull.stdout).slice(0, 800)}`,
64    }
65  }
66  if (staged.length > 0) await git($, repo, ['add', '-A', '--', ...staged])
67
68  return { note: `git-guard: リモートの${n}コミットを pull --rebase --autostash で取り込んだ${staged.length > 0 ? '(ステージは復元済み)' : ''}。` }
69}
70
71const deletions = async ($: EngineInterface, repo: string) => {
72  const out = lines((await git($, repo, ['status', '--porcelain=v1', '-z', '--no-renames'])).stdout)
73  return out.filter(entry => entry[0] === 'D' || entry[1] === 'D').map(entry => entry.slice(3))
74}
75
76const showStatus = async ($: EngineInterface, root: string, force: boolean) => {
77  const parts: string[] = []
78  for (const [label, path] of [['保管庫', root], ...SUBMODULES.map(s => [s, `${root}/${s}`])] as const) {
79    const n = await behind($, path, force).catch(() => 0)
80    if (n === undefined) parts.push(`${label} fetch失敗`)
81    else if (n > 0) parts.push(`${label} ${n}遅れ`)
82  }
83  $.ui.status(parts.length > 0 ? `git: ${parts.join(' / ')}` : undefined)
84}
85
86const repoOf = async ($: EngineInterface, command: string, root: string) => {
87  const dir = unquote(command.match(C_DIR)?.[1] ?? command.match(CD_DIR)?.[1] ?? '.')
88  const home = (await $.env.get('HOME')) ?? ''
89  const abs = dir.startsWith('/') ? dir : dir.startsWith('~') ? home + dir.slice(1) : `${root}/${dir}`
90  const top = await git($, abs, ['rev-parse', '--show-toplevel']).catch(() => undefined)
91  if (top === undefined || top.exitCode !== 0) return undefined
92  const repo = top.stdout.trim()
93  return repo === root || repo.startsWith(`${root}/`) ? repo : undefined
94}
95
96export const register: Register = on => {
97  on('session.start', async ($, e, next) => {
98    const started = await next(e)
99    const root = await $.session.root()
100    void showStatus($, root, true)
101
102    return started
103  })
104
105  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
106    const command = e.command
107    if (FORCE_PUSH.test(command)) {
108      return { deny: 'git-guard: force push はしない(AGENTS.md「Git 運用」)。拒否されたら取り込んでからやり直す。' }
109    }
110
111    const root = await $.session.root()
112    const isStaging = ADD.test(command) || COMMIT.test(command)
113    const repo = isStaging ? await repoOf($, command, root) : undefined
114    let note: string | undefined
115
116    if (repo !== undefined) {
117      const synced = await sync($, repo)
118      if ('deny' in synced) return synced
119      note = synced.note
120
121      if (ADD_ALL.test(command) || COMMIT_ALL.test(command)) {
122        const deleted = await deletions($, repo)
123        if (deleted.length > 0) {
124          return {
125            deny: `git-guard: 一括ステージに削除が含まれる: ${deleted.slice(0, 10).join(', ')}${deleted.length > 10 ? ` ほか${deleted.length - 10}件` : ''}。自分が消したものか確かめ、意図した削除ならパスを個別に指定して git add する(AGENTS.md「Git 運用」)。`,
126          }
127        }
128      }
129    }
130
131    const ran = await next(e)
132    if (repo !== undefined || GIT_ANY.test(command)) void showStatus($, root, false)
133    if (note === undefined || 'deny' in ran) return ran
134
135    return { ...ran, context: [...(ran.context ?? []), note] }
136  })
137}
138