1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ベースを別リポジトリへ配る 2 経路を dry-run で動かす(apply-to-repo.sh と bootstrap.sh)

1
Posted at

この記事はシリーズ「自律運用の土台を 1 本まるごと読む: claude-code-repository-base 全解剖」の第 3 回(全 10 回)です。

Claude Code に毎回同じ指示をしなくて済むように、ルール・フック・スキル・ツールを一式にまとめた公開リポジトリ kai-kou/claude-code-repository-base(MIT)を、実ファイルを引用し実際にコマンドを動かしながら読み解く連載です。掲載する実行結果と数値はすべて各回の執筆時点で採取し直し、検証したベースのコミット SHA を各回の冒頭に記します。

シリーズ全体の目次(クリックで開く)
  • 第 1 回 Claude Code に毎回同じ指示をしなくて済むように、運用の土台をリポジトリ 1 本にまとめた
  • 第 2 回 git push origin main | tee log で保護が素通りしていたので、コマンド分割で塞ぎ直した(公開予定)
  • 第 3 回 ベースを別リポジトリへ配る 2 経路を dry-run で動かす(apply-to-repo.sh と bootstrap.sh)(この記事)
  • 第 4 回 Stop フックを 5 本並べたら最初の 1 本しか読まれなかったので、ルーター 1 本に集約した(公開予定)
  • 第 5 回 コンテキスト圧縮で作業が消えるのを、圧縮前後の二段 WIP コミットで防いだ(公開予定)
  • 第 6 回 sandbox.enabled を true にしてもクラウドでは bwrap が無く、許可リスト外へ素通りだった(公開予定)
  • 第 7 回 「確認してよいですか」を 6 種類に限定したら、それ以外は全部自律実行になった(公開予定)
  • 第 8 回 ルールと教訓を増やし続けないために、常駐バイト予算と「昇格=物理削除」を機械強制した(公開予定)
  • 第 9 回 複数エージェントが同時に書くホワイトボードを、個別ファイル+単一集約者で壊れなくした(公開予定)
  • 第 10 回 10 回分を読み終えたら、自分のリポジトリに最初に持ち込む 3 つはどれか(公開予定)

検証時点: base kai-kou/claude-code-repository-base(MIT) HEAD 945e273(2026-09-10 JST)

対象読者は、共通の Claude Code 設定を複数のリポジトリに配りたい人、あるいは他人が公開しているテンプレートリポジトリを自分のプロジェクトへ導入したい人です。

第 1 回の一覧表に「🔒 このリポジトリを別のリポジトリへワンコマンドで導入できる」という行がありました。今回はその実体を動かします。

テンプレートリポジトリを配る仕組みでいちばん怖いのは、2 回目以降の同期です。1 回目は空のディレクトリへコピーするだけなので何も失われません。ところが下流のリポジトリで設定を少し書き換えたあと、テンプレート側の更新を取り込もうとすると、素朴なコピーはその書き換えを黙って消します。消えたことに気づくのは、たいてい何かが動かなくなった後です。

このリポジトリには配布の経路が 2 つあります。既存リポジトリへ後付けする scripts/apply-to-repo.sh と、新規プロジェクトを初期化する scripts/bootstrap.sh です。どちらもダミーのリポジトリに対して実際に動かし、出力をそのまま載せます。

TL;DR

  • apply-to-repo.sh --dry-run は 1 ファイルも書き換えずに配置プランだけを出します。今回のダミーリポジトリでは 配置・更新 227 件 / マージ 0 / 要確認 0 / 触れず 0 でした。
  • 一度適用すると .claude/base-sync-state.json に「前回適用したベースの SHA」が残ります。この SHA を祖先として、2 回目以降は ファイルごとに 4 分岐(無変更 / fast-forward / 3 方向マージ / 衝突時は下流温存)へ振り分けられます。
  • 実際に適用してから下流のファイルを 1 つ書き換えて再度 --dry-run すると、触れず 214 件 / 配置・更新 13 件 に変わり、書き換えた内容はそのまま残っていました。
  • 衝突したときにワークツリーへ衝突マーカーを書かないのが設計の中核です。壊れた settings.json やマーカー入りのルールファイルが下流に残らないようにしています。
  • bootstrap.sh は新規プロジェクト向けで、216 ファイル のプレースホルダを置換し、ベース固有の配布定義 .claude-plugin/marketplace.json を削除しました。

