「nix run .#update を回したら、git status に身に覚えのない untracked が大量に増えている」。symlink を貼るだけのはずが、回すたびにディレクトリの中にディレクトリが入れ子になっていって、最終的に dir/dir/dir/dir という循環 symlink が出来上がっている(しかも自分で書いたコードで)。新しい Mac で初回セットアップだけ activation 全体が落ちる現象もあります。同じ手順を踏んでいるはずなのに、結果だけが違う。原因は冪等性、しかも macOS の BSD ln 固有のクセまで絡んできます(Linux で動いていたんだから macOS でも動くはず、と思いきや、ここで足元をすくわれます)。
前回、macOS の開発環境を nix-darwin + Home Manager でコードに固める基本を書きました。home.file でディレクトリごと symlink を貼るだけなら宣言的に書けて、recursive = true を付けておけば中身がファイル単位で symlink されて、ツール側のキャッシュがリポジトリに書き戻る事故も防げる、という話です。ところが現実には home.file だけでは届かない領域があって、そこに踏み込むと急に「自分で活性化スクリプトを書く」世界に入ります(宣言的だったはずがいつの間にか bash)。Home Manager の home.activation です。
この記事は、home.activation で symlink を貼るときに何度もハマった3 つの落とし穴と、副次的に出てくる運用上の気づき 3 件を、実際の構成からそのまま引っ張ってまとめた中級者向けの実装メモです。home.file の基本パターンは前回の記事で扱っているので、本記事はその先、「activation script を冪等にするとはどういうことか」に集中します。
この記事で学べること
home.activationで symlink を貼るときに、初回未存在 / 既存実体保護 / BSDln循環の 3 落とし穴を回避する書き方lib.hm.dag.entryAfter [ "writeBoundary" ]の意味と、書き込み順を DAG で並べる考え方- macOS 限定の launchd 設定を
lib.optionalAttrs pkgs.stdenv.isDarwinで囲み、Linux 構成を巻き添えにしない方法 - iOS ビルド系ツール(xcodegen / swiftlint / fastlane)を
darwin/default.nix側に分離する設計判断
前提条件
nix-darwin+home-managerを flake で input 済みhome.fileのrecursive = true基本パターンを使ったことがある- 本記事のコードは Apple Silicon の macOS(
aarch64-darwin)で検証
なぜ home.file だけでは足りないのか
home.file = { ".config/ghostty" = { source = ./file/ghostty; recursive = true; }; } のようにリポジトリ内のソースを ~/.config/ 配下に配るだけなら、home.activation を書く必要はありません。home.file がやってくれます。じゃあ何で home.activation が必要になるのか、実際どうなの?というのが本記事の出発点です。
home.activation が必要になるのは、たとえば以下のようなケースです。
- 別リポジトリの実体を参照させたい(リポジトリ A の home-manager から、リポジトリ B のディレクトリへ symlink を張りたい)
- 初回セットアップで参照先がまだ無い可能性がある(clone 直後など)
- 複数の独立した「設定ディレクトリ群」をまとめて貼る(CLI ツールの設定一式、Markdown ドキュメント一式、
hooks/ディレクトリ配下のスクリプトなど) - 既存ファイルがシンボリックリンクでない実体だった場合に、保護したい
home.file は、リポジトリ内ソース → ~/.config/ 配下、という一方向の素直なケースに最適化されていて、上のような「他リポジトリ参照」「実体保護」「条件付きスキップ」あたりから急に窮屈になります。そこで home.activation の出番です。
# home-manager/home/default.nix
home.activation.setupClaudeCode = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
DOTFILES_CLAUDE="${config.home.homeDirectory}/ghq/github.com/your-org/dotfiles/claude-code"
CLAUDE_DIR="${config.home.homeDirectory}/.claude"
# ... ここに bash スクリプトが入る ...
'';
lib.hm.dag.entryAfter [ "writeBoundary" ] は、「home.file 等で実体ファイルがディスクに書き出された後にこのスクリプトを走らせる」という DAG 上の依存宣言です。home.file の symlink が出来上がる前に activation が走ると参照先が無い、という順序事故を防ぐためのものです。writeBoundary は Home Manager が用意している論理的な境界で、ここを基準に「前」「後」を並べます。
ここから先、よく踏む 3 つの地雷を順に潰していきます。
落とし穴 1: 参照先が「初回はまだ無い」ケースで全体を落とす
新しい Mac で初回セットアップをしたとき、自分のリポジトリ群はまだ clone されていません。home.activation の中で、~/ghq/github.com/<org>/<repo>/ のような別リポジトリの実体を参照するスクリプトを書くと、参照先ディレクトリが存在しない状態で ln -sfn が呼ばれて activation 全体が non-zero exit で終わります。
問題は、これが初回の nix run .#update だけで発生して、二回目以降は(手動で clone してから回せば)再現しないことです。再現条件が分かりにくく、「なぜか新しい Mac だけセットアップに失敗する」という、原因に辿り着くまで時間が溶けるタイプの不具合になります。
対策は単純で、参照先の存在チェックで早期 exit 0 し、警告だけ出して activation 全体は止めない設計にします。
DOTFILES_CLAUDE="${config.home.homeDirectory}/ghq/github.com/your-org/dotfiles/claude-code"
# 参照先が存在しない場合はスキップ(初回セットアップ時などを考慮)
if [ ! -d "$DOTFILES_CLAUDE" ]; then
echo "Warning: $DOTFILES_CLAUDE does not exist. Skipping Claude Code setup."
exit 0
fi
ポイントは exit 1 ではなく exit 0 を返すこと。activation script は exit code を見るので、ここで 1 を返すと他の何の問題も無いのに nix run .#update 全体が失敗扱いになります(「俺は何もしていない」と主張する権利すら無い)。「参照先が無い」を「失敗」ではなく「スキップ」として表現するのが正解です。
同じパターンは Codex 用設定の同期スクリプトでも使っていて、DOTFILES_CODEX ディレクトリが存在しない場合は警告だけ出して exit 0 します。activation script が複数のエントリに分かれていれば、片方が早期 exit してももう片方には影響しません(この設計判断は最後の節で扱います)。
落とし穴 2: 既存の「実体ディレクトリ」を symlink で上書きして中身を消す
二つ目はもっと怖い地雷で、リンク先がsymlink ではない実体ディレクトリだった場合に、無条件で ln -sfn すると、その中身が消えるか、状況によっては想定外の場所に書き込まれます(バックアップ取っていなかった日に限ってこれを踏みます)。
これが事故になりやすいのは、~/.claude/agents のようなツール固有のディレクトリです。ツール側がデフォルトで実体ディレクトリを作ることがあって、そこに後から home-manager の activation で「同じ場所に symlink を張りたい」となったとき、保護せずに ln -sfn すると既存ディレクトリが破壊されます。
対策は、「symlink であるか、そもそも存在しない」場合だけリンクを張り、それ以外は警告してスキップする判定を入れることです。
# ~/.claude/agents → skills repo の .claude/agents
SKILLS_AGENTS="${config.home.homeDirectory}/ghq/github.com/your-org/skills/.claude/agents"
CLAUDE_AGENTS="$CLAUDE_DIR/agents"
if [ -d "$SKILLS_AGENTS" ]; then
if [ -L "$CLAUDE_AGENTS" ] || [ ! -e "$CLAUDE_AGENTS" ]; then
ln -sfn "$SKILLS_AGENTS" "$CLAUDE_AGENTS"
else
echo "Warning: $CLAUDE_AGENTS exists and is not a symlink. Skipping (manual review needed)."
fi
else
echo "Warning: $SKILLS_AGENTS does not exist. Skipping agents symlink."
fi
[ -L "$CLAUDE_AGENTS" ] || [ ! -e "$CLAUDE_AGENTS" ] の || の右側、-e は「存在判定」で、symlink を辿った先の実体まで含めて見ます。-L で symlink を先に拾い、それ以外なら -e で「ファイルもディレクトリも何も無い」状態だけを許可する、というわけです。「実体が居座っていたら手動レビューに回す」という方針を、機械的に表現したのがこの 2 段判定なのですが、ここを横着すると後で泣くことになります。
同じ判定はマークダウン系ファイル(CLAUDE.md / PRINCIPLES.md / RULES.md などのドキュメント類)の同期でも使っていて、既存実体は rm で退避してから ln -sf を張り直す書き方になっています。
for f in CLAUDE.md PRINCIPLES.md RULES.md FLAGS.md README.md; do
target="$CLAUDE_DIR/$f"
if [ -f "$target" ] && [ ! -L "$target" ]; then
rm "$target"
fi
[ -f "$DOTFILES_CLAUDE/$f" ] && ln -sf "$DOTFILES_CLAUDE/$f" "$target"
done
ファイル単位の場合は「既存が実体ファイルなら削除してから symlink を張り直す」でも被害が出にくいですが、ディレクトリは中身ごと吹き飛ぶので、必ず判定でブロックしておきます。
落とし穴 3: macOS の BSD ln で循環 symlink を量産する
ここが本題で、macOS 固有のハマりどころです。末尾に / が付いたディレクトリパス + 既存の symlink に対して ln -sf を再実行すると、BSD 版 ln(macOS の /bin/ln)は既存 symlink をdereference して、その中に新しい symlink を作ります。
具体的に何が起きるかというと、~/.dotfiles/plugins/path_guard/ が既に対象ディレクトリへの symlink だった状態で、もう一度 ln -sf <src>/ <dst>/ を末尾スラッシュ付きで実行すると、~/.dotfiles/plugins/path_guard/path_guard という入れ子の symlink が生まれます。さらに次の nix run .#update で同じことが起きて、path_guard/path_guard/path_guard と階層が増殖していきます(毎日 1 階層ずつ自己複製していく、地味な恐怖)。
これは GNU coreutils の ln(Linux)では再現しません。BSD ln の dereference 挙動由来の現象です。同じスクリプトを Linux でテストして「再現しないから問題ない」と判断すると、macOS だけで毎回 git status の untracked が雪だるま式に積み上がります(しかも一見しただけでは原因が分からない種類の積み上がり方で)。
実際にこのバグを踏んで修正した記録があります。setupHermes という別の activation エントリで、plugin 用 symlink を生成していたコードが、まさにこの dereference 挙動で <name>/<name> という循環 symlink を毎回量産していました。nix run .#update を回すたびに untracked が増え続けるという、致命傷ではないが毎朝 git status を見るたびに溜息が出るタイプの不具合です。
対策は 2 つで、両方やります。
- パスの末尾
/を剥がす - 既存 symlink は
rm -fで必ず消してからlnし直す
# NG パターン (BSD ln が dereference してネスト)
ln -sf "$SRC/" "$DST/"
# OK パターン (末尾スラッシュ剥がし + 既存削除)
src="${SRC%/}"
dst="${DST%/}"
rm -f "$dst"
ln -s "$src" "$dst"
${SRC%/} は bash の suffix 除去で、末尾に / があれば剥がし、無ければそのまま返します。安全側に倒したいなら、ディレクトリ symlink を扱うときは常に剥がすクセを付けておくのが楽です。rm -f は「無くてもエラーにならない削除」なので、まだ無い初回でもこの行は安全に通ります。
これが macOS 固有である以上、CI を Linux だけで回していると一生気づきません。nix flake check だけで安心せず、実機の macOS で nix run .#update を 2 回連続で回して git status がクリーンなままかを見るのが、現実的な fence になります(「2 回回す」は今日から自分との約束にしてください)。
運用気づき 1: 別リポジトリの subagent 定義が解決されない問題
home.activation で symlink を扱う実装をした最近の例として、~/.claude/agents をユーザーグローバルな位置から別リポジトリの実体へ symlink で繋ぐ activation を追加しました。これも上で扱った冪等な symlink パターンの応用です。
何が問題だったかというと、ある自動化ツールの subagent 定義(YAML / Markdown 形式)が、その定義の置いてあるリポジトリを作業ディレクトリにしたときしか解決されない、という制約があった。実際の作業はだいたい別リポジトリ(コーポレートサイト、別案件のフロントエンドなど)で発生するので、起動直後に No such file or directory で止まる、という「ツールは悪くないのに使いづらい」状態になっていました。
ユーザーグローバルな ~/.claude/agents は作業ディレクトリ非依存で解決される、という性質を使い、home-manager の activation で「該当リポジトリの定義ディレクトリ → ~/.claude/agents」の symlink を貼って、どのリポジトリからでも定義が解決される状態に揃えました。先ほどの落とし穴 2 の「実体保護判定」をそのまま使っています。-L || ! -e の判定を入れて、既存実体は壊さない設計です。
教訓としては、ツール側が「グローバルな探索パス」と「ローカルな探索パス」を持っていて挙動が分かれる場合、home.activation で symlink を貼ってグローバル側に固定するのは有力な選択肢になります。再現条件が分かりにくい不具合は、たいてい「どこから起動したか」で挙動が変わるところに潜んでいます。
運用気づき 2: launchd 自動起動は pkgs.stdenv.isDarwin で囲む
ログイン時にバックグラウンドサービスを自動起動する設定を home-manager に入れる際、macOS の launchd を直に触ることになります。このとき必ず lib.optionalAttrs pkgs.stdenv.isDarwin { ... } で囲んで、Linux 構成に影響が出ないようにします。
launchd.agents = lib.optionalAttrs pkgs.stdenv.isDarwin {
some-background-service = {
enable = true;
config = {
ProgramArguments = [ "/bin/wait4path" "/Users/${username}/.local/bin/some-binary" ];
KeepAlive = {
Crashed = true;
SuccessfulExit = false;
};
ThrottleInterval = 30;
# ...
};
};
};
実際にバックグラウンドエージェントを launchd 化したときも、この囲みを入れています。KeepAlive の Crashed + 非0 exit と ThrottleInterval=30 を組み合わせて、Docker Desktop の起動遅延や一時的なネットワーク断にも自動復旧するようにしてあって、ログは ~/.<service>/logs/<name>.{out,err}.log に分離しています。これらは macOS でしか意味を持たない設定なので、Linux マシンで同じ flake をビルドしたときに無関係な設定が混ざるのは害悪です。
マルチプラットフォーム構成では「この設定はどの OS で有効か」を明示しないと、別 OS のビルドが巻き添えで壊れます。lib.optionalAttrs は属性セットを条件付きで存在させる関数で、isDarwin が false なら空セットになるので、Linux 側には何も漏れません。
副次的な気づきとして、同じ user account を複数 Mac で運用するケースで、両機で同じ launchd エージェントが走ると外部サービスへの multi-connect が起きて二重応答する、という事故もあります。対策として ~/.<service>/.primary のような opt-in marker file を置いた host でのみ実起動する設計に切り替えています。マーカー不在なら exit 0 で終了し、KeepAlive も発火しません。flake 側に host 別の分岐を持ち込まずに済むので、構成は両機で完全同一を維持できます。
運用気づき 3: iOS ビルド系ツールは darwin/default.nix 側に分離する
iOS ビルド系ツール(xcodegen / swiftlint / swiftformat / fastlane)は macOS 専用なので、共通パッケージリストではなく darwin/default.nix 側の swiftDevPackages に分離します。
# darwin/default.nix
{ pkgs, username, ... }:
let
packages = import ../common/packages.nix { inherit pkgs; };
# Swift/iOS 開発ツール (macOS 専用)
swiftDevPackages = with pkgs; [
xcodegen # project.yml から .xcodeproj を生成
swiftlint # Swift Lint
swiftformat # Swift Formatter
fastlane # iOS ビルド/署名/TestFlight・App Store 提出の自動化
];
in
{
environment.systemPackages =
packages.commonPackages
++ swiftDevPackages;
# ...
};
fastlane は Ruby 同梱の hermetic closure で配られるので、システムの Ruby を汚さずに使えます。xcodegen の project.yml → .xcodeproj 生成と組み合わせると、Xcode プロジェクトの差分が YAML だけになって、レビューしやすくなります。
設計の話としては、「どのパッケージがどのプラットフォーム専用か」を構成ファイルの場所で表現しておくのが大事です。darwin/default.nix に入っているなら macOS でしか使われない、common/packages.nix にあれば両 OS で使われる。コメントでもいいんですが、ファイル位置で区別したほうが後から読む人(だいたい半年後の自分)がコメントを読まずに済む、という地味だけど効く運用です。
似た話で、linux-builder の有効化(macOS 上で Linux 用 derivation をビルドするための VM)も darwin/default.nix 側に書きます。ephemeral = true を付けると VM は必要時のみ起動するので、RAM/CPU の常時占有を避けられます。これも macOS 固有の話で、Linux 構成には不要です。
設計トピック: activation script は用途ごとに分割する
最後に短く設計の話を。home.activation.setupClaudeCode と home.activation.setupCodex のように、用途ごとに別エントリへ分けて書くのがおすすめです。一つの巨大な home.activation.setupAll に詰め込まないこと。
理由は単純で、片方の修正でもう片方が巻き添えで落ちないからです。setupClaudeCode が落ちても setupCodex は走るし、逆も真。落とし穴 1 で「参照先が無いから早期 exit 0」と書きましたが、これも分割しているからこそ安全にできる選択肢です。一つのスクリプトに詰め込んでいると、途中で exit 0 するわけにいきません。
lib.hm.dag.entryAfter で DAG 上の順序も並べられるので、「setupB は setupA の後に走る」のような依存関係も宣言できます。分割しておいて損はない、というのが実装してみての感想です。
まとめ
home.file の基本パターンを越えて home.activation に踏み込むときは、symlink の冪等性を意識的に作り込む必要があります。(1) 参照先未存在で早期 exit 0、(2) -L || ! -e 判定で既存実体を保護、(3) 末尾スラッシュを剥がして既存 symlink を rm -f してから ln。この 3 つを毎回入れるだけで、macOS 固有の循環 symlink 量産も初回セットアップだけの失敗も避けられます。
運用面では、lib.optionalAttrs pkgs.stdenv.isDarwin で macOS 限定設定を囲み、iOS ビルド系は darwin/default.nix 側に分離し、activation は用途ごとに分割する。この 3 つを習慣にしておくと、「初回だけ失敗する」「git status に身に覚えのない untracked が増える」「Linux で flake が壊れる」あたりの、再現条件が分かりにくくて深夜に泣くタイプの不具合をだいぶ予防できます。
次の Mac が来たときに nix run .#update 一発で全部揃う、という体験は気持ちいいんですが、その気持ちよさは「冪等性が成立している」ことを地味に維持しているから手に入るものです。home.activation を書くときは、毎回この 3 落とし穴をチェックしながら書く。半年後の自分から「あの時の俺、ちゃんとしてた」と感謝される、地味だけど確実なリターンのある投資です。



