2026年6月12日
macOSAutomationDotfilesGitlaunchd

自分のノートパソコンは今、自分の設定を自らコミットする

マシン移行を生き延びる dotfiles セットアップ(シンボリックリンクされた git・シェル設定、Homebrew を監視する launchd エージェント、マシン移行を手動バックアップなしで乗り切るアイデンティティ振り分け)。

3 分で読めます

マシン移行を生き延びる方法についての記事を公開した。その1週間後、自分のセットアップがすでに失敗していたことに気づいた。それを直すのに git、シンボリックリンク、launchd、そして一つの謙虚にさせられる午後が必要だった。

1週間前、ワークスペースのアーキテクチャについての記事を公開した。

その論旨は自信満々だった。

もし今日マシンが壊れたら、一つのディレクトリからすべてを再構築できるべきだ。

すべてが ~/Developer/ の下にある。リポジトリ。インフラ。データ。一つのディレクトリで、完全に持ち運べる。

自分としては、かなり良い気分だった。

それから数日後、AI アシスタントに Claude Code のスキルフォルダを新しい構造にバックアップするよう頼んだ。

返ってきたのは予想外のものだった。

ls: /Users/paurushrai/.claude/skills/: No such file or directory

そのフォルダは存在しなかった。

だが自分には確かにスキルがあった。graphify という自作のもの(作って、毎日使っていて、グローバル設定は今でもそれを参照している)。ではそれはどこにあったのか?

/Users/paurushrai/Downloads/mac-restore/dot-claude/skills/graphify/

そこにあった。Downloads フォルダの中に。mac-restore という名前のディレクトリの中に。

マシンを移行した日からずっとそこに座っていた。一度も復元されないまま。忘れられて。

graphify スキルが ~/Downloads/mac-restore の中で死んでいるのが見つかった様子。移行後一度も復元されていない

自分はまさにこの失敗モードについて、丸ごと一本記事を書いていた。そして自分のマシンは、その間ずっと静かにそれを生きていた。

🔍 死角

自分が見落としていたのはこれだ。

自分のアーキテクチャは、すべてを ~/Developer/ の中に組織化していた。だが、マシンを自分のものらしく感じさせる設定は、そこには住んでいない。

  • Git のアイデンティティと挙動(~/.gitconfig)
  • シェルのセットアップ(~/.zshrc、~/.zprofile)
  • グローバルな ignore(~/.gitignore_global)
  • AI コーディングのルール(~/.claude/CLAUDE.md)
  • カスタム AI スキル(~/.claude/skills/)
  • そして最も見えにくいもの(インストール済みパッケージの一覧)。これはどこにも存在しない

そのすべてが $HOME 全体に散らばっていた。バージョン管理されず。バックアップされず。自分の美しいディレクトリ構造からは見えない。

被害の程度を確認しようと ~/.gitconfig を調べたとき、思っていたより悪かった。

cat ~/.gitconfig
# → no such file

そのファイルは存在しなかった。古いマシンではメールの振り分けを設定していた。自分のプロジェクトには個人用メール、会社のリポジトリには仕事用メール。そのどれも生き残っていなかった。何か月も、それに気づかずコミットし続けていた。

コードは移行していた。だが設定は移行していなかった。

🔗 Unix で最も古いトリック

最初の直感はありがちなものだった。バックアップ用フォルダを作り、dotfiles をそこにコピーし、GitHub にプッシュする。

だがコピーは乖離する。.gitconfig のバージョンが2つ存在した瞬間、そのどちらかは嘘になる。そして何かが壊れるまで、どちらが嘘かはわからない。

その修正は、自分より古くからある。シンボリックリンクだ。

実在するファイルは一つだけ。それはリポジトリの中に住んでいる。ホームディレクトリが持つのはポインタだけだ。

~/.gitconfig  ──symlink──▶  ~/Developer/infra/dotfiles/.gitconfig
                            └── the ONLY real file

リポジトリの中にある実ファイル一つと、シンボリックリンクのポインタだけを保持するホームディレクトリ

これは変更を戻し続けて同期する必要があるということかと尋ねたとき、この設計全体を成立させる答えが返ってきた。