手順 1: 既存リポジトリへ後付けする(apply-to-repo.sh)

ダミーリポジトリを用意する

自分の本番リポジトリでいきなり試すものではないので、空のリポジトリを作ります。

mkdir -p /path/to/dummy-downstream && cd /path/to/dummy-downstream
git init -q
git config user.email test@example.com
git config user.name test
printf '# dummy\n' > README.md
git add README.md && git commit -q -m init

dry-run で配置プランを見る

--dry-run を付けると、1 ファイルもコピーせずに「何をどこへ置くつもりか」だけを出力します。

bash /path/to/base/scripts/apply-to-repo.sh --repo kai-kou/dummy-sandbox --dry-run

出力の冒頭は次のようになりました。

[apply] ベース   : kai-kou/claude-code-repository-base@main
[apply] 対象     : kai-kou/dummy-sandbox (/path/to/dummy-downstream)
[apply] name     : dummy-sandbox
[apply] *** DRY-RUN モード(コピーは行いません)***
[apply] git でベースを取得します

[apply] ── アップデート確認(kai-kou/claude-code-repository-base@main = 945e273)──
[apply] 前回適用マーカー(.claude/base-sync-state.json)なし: 初回適用として扱います
[apply] (適用完了時にマーカーを作成し、次回からアップデート一覧を表示します)
[apply] ── ルール・スキル・ハーネスを同期 ──
[apply] 前回適用マーカーが無いため、今回は上書き同期します(次回から下流の変更を保護します)
[apply]   ~ would place: docs/rules/output-verbosity-rules.md
[apply]   ~ would place: docs/rules/lessons/session-safety.md

以降、配置予定のファイルが 1 行ずつ並び、ディレクトリごとに小計が出ます。

