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

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
knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。<クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝ってどちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。
you@gmail.com、<MAIN_LIST_ID> など)にしてある。MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。
hooks/register.ts 138 lines1import 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