書き込みはシンボリックリンクを通る。

設定を変えるために git config --global を実行すれば、git はポインタをたどり、リポジトリ内のファイルに直接書き込む。~/.zshrc を編集すれば、それはリポジトリのファイルを編集していることになる。同期するものは何もない。なぜなら、同期元となるものがそもそも存在しないからだ。ファイルは一つ、住所は二つ。

だからそれを作った。自分のインフラ層の中に置いた dotfiles リポジトリで、実ファイルを保持し、新しいマシン上ですべてのシンボリックリンクを再作成する冪等な install.sh を備えている。見つかったものは何であれまずバックアップし、二回実行しても安全だ。

Git 設定、シェル設定、グローバル ignore、自分の AI ルール、救出した graphify スキル。コミット済み。プッシュ済み。恒久的。

一つのルールを、リポジトリに入りかけたものを検分して学んだ。秘密情報は決してコミットしない。 .zshrc は今、git が決して見ることのない ~/.zshrc.local を読み込むようになっている。リポジトリは構造を持ち、マシンは秘密を持つ。

実ファイルを保持する dotfiles リポジトリと、すべてのシンボリックリンクを再作成する install.sh

これで終わったと思っていた。

🍺 Homebrew は日記をつけない

それから、単純な問いに答えようとした。このマシンが壊れたら、何を再インストールすればいいのか?

git の設定には美しい性質がある。ツール自身が自分の設定ファイルに書き込むので、リポジトリは常に真実を知っている。Homebrew にはそんな行儀の良さはない。brew install jq はパッケージをインストールし、その決定をどこにも記録しない。パッケージ一覧は暗黙的に、/opt/homebrew/Cellar の中のフォルダとしてしか存在しない。

Homebrew はオンデマンドでマニフェストをダンプできる。

brew bundle dump --global --force
# → writes .Brewfile: brew "gh", brew "git", brew "tree"...

そこで、他のすべてと同じように .Brewfile をリポジトリにシンボリックリンクした。だが一つ引っかかることがあり、それが頭から離れなかった。

Brewfile が更新されるのはダンプすることを覚えていたときだけだ。

自分の設計全体は、自分が覚えていないという前提の上に成り立っていた。それこそがシンボリックリンクが存在する理由のすべてだ。自分の規律に依存するマニフェストは、必要になる頃には3か月古くなっているマニフェストだ。

🧪 最初の試みは間違っていた

最初の解決策は賢く感じられた。brew コマンドをシェル関数でラップする。すべてのインストールとアンインストールを捕捉し、マニフェストを再ダンプし、自動コミット、自動プッシュ。

それを作った。一発で動いた。

$ brew install jq
...
$ git log --oneline -1
df053cb chore(brew): install jq

美しい。コミットメッセージにパッケージ名まで入っている。

そして、それをもう一度見て、穴に気づいた。

シェルラッパーが捕まえられるのは自分の対話的シェルに打ち込まれたコマンドだけだ。依存関係をインストールするセットアップスクリプトは? 見えない。セッション内で brew を実行する AI エージェントは? 見えない。自分の zsh と自分の設定が読み込まれていないコンテキストは、すべて見えない。

自分はイベントを自動化する必要があったのに、インターフェースを自動化していた。

パッケージのインストールそのものがイベントであり、それは自分のシェルの中ではなく、ファイルシステムの中で起きる。

⚙️ OS はすでに答えを持っていた

自問した。これに launchd を使えないか?

launchd は macOS のサービスマネージャーだ。システム上のすべてのデーモンを起動しているもの、Apple にとっての systemd だ。Mac を触る開発者なら誰でも見たことがある。だが、その一番の機能を使っている人はほぼいない。

WatchPaths、launchd は OS レベルでパスを監視し、それが変化した瞬間にジョブを発火させる。

<key>WatchPaths</key>
<array>
    <string>/opt/homebrew/Cellar</string>
    <string>/opt/homebrew/Caskroom</string>
</array>

これでもう、誰がパッケージをインストールしたかは関係なくなった(自分のシェル、スクリプト、エージェント、何でもいい)。Cellar が変化し、OS がそれに気づき、自分の同期スクリプトが実行される。