[apply]   + docs/rules(更新 63 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/rules(更新 13 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/hooks(更新 25 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/skills(更新 48 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/agents(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/output-styles(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/commands(更新 2 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude-plugin/plugin.json(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + tools(更新 64 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + scripts(更新 3 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + modules.yaml(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .mcp.json(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + requirements.txt(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + config/claude_code_spec_sync.yaml(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + config/broker_workflows.json.example(更新 1 / マージ 0 / 要確認 0 / 触れず 0)
[apply] ── プロジェクト固有ファイル ──
[apply]   ~ would install: CLAUDE.md
[apply]   ~ would install: docs/project-mission.md
[apply]   ~ would install: config/publish_events.yaml
[apply]   ~ would install: config/data_only_path_prefixes.txt
[apply]   ~ would install: config/pr_review_comment_categories.json

[apply] ── 同期サマリー ──
[apply]   祖先: 未使用(上書き同期)
[apply]   触れず(ベース側に更新なし): 0 / 配置・更新: 227 / マージ: 0 / 要確認: 0 / 削除を尊重: 0
[apply] DRY-RUN 完了。--dry-run を外すと実際に適用します。

配置予定は 227 件でした。ログ全体は 265 行あり、そのうち大半が ~ would place: の 1 ファイル 1 行です。

SYNC_PATHS と PROTECT_PATHS の違い

サマリーの前に 2 つの区画があります。上が同期対象(SYNC_PATHS)、下が「プロジェクト固有ファイル」(PROTECT_PATHS)です。スクリプト内では配列として定義されています。

SYNC_PATHS=(
  "docs/rules"
  ".claude/rules"
  ".claude/hooks"
  ".claude/skills"
  ".claude/agents"
  ".claude/output-styles"
  ".claude/commands"
  # .claude-plugin/ はディレクトリ丸ごとにしない。marketplace.json(本ベースを配布するための
  # マーケットプレイス定義)を下流へ配ると、下流リポジトリが「claude-code-base を配布する
  # マーケットプレイス」を名乗ってしまう。下流に要るのは plugin.json の雛形だけ。
  ".claude-plugin/plugin.json"
  "tools"
  "scripts"
  "modules.yaml"
  ".mcp.json"
  "requirements.txt"
  # config/ は性質ごとに個別指定する(ディレクトリ丸ごとにしない・Issue #448)。
  # 丸ごと同期すると state ファイルまで上書きされ、下流の実行状態がベースの値へ巻き戻る。
  "config/claude_code_spec_sync.yaml"    # tools/check_claude_code_updates.py が起動時に読む。
                                         # 既定値フォールバックが無く、不在だとロード自体が失敗する
  "config/broker_workflows.json.example" # プレースホルダのみの雛形。実ファイル(.json)は下流が作る
)
PROTECT_PATHS=(
  "CLAUDE.md"
  "docs/project-mission.md"
  # config/ のうち「下流が追記して拡張する」契約をファイル自身が明記しているもの(Issue #448)。
  # SYNC 側に置くと cp -a の無条件上書きで下流の追記が必ず失われる。
  "config/publish_events.yaml"
  "config/data_only_path_prefixes.txt"
  "config/pr_review_comment_categories.json"
)

SYNC_PATHS 配下は祖先つき 3 方向マージで同期されます。PROTECT_PATHS 配下はプロジェクト固有として既定で保護され、下流に既存ファイルがあればそれを維持したうえで、ベース版が <path>.base として並置されます。--overwrite-project を付けたときだけ上書きされます。

コメントを読むと、config/ をディレクトリ丸ごと指定しない理由が書いてあります。丸ごと同期すると state ファイルまで上書きされ、下流の実行状態がベースの値へ巻き戻るためです。同じ config/ の下に、同期したいファイルと保護したいファイルが混在しているという状況を、ディレクトリ単位ではなくファイル単位の列挙で解いています。

祖先つき 3 方向マージの 4 分岐

apply-to-repo.sh の冒頭コメントに、同期の設計方針が書かれています。

#   - SYNC_PATHS 配下も無条件上書きはしない。前回適用したベースの SHA
#     (.claude/base-sync-state.json)を祖先として、ファイルごとに
#       ① ベース側が前回から無変更 → 触らない(下流の変更をそのまま保つ)
#       ② 下流が祖先のまま        → ベース最新で上書き(fast-forward)
#       ③ 両側が変更              → 3 方向マージ(tools/merge_three_way.py)
#       ④ 衝突・検証失敗・祖先なし → 下流を温存し、ベース最新を <path>.base-latest に併置
#     と振り分ける。衝突マーカーはワークツリーに一切書かない(壊れた settings.json で
#     下流のセッションが起動不能になる/ルールが壊れた形で読まれる、という失敗を構造的に断つ)。

④ に注目してください。衝突したときに <<<<<<< ======= >>>>>>> をワークツリーへ書き込まない、という選択をしています。手作業でのマージであれば衝突マーカーは「人間が読んで解決する目印」として機能しますが、settings.json に衝突マーカーが混ざると JSON として壊れます。ルールファイルにマーカーが残れば、それがそのまま規範として読まれます。下流に置かれるのはあくまで既存の内容で、ベース側の最新版は <path>.base-latest という別ファイルとして並びます。

祖先マーカーが生まれるところ

--dry-run を外して実際に適用すると、.claude/base-sync-state.json が生成されます。dry-run では作られません。

bash /path/to/base/scripts/apply-to-repo.sh --repo kai-kou/dummy-sandbox
{
  "base_repo": "kai-kou/claude-code-repository-base",
  "ref": "main",
  "commit": "945e273f38b4c0bdb0ff2035499853fce6a0b619",
  "applied_at": "2026-09-10T12:12:53+0900"
}

適用完了時のメッセージにも「.claude/base-sync-state.json をコミットに含める(次回アップデート確認の基準点)」とあります。このファイルをコミットし忘れると、次回も初回扱いになって上書き同期に戻ります。

2 回目の同期で下流の変更がどうなるか

祖先が記録された状態で、下流のファイルを 1 つ書き換えてみます。

printf '\n<!-- 下流だけの独自追記(このリポジトリ固有のメモ) -->\n' >> docs/rules/core-principles.md
bash /path/to/base/scripts/apply-to-repo.sh --repo kai-kou/dummy-sandbox --dry-run

出力の冒頭が変わりました。

[apply] ── アップデート確認(kai-kou/claude-code-repository-base@main = 945e273)──
[apply] 前回適用(945e273・2026-09-10)から変更なし

[apply] ── 手動手順が必要な更新(docs/base-update-notes.md・前回適用日 2026-09-10 以降)──
## 2026-09-10(Issue #618)リポジトリ内の `.claude/` `.git/` への Bash 書き込みも機械ブロックする(保護パス)

サマリーはこうなります。

[apply]   + docs/rules(更新 0 / マージ 0 / 要確認 0 / 触れず 63)
[apply]   + .claude/rules(更新 13 / マージ 0 / 要確認 0 / 触れず 0)
[apply]   + .claude/hooks(更新 0 / マージ 0 / 要確認 0 / 触れず 25)
[apply]   + .claude/skills(更新 0 / マージ 0 / 要確認 0 / 触れず 48)
[apply]   + tools(更新 0 / マージ 0 / 要確認 0 / 触れず 64)
[apply] ── 同期サマリー ──
[apply]   祖先: 945e273(前回適用したベース)
[apply]   触れず(ベース側に更新なし): 214 / 配置・更新: 13 / マージ: 0 / 要確認: 0 / 削除を尊重: 0

初回は「触れず 0 / 更新 227」でしたが、2 回目は「触れず 214 / 更新 13」に反転しました。ベース側が前回適用時点から動いていないので、分岐①(触らない)が 214 件に効いています。書き換えた docs/rules/core-principles.md を確認すると、追記した行はそのまま残っていました。

tail -2 docs/rules/core-principles.md
<!-- 下流だけの独自追記(このリポジトリ固有のメモ) -->

残る 13 件は .claude/rules です。ここは docs/rules/ の実体を指すシンボリックリンクの置き場で、リンクは毎回置き直される扱いになっていました。筆者が今回いちばん腑に落ちたのはこの数字の反転で、「祖先を記録しているかどうか」だけで同期の意味がここまで変わることが、サマリー行 1 行の差として見えるようになっています。dry-run のサマリーだけ見れば、適用してよいかどうかを判断できます。

なお docs/base-update-notes.md は、下流で手動対応が必要な変更だけを新しい順に記録する append-only のノートです。apply-to-repo.sh は前回適用日以降のエントリを見出しの日付で機械抽出し、アップデート確認の欄に差し込みます。通常のルール・スキル改良はここに書かず、再実行だけで同期されます。

手順 2: 新規プロジェクトを初期化する(bootstrap.sh)

既存リポジトリではなく、これから始めるプロジェクトの雛形として使うときは bootstrap.sh です。ベースをそのまま複製したディレクトリの中で実行します。ベースのクローン自体を汚さないよう、cp -r した別コピーに対して動かします。

cp -r /path/to/base /path/to/base-bootstrap-copy
cd /path/to/base-bootstrap-copy
bash scripts/bootstrap.sh --repo testowner/testrepo --name "Test Project"

実行前の CLAUDE.md はプレースホルダのままです。

# CLAUDE.md — {{PROJECT_NAME}}

> このファイルは `kai-kou/claude-code-repository-base`(汎用 Claude Code 自律運用ベース)から生成された雛形。

実行結果は次のとおりでした。

[bootstrap] repo=testowner/testrepo name=Test Project
[bootstrap] placeholders replaced in 216 files
[bootstrap] removed .claude-plugin/marketplace.json (base-only distribution manifest)
[OK] docs/rules/ と .claude/rules/ は同期されています
[bootstrap] done. 次のステップ:
  - docs/project-mission.md にミッション・KPI を記入
  - CLAUDE.md の応答スタイル / PR 自律化方針を確認
  - env は Claude.ai の環境設定に登録(GH_TOKEN は未設定でよい=プロキシが認証を注入)

CLAUDE.md の冒頭が置換されました。

# CLAUDE.md — Test Project

> このファイルは `kai-kou/claude-code-repository-base`(汎用 Claude Code 自律運用ベース)から生成された雛形。

やっていることは 96 行のスクリプトに収まっていて、冒頭コメントが役割を 4 つ挙げています。

# 役割:
#   1. プレースホルダ置換(__OWNER__/__REPO__, {{REPO_SLUG}}, {{PROJECT_NAME}} 等)
#   2. ベース固有の配布物(.claude-plugin/marketplace.json)を除去
#   3. .claude/rules/ の symlink を同期(check_rules_sync.sh --fix)
#   4. (任意)modules.yaml で enabled:false のモジュールを除去(--prune)

2 番目が効いたことは、ディレクトリを見比べれば確かめられます。

ls .claude-plugin/          # コピー側(bootstrap 実行後)
# plugin.json

ls /path/to/base/.claude-plugin/    # ベース側
# marketplace.json
# plugin.json

marketplace.json は「このベースを配布するためのマーケットプレイス定義」なので、下流に残ると下流リポジトリがベースの配布元を名乗ってしまいます。SYNC_PATHS.claude-plugin/ をディレクトリ丸ごとではなく .claude-plugin/plugin.json だけを指定しているのも同じ理由で、後付け経路と新規初期化経路の両方で同じ再帰を止めています。

裏側の仕組み: merge_three_way.py

3 方向マージの実体は tools/merge_three_way.py です。自己テストが付いています。

python3 tools/merge_three_way.py --self-test
✅ merge_three_way self-test: PASS

冒頭の docstring に、マージ結果をそのまま信じない理由が書かれています。

検証は 4 :
  1. 衝突マーカー<<<<<<< / ======= / >>>>>>>が残っていないこと
  2. JSON なら構文が妥当であること
  3. JSON なら **重複キーが無いこと**
  4. JSON かつ ORDER_SENSITIVE_ARRAY_PATHS 対象なら **順序依存配列の相対順序が両側とも未決定でないこと**
3 が要るのは同一階層の別位置に両側が同名キーを追加すると merge-file が衝突なしと判定し
構文的にも妥当な重複キー JSONが生成されるためこの JSON はパース時に片方の値が
サイレントに消えるPython は最後の出現が勝つ)。構文検証だけでは検出できない

3 段目が興味深いところです。git merge-file は行単位で見るので、同じ JSON オブジェクトの離れた位置に両側が同名キーを足すと、行としては重複しないため衝突と判定されません。生成された JSON は構文的にも妥当です。ところが読み込むと、Python の dict は後に出てきたキーが勝つため、片方の値が何のエラーも出さずに消えます。構文チェックだけでは通ってしまう種類の壊れ方で、これを弾くために重複キーの検出を別段として持っています。

4 段目は hooks.PreToolUse のように、配列の要素順が実行順序を意味する場合の話です。両側が別の位置に新規エントリを挿入すると、行としては非重複で構文も妥当なマージ結果になりますが、その並び順はどちらの入力も決めていない偶然の産物です。

つまずきやすいところ

  • --dry-run を外して実行したあと、.claude/base-sync-state.json をコミットし忘れる: 次回も祖先なしと判定され、上書き同期に戻ります。適用完了メッセージにも念押しがあります。
  • 自分の本番リポジトリでいきなり適用する: 既存の CLAUDE.mdPROTECT_PATHS にあるので保護されますが、.claude/settings.json はハーネス本体として導入されます(既存があれば .bak に退避されます)。まず --dry-run でサマリーを見てください。
  • 適用したいものと保護したいものが同じディレクトリにある: config/ がその例です。ディレクトリ単位で追加すると state ファイルが巻き戻るので、ファイル単位で SYNC_PATHSPROTECT_PATHS のどちらかへ足します。

まとめ

既存リポジトリに後付けするなら apply-to-repo.sh、これから始めるプロジェクトなら bootstrap.sh、という使い分けです。前者は --dry-run でプランを確認してから適用でき、2 回目以降は .claude/base-sync-state.json の SHA を祖先として、ベース側が動いていないファイルには触れません。今回のダミーリポジトリでは初回 227 件の配置が、2 回目には触れず 214 件へ反転しました。どちらのスクリプトも何度でも再実行できます。

ここまではリポジトリを配る側の仕組みでした。次回からは Part 3 に入り、配られた先で実際にフックがどう動いているかを見ていきます。最初に扱うのは Stop フックの集約です。

関連記事

参考リンク

シリーズの前後の記事

  • ⬅️ 前の記事: 第 2 回 git push origin main | tee log で保護が素通りしていたので、コマンド分割で塞ぎ直した

  • ➡️ 次回予告: 第 4 回 Stop フックを 5 本並べたら最初の 1 本しか読まれなかったので、ルーター 1 本に集約した

    ここからフック運用編。複数フックの警告が 1 つしか届かない問題を、ルーター 1 本への集約でどう直したかを読みます。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?