「nix run .#update を回したら、git status に身に覚えのない untracked が大量に増えている」。symlink を貼るだけのはずが、回すたびにディレクトリが入れ子になり dir/dir/dir/dir という循環 symlink ができます。新しい Mac で初回セットアップだけ activation 全体が落ちる現象もあります。原因は冪等性、しかも macOS の BSD ln 固有のクセまで絡みます。
前回、macOS の開発環境を nix-darwin + Home Manager でコードに固める基本を書きました。home.file でディレクトリごと symlink を貼るだけなら宣言的に書けます。ですが現実には home.file だけでは届かない領域があり、そこで Home Manager の home.activation を自分で書く世界に入ります。
この記事は、home.activation で symlink を貼るときに何度もハマった3 つの落とし穴をまとめた実装メモです。副次的な運用上の気づき 3 件も扱います。
| # | 落とし穴 | 対策 |
|---|---|---|
| 1 | 参照先が初回はまだ無い | 存在チェックで早期 exit 0 |
| 2 | 実体ディレクトリを symlink で上書きし中身消失 | -L || ! -e 判定でsymlinkか未存在の時だけリンク |
| 3 | BSD ln が既存 symlink を dereference し循環増殖 | 末尾 / を剥がし rm -f してから ln |
この記事で学べること
home.activationで symlink を貼るときに、初回未存在 / 既存実体保護 / BSDln循環の3落とし穴を回避する書き方lib.hm.dag.entryAfter [ "writeBoundary" ]の意味とDAGの考え方- macOS 限定の launchd 設定を
lib.optionalAttrsで囲む方法 - iOS ビルド系ツールを
darwin/default.nix側に分離する設計判断
前提条件
nix-darwin+home-managerを flake で input 済みhome.fileのrecursive = true基本パターンを使ったことがある- コードは Apple Silicon macOS(
aarch64-darwin)で検証
なぜ home.file だけでは足りないのか
リポジトリ内のソースを ~/.config/ 配下に配るだけなら、home.activation を書く必要はありません。home.file がやってくれます。じゃあ何で必要になるのか、というのが本記事の出発点です。
必要になるのは、別リポジトリの実体を参照、初回に参照先がまだ無い可能性、複数の設定ディレクトリ群をまとめて貼る、実体ファイルの保護、といったケースです。
home.file は一方向の素直なケースに最適化されています。
# 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" ] は、実体ファイルが書き出された後にこのスクリプトを走らせるという DAG 上の依存宣言です。writeBoundary は Home Manager が用意する論理的な境界で、ここを基準に前後を並べます。
ここから先、よく踏む 3 つの地雷を順に潰していきます。
落とし穴 1: 参照先が「初回はまだ無い」ケースで全体を落とす
新しい Mac の初回セットアップでは、自分のリポジトリ群はまだ clone されていません。別リポジトリの実体を参照するスクリプトを書くと、参照先が存在しない状態で ln -sfn が呼ばれ activation 全体が失敗します。
問題は、これが初回だけで発生し二回目以降は再現しないこと。対策は、存在チェックで早期 exit 0 し、警告だけ出して全体は止めない設計にすることです。
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 を返すこと。1を返すと他の問題が無いのに全体が失敗扱いになります。「無い」を「失敗」でなく「スキップ」として表現するのが正解です。同じパターンは Codex 用設定でも使っています。
落とし穴 2: 既存の「実体ディレクトリ」を symlink で上書きして中身を消す
二つ目はもっと怖い地雷で、リンク先がsymlink ではない実体ディレクトリだった場合に無条件で ln -sfn すると中身が消えます。~/.claude/agents のようなツール固有ディレクトリで、ツール側が実体ディレクトリを作った後に保護せず 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
-e は symlinkを辿った先の実体まで含めて見る存在判定です。-L で symlink を先に拾い、それ以外は「何も無い」状態だけ許可する2段判定です。
同じ判定はマークダウン系ファイル(CLAUDE.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
ファイル単位なら被害は出にくいですが、ディレクトリは中身ごと吹き飛ぶので必ず判定でブロックします。
落とし穴 3: macOS の BSD ln で循環 symlink を量産する
ここが本題で、macOS 固有のハマりどころです。末尾に / が付いたディレクトリパス + 既存の symlink に対して ln -sf を再実行すると危険です。BSD 版 ln(macOS の /bin/ln)は既存 symlink をdereference して、その中に新しい symlink を作ります。
具体的には、既に symlink だった状態で末尾スラッシュ付きで再実行すると path_guard/path_guard という入れ子が生まれます。次の実行では path_guard/path_guard/path_guard と増殖します。
これは GNU coreutils の ln(Linux)では再現しません。Linux でテストして「問題ない」と判断すると、macOS だけで untracked が積み上がります。
実際にこのバグを踏んで修正した記録があります。setupHermes エントリのコードが、まさにこの挙動で <name>/<name> という循環 symlink を毎回量産していました。
対策は 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 除去で、末尾の / を剥がします。rm -f は「無くてもエラーにならない削除」なので、まだ無い初回でも安全に通ります。
これが macOS 固有である以上、CI を Linux だけで回していると一生気づきません。実機の macOS で nix run .#update を 2 回連続で回し、git status がクリーンなままかを見るのが現実的な fence になります。
運用気づき 1: 別リポジトリの subagent 定義が解決されない問題
~/.claude/agents をユーザーグローバルな位置から別リポジトリの実体へ symlink で繋ぐ activation を追加した例です。これも上で扱った冪等な symlink パターンの応用です。
問題は、ある自動化ツールの subagent 定義が、その定義の置いてあるリポジトリを作業ディレクトリにしたときしか解決されないという制約があったこと。別リポジトリで作業すると起動直後に No such file or directory で止まっていました。
~/.claude/agents は作業ディレクトリ非依存で解決される性質を使い、symlink を貼りました。落とし穴 2 の実体保護判定(-L || ! -e)をそのまま使っています。
教訓は、ツール側の探索パスで挙動が分かれる場合、home.activation でグローバル側に固定するのが有力な選択肢になるということです。
運用気づき 2: launchd 自動起動は pkgs.stdenv.isDarwin で囲む
ログイン時にバックグラウンドサービスを自動起動する設定では 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 を組み合わせて自動復旧するようにしています。lib.optionalAttrs は属性セットを条件付きで存在させる関数で、isDarwin が false なら空セットになるので Linux 側には何も漏れません。
同じ user account を複数 Mac で運用すると、両機で同じ launchd エージェントが走り多重接続が起きる事故もあります。~/.<service>/.primary のような opt-in marker file を置いた host でのみ実起動する設計に切り替えています。マーカー不在なら exit 0 で終了します。
運用気づき 3: iOS ビルド系ツールは darwin/default.nix 側に分離する
iOS ビルド系ツール(xcodegen 等)は 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の常時占有を避けられます。
設計トピック: activation script は用途ごとに分割する
setupClaudeCode と setupCodex のように、用途ごとに別エントリへ分けて書くのがおすすめです。一つの巨大なエントリに詰め込まないこと。
理由は単純で、片方の修正でもう片方が巻き添えで落ちないからです。落とし穴 1 の早期 exit 0 も、分割しているからこそ安全にできる選択肢です。lib.hm.dag.entryAfter で DAG 上の順序も並べられるので、依存関係も宣言できます。
まとめ
home.file の基本パターンを越えて home.activation に踏み込むときは、symlink の冪等性を意識的に作り込む必要があります。(1) 参照先未存在で早期 exit 0、(2) -L || ! -e 判定で既存実体を保護、(3) 末尾スラッシュを剥がして rm -f してから ln。この 3 つで循環 symlink 量産も初回失敗も避けられます。
運用面では、macOS 限定設定を lib.optionalAttrs で囲み、iOS ビルド系は分離し、activation は用途ごとに分割する。深夜に泣くタイプの不具合を予防できます。