# sync.sh, the whole engine
brew bundle dump --global --force    # snapshot packages
git add -A                           # stage any dotfile drift
git diff --cached --quiet && exit 0  # nothing changed? exit silently
git commit -q -m "chore: auto-sync ${CHANGED}"
git push -q                          # offline? commits locally, pushes next time

加えて、受け皿として正午の毎日実行と、5個のパッケージをインストールしても5個ではなく1個のコミットになるようにする60秒のスロットルも用意した。

そしてテスト。パッケージをインストールし、それ以外は何も触らず、見守る。

$ brew install jq
... 8秒後、シェルの関与ゼロで:
67ba27e chore: auto-sync .Brewfile
→ pushed to GitHub ✅

8秒。マシンが自分自身の変化を記録し、コミットし、自分が座っている間にプッシュしていた。

同期フロー: brew install が Cellar を変化させ、launchd が sync.sh を発火させ、それがコミットしてプッシュする

シェルラッパーは、その同じ分のうちに削除した。サードパーティのツールなし、タイマーでポーリングする cron なし、インストールが必要なデーモンなし。OS がずっと答えを出荷していた。

パッケージでそれがうまくいくと、次のステップは明らかだった。dotfiles 自体を監視する。今では .zshrc や自分の AI ルールを編集すると、ファイル名入りのコミットメッセージとともに1分以内に自動コミットされる。

🔁 危うく作りかけた罠

ここで一つ、これを作る誰もが噛みつかれる決断があるので、告白しておく価値がある。

怠惰なバージョンは、リポジトリのルートディレクトリを監視することだ。監視エントリ一つで、すべてをカバーできる。

だがすべての git コミットは**.git/ を変化させる**。リポジトリのルートを監視すれば、ウロボロスを作ったことになる。変化がエージェントを発火させる → エージェントがコミットする → そのコミットが .git/ を変化させる → それがまたエージェントを発火させる → 永遠に。

だからエージェントは個々のファイルを監視し、.git を含むディレクトリは決して監視しない。トレードオフ:深い階層にある真新しいファイルは即座には捕捉されない。代わりに毎日の定期実行がそれを拾い上げる。それは README に明記されており、公正な代償だ。

自分自身を発火させる自動化は、午前2時に見つかる類のバグだ。

🔔 話すことを教える

残っていた問題が一つあり、それは信頼にまつわるものだった。

エージェントは今、自分の代わりに黙ってコミットするようになっていた。静かなデーモンは、信頼されなくなるデーモンだ。さもなければ、存在すら忘れられる。

スクリプトに、macOS ネイティブな通知の2行を加えた。

osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'

最初のテスト:一度も見なかった。バナーは5秒で消え、自分は別の方を見ていた。

修正はコードではなく、一つの設定だった。macOS の通知には2つのスタイルがあり、ほぼ誰もデフォルトを変えない。システム設定 → 通知 → スクリプトエディタ → 通知(アラート形式)。アラート形式の通知は消去するまで画面に残り続ける。

これで同期のたびに、変更されたファイル名を添えたメッセージが、隅で自分が確認するのを待つようになった。自分のマシンの設定履歴が、レシートとして届けられる。

そして、実際に何かが変化したときにしか話さない。何もしなかった同期は静かなままだ。1分おきに届く通知は、すでに無視することを学んでしまった通知だ。

🎭 疑り深い問い

すべてがうまく動いたちょうどそのとき、こういうセットアップの大半を壊す問いを思いついた。

GitHub に 仕事用 アカウントでログインしたらどうなる?

調べてみると、「GitHub にログインしている」というのは実は3つの独立したレイヤーであり、ほとんどの人はそれを分けて考えたことがないとわかった。

レイヤー1:誰がコミットを書いたのか? git 設定だけで決まる、それだけだ。コミットはローカルのファイル書き込みであり、GitHub には連絡すら行かない。自分の includeIf ルールが、会社のリポジトリを仕事用メールに、それ以外すべてを個人用に振り分ける。CLI のログインはこれに触れられない。

