この記事のポイント
- ✓.gitignore したファイルは履歴もバックアップもない単一障害点になりがち。「素材が唯一の情報源」という建前は、履歴がないと静かに崩れる
- ✓dotfiles管理で知られる bare repo + work-tree 方式なら、作業ツリーを共有したまま「公開用」「非公開用」2つのGitを衝突なく同居させられる
- ✓.gitignore は info/exclude より優先される。この罠の回避策と、Obsidian Vault への「Git差分 × iCloudバックアップ」一方通行同期まで実装した
きっかけ——「唯一の情報源」は本当に唯一か
このサイトはGitで管理していて、pushすれば自動でデプロイされる。記事のメタデータはJSONファイル1つに集約し、一覧やRSSは自動生成。「情報源は1ヶ所、派生物は生成する」というシングルソース(Single Source of Truth)の考え方で組んできた。
ただし、Gitに入れていないファイル群がある。プロフィール・経歴・スキルの素材データ、そしてAIエージェントへの指示書である CLAUDE.md だ。素材には公開前の生データ——本名や具体的な金額など——が含まれるため、公開サイトと同じリポジトリには置けない。そこで .gitignore で除外し、「ページを作るときは必ずこの素材を参照する」というルールで運用していた。
つまりサイトの“真実”は、Git管理外のファイルが持っている設計だった。
ある日ふと不安になって、この管理外ファイル群をシングルソースの観点で棚卸ししてみた。結果から言うと、建前はすでに崩れていた。
棚卸しで見つかった3つの綻び
見つかった問題は、どれもシングルソース管理の典型的な崩れ方だった。
①真実の方向が逆転していた
公開ページは「IT歴14年」に更新済みなのに、素材ファイルは「13年」のまま3月の更新を最後に止まっていた。このまま素材からページを再生成すると、公開済みの正しい情報が古い情報に巻き戻る。「素材が正、ページは派生」のはずが、いつの間にか逆になっていた。
②誰も見ていない「死んだ複製」
素材内のサイト設定ファイルにナビゲーション定義があったが、実際のナビは共通コンポーネントのJSが描画していて、設定ファイル側は旧構成のまま放置されていた。参照されない写しは、存在するだけで「どっちが正?」という混乱のもとになる。
③TODOファイルの三重管理
作成時期の違うTODOメモが3本並存し、同じテーマのタスクが片方では「着手待ち」、もう片方では「完了」になっていた。実際にサイトを検証すると、3月のTODOの多くはとっくに解消済みだった。
3つに共通する根本原因ははっきりしている。Git管理外のファイルには履歴がないことだ。
公開ページ側のズレはコミットログから「いつ・何が変わったか」を特定できた。しかし素材側は、いつから古いのか、誰がどう変えたのか、何も遡れない。おまけにリモートにpushされないので、バックアップもゼロ。ツイートのアーカイブや読書記録など再収集の難しい一次データが、PC一台の単一障害点に乗っていた。
ドリフト(情報のズレ)は、diffが取れて初めて検知可能になる。履歴のないファイルのズレは、誰かが偶然気づくまで静かに進行する。
3つの選択肢——どこで管理するか
「じゃあバージョン管理しよう」となったとき、選択肢は3つあった。
| 案 | 内容 | 判断 |
|---|---|---|
| A. メインに入れる | 公開サイトのリポジトリに含め、デプロイ時に除外する | 見送り |
| B. ノートアプリへ移動 | iCloud同期のObsidian Vaultに移し、リンクで参照する | 見送り |
| C. “第二のGit” | ファイルは今の場所のまま、別のGitリポジトリで履歴だけ持つ | 採用 |
案Aを見送った理由は、事故ったときの被害が大きすぎるからだ。デプロイ設定の除外漏れが一発あれば、本名や金額入りの生データがそのまま公開される。将来「ポートフォリオとしてリポジトリ自体を公開したい」と思った瞬間にも詰む。Gitの履歴から機微情報を消すのは非常に面倒で、最初から分けておくのが一番安い防御になる。
案Bの問題は、同期はバージョン管理ではないこと。iCloudは最新状態を揃えてくれるが、差分も履歴も持たないので、今回の「いつから古いのか分からない」問題は何も解決しない。素材データはサイトのビルド入力でもあるので、プロジェクトの隣に置いておくのが自然でもある。
というわけで案C。ファイルは1バイトも動かさず、「履歴を持つ仕組み」だけを後付けする。これを実現するのが bare repo + work-tree 方式だ。
bare repo + work-tree——1つの作業ツリーに2つのGit
普段の git コマンドは、カレントディレクトリから .git/ を探して「履歴データベース」と「作業ツリー」を自動で結びつけている。実はこの結びつけは、オプションで明示的に上書きできる。
git --git-dir=<履歴DBの場所> --work-tree=<作業ツリーの場所> <コマンド>
これを使い、履歴DBだけをプロジェクトの外に置いて、作業ツリーを2つのリポジトリで共有する。ホームディレクトリの設定ファイル群(dotfiles)をGit管理する定番手法として知られているパターンだ。
~/workspace/my-site/(作業ツリー:1つを共有)
.git/ …… メインGitの履歴DB → 公開ファイルを追跡
index.html, lab/ … ← メインが追跡
.docs/, CLAUDE.md … ← 第二のGitが追跡(メインはignore)
~/.private-git/my-site.git(第二のGitの履歴DB:プロジェクトの外)
非公開素材だけの履歴を保持。メインのgitからは存在自体が見えない
セットアップはこれだけ。
# 1. 履歴DBだけの「bareリポジトリ」をプロジェクト外に作る
git init --bare ~/.private-git/my-site.git
# 2. シェルにエイリアスを定義(pgit = private git)
alias pgit='git --git-dir="$HOME/.private-git/my-site.git" \
--work-tree="$HOME/workspace/my-site"'
# 3. 管理したいものだけ add してコミット
pgit add .docs CLAUDE.md
pgit commit -m "非公開素材の初期コミット"
以後は pgit status / pgit diff / pgit log -p .docs/profile.md が全部使える。「このプロフィール、いつ・どう変えたっけ」が、ようやく遡れるようになった。
混ざらない根拠は単純で、普通の git コマンドは .git/ しか見ないから。第二のGitの履歴DBはプロジェクトの外にあるので、メイン側のstatusにもpushにも入りようがない。コマンド名も git=公開、pgit=非公開と文脈が分かれる。
※ なお git worktree という似た名前の別機能(1つのリポジトリから複数の作業ディレクトリを生やす)があるが、これは逆に「2つのリポジトリが1つのディレクトリを共有する」話なので混同注意。
ハマった罠——.gitignore はホワイトリストより強い
安全のため、第二のGit側にはホワイトリストを仕込むことにした。リポジトリ固有の除外設定ファイル info/exclude に「全部無視、ただし非公開素材だけ追跡可」と書いておけば、うっかり pgit add . しても公開ファイルが混入しない、という発想だ。
# ~/.private-git/my-site.git/info/exclude
*
!.docs
!.docs/**
!CLAUDE.md
ところが、いざ pgit add するとステージされたファイルが0件。原因を調べて、Gitのignore優先順位を改めて思い知った。
ignoreルールの優先順位(強い順)
- コマンドラインの指定
- 作業ツリー内の .gitignore(深い階層ほど強い)
- $GIT_DIR/info/exclude ← ホワイトリストを書いた場所
- core.excludesFile(グローバル設定)
作業ツリーにはメインGit用の .gitignore があり、当然そこには「.docs を無視」と書いてある。作業ツリーを共有している以上、この .gitignore は第二のGitからも読まれ、しかも info/exclude のホワイトリストに勝ってしまう。
今回は運がよかった。調べてみるとこの .gitignore は自分自身を無視している未追跡のローカル限定ファイルで、消しても誰にも影響がなかった。そこでメインGitの除外ルールをメイン側の info/exclude に引っ越し、.gitignore そのものを廃止。各リポジトリが自分のinfo/excludeだけで自分のルールを持つ、衝突ゼロの構成になった。
既存プロジェクトに横展開する場合の注意:普通のリポジトリでは .gitignore は追跡済みで消せない。その場合は初回だけ pgit add -f で強制追跡すればいい。ignoreは未追跡ファイルにしか作用しないので、一度追跡してしまえば以後のdiff・status・commitは普通に効く。弱点は「新規の機密ファイルがpgit statusに出ない」ことだけ。
最終的に、ステージされた全ファイルに公開ファイルがゼロ件であることを機械的に確認してから初回コミットした。「混ざらない」は気合いではなく、ホワイトリストの構造で保証する。
Obsidian Vault との統合——隠して、写して、一方通行
履歴は手に入った。残るはバックアップと、もう1つの要望——ノートアプリ(Obsidian)のキャリア分析ノートから、この素材を参照したい。プロフィールや経歴の数値は、職務経歴書を書くときの一次データでもあるからだ。
push先は、iCloud同期しているObsidian Vaultの中に置いた。ただし置き方に2つ工夫がある。
Vault内 .git-remotes/(bareリポジトリ)
dotフォルダなのでObsidianからは不可視。iCloudが同期=オフサイトバックアップ
Vault内 参照用clone(普通のフォルダ)
ノートから [[リンク]] で参照できる読み取り専用の写し。スマホからも読める
工夫の1つ目はbareリポジトリをdotフォルダに置くこと。Obsidianは「.」始まりのフォルダをインデックス・検索・グラフから完全に除外する(設定フォルダの .obsidian と同じ扱い)。bareリポジトリは運用が続くほど細かいobjectファイルが大量に増えていくので、普通のフォルダに置くとVaultのインデックスを汚してしまう。dotフォルダなら不可視のまま、iCloud同期にはちゃんと乗る。
2つ目は「読むための写し」を別に用意すること。bareリポジトリの中身は圧縮されたバイナリで、人間にもObsidianにも読めない。そこでVault内のノートフォルダに普通のcloneを作り、ノートからはそこへリンクする。
このとき絶対に守るルールが「同期は一方通行」。編集は必ずプロジェクト側で行い、push → pull の流れでVaultに反映する。写し側でも編集できる状態にすると、3つのコピーの整合を人間が管理する羽目になり、冒頭の「真実の逆転」が場所を変えて再発するだけだ。push と pull をまとめた pgit-sync コマンドにして、反映は1アクションにした。
※ macOSの「Macストレージを最適化」がONだと、iCloud上のファイルがローカルから退避されてpushに失敗することがある。Git置き場にする場合は要注意。
まとめ——ドリフトは意志ではなく仕組みで防ぐ
最終的な運用ルールは3行に収まった。
- 1.非公開素材を編集したら
pgit commit→pgit-syncまでがワンセット - 2.公開ページのプロフィール系記述を更新したら、同じ作業の中で素材側にも反映する
- 3.Vault側の写しは読み取り専用。編集したくなったらプロジェクト側へ
そしてこのルールは、自分の記憶力には頼らずCLAUDE.md に書いてAIエージェントにも覚えさせた。このサイトの更新作業はClaude Codeと一緒にやっているので、素材を編集したらコミットまで促される。今回のシングルソース分析自体もClaude Codeとの共同作業で、「13年と14年のズレ」を見つけたのはgrepとgit logの突き合わせだった。
振り返ると、今回やったことは新しい技術でも何でもない。bare repo + work-tree はdotfiles界隈で昔から使われている枯れた手法だし、ignoreの優先順位はマニュアルに書いてある仕様だ。
それでも書き残す価値があると思ったのは、「Git管理外の重要ファイル」は多くのプロジェクトに存在するのに、その扱いが語られることは少ないからだ。.envファイル、AIエージェントへの指示書、デプロイ前の素材データ。公開できないという理由で履歴からも追い出されたファイルたちは、静かに古び、静かに矛盾し、ある日「どれが正しいんだっけ?」という形で請求書を送ってくる。
シングルソースは宣言するものではなく、維持する仕組みごと設計するもの。履歴と差分は、その仕組みのいちばん安い部品だと思う。