レイヤー2:誰がプッシュを許可されるのか? macOS キーチェーンに入っている資格情報で決まる。そしてここが脆い部分だ。キーチェーンはホストごとに資格情報を保存する。2つのアカウント、一つの github.com。後のログインが前のログインを静かに上書きしてしまい、気づけば自分の同期エージェントが間違ったアイデンティティでプッシュし、403 を集め始める。

レイヤー3:gh CLI は誰として振る舞うのか? アクティブなアカウント次第。純粋な git を話す自分の同期には関係がない。

3つのアイデンティティレイヤー: 誰がコミットを書いたか、誰がプッシュできるか、gh CLI は誰として振る舞うか

レイヤー2が本当の露出面であり、修正は1行で済む。

git remote set-url origin https://paurushrai@github.com/paurushrai/dotfiles.git

その paurushrai@ はメールアドレスではない。URL 構文だ、ssh と同じ user@host の形式。URL にユーザー名が入っていると、キーチェーンはホスト + ユーザー名で資格情報をファイリングする。別々のアカウントは別々の引き出しに入り、上書きは起こり得ない。

これは、メールの振り分けの裏にあるのと同じ原則であり、こう言い表す価値がある。

アイデンティティは、リポジトリの属性であるべきだ。グローバルで可変な状態の属性ではなく。

🚀 10分マシン

物語はここに行き着く。

次に Mac の箱を開けるとき、手順の全体はこうなる。

# 1. Homebrew, then the two bootstrap tools
brew install gh git
gh auth login
# 2. Clone the config, link everything, reinstall every package
gh repo clone paurushrai/dotfiles ~/Developer/infra/dotfiles
~/Developer/infra/dotfiles/install.sh
brew bundle --global
# 3. Re-authenticate, credentials never migrate, by design
claude login

そして自分が本当に満足している部分は、install.sh が設定を復元するだけではないことだ。launchd エージェントもブートストラップする。

新しいマシンは、自分のセットアップのスナップショットを受け取るのではない。自分のセットアップを維持し続けるシステムを受け取り、1分目から自分自身を維持し始める。

意図的に除外したものも、それと同じくらい重要だ。

  • 資格情報、秘密情報のバックアップは、もう一度ログインすることだ。設計上、10秒で済む。
  • キャッシュ、セッション状態、ビルド成果物、自然に再構築される。これらをバックアップするのは、ただの溜め込みだ。
  • データ層、Postgres と Redis のボリュームは大きく、生きていて、変化し続けるバイナリだ。git は間違ったツールであり、稼働中のデータベースをコピーすれば壊れたゴミが出来上がる。データには本物のスナップショットバックアップが必要だ。それは次の記事のテーマだ。

🎯 実際に学んだこと

前回の記事の教訓はこうだった。あなたのワークステーションはシステムだ。設計せよ。

今週はその系を教えてくれた。規律で維持されるシステムは劣化する。自らを維持するシステムは複利で積み上がる。

この物語の各修正の形を見てほしい。

  • 設定ファイルをコピーし忘れるだろう → だからファイルは一つ、シンボリックリンクされていて、コピーはない
  • パッケージのマニフェストを更新し忘れるだろう → だから OS がファイルシステムを監視する
  • コミットし忘れるだろう → だからエージェントがコミットする
  • 静かな失敗を見逃すだろう → だから通知は自分が消去するまで待つ
  • 次のマシンでこれをまた全部セットアップし忘れるだろう → だからインストーラーが自動化そのものを再インストールする

そのすべてが、「覚えておく」を「勝手にそうなる」に置き換えている。

あのスキルファイルが Downloads フォルダの中で何か月も死んでいたのは、それを復元することが自分がその存在を覚えていることに依存していたからだ。もう自分のマシン上には、自分の記憶に依存しているものは何もない。

そして正直に言うと、それは自分がこれまで自分自身に出荷した中で、最高の機能だ。

あなたの設定層はどんな見た目ですか?

Dotfiles リポジトリは古いアイデアだ。だがほとんどは「リポジトリの中にファイルがあって、インストールスクリプトがある」で止まっている。初日の後もリポジトリを正しく保ち続ける部分こそが、面白くなるところだ。

ツールは変わり続ける。アーキテクチャはそれを生き延びるべきだ。