「(廣川政樹さんの競合コピー)」というファイルが Pods の中に増えているのに気づいたのは、ある朝ビルドが赤で止まったときでした。同じヘッダが二重に見えて、リンカが混乱している。前の晩まで普通に動いていたプロジェクトです。
犯人はクラウド同期でした。私はソースコードを Dropbox の中に置いて複数マシンで開発しているのですが、その同期がビルド生成物にまで手を伸ばし、書き込みの最中に割り込んで「競合コピー」を作っていたのです。ソースをクラウドに置くこと自体は悪くありません。落とし穴は、同期する必要のない生成物まで同期していたことでした。
同じ構成で開発している個人開発者は少なくないと思います。ここでは、私が Dolice のプロジェクト群でこの問題を根治した手順を残しておきます。
なぜ同期フォルダでビルドが壊れるのか
ビルドは、短時間に大量のファイルを生成・上書き・削除します。node_modules の展開、Pods のインストール、コンパイラの中間生成物。これらが書き込まれている最中に、クラウド同期クライアントが「変更を検知した」と反応してアップロードを始めます。
ここで2つの厄介が起きます。ひとつは、複数マシンやバージョン履歴との間で同じファイルに別々の変更が入り、同期クライアントが解決できずに競合コピーを作ること。もうひとつは、同期がファイルを一時的にロックしたりプレースホルダ化したりして、ビルドツールが期待した実体を読めなくなることです。
つまり壊れているのはコードではなく、コードの周りにある生成物の状態です。だから「クリーンして再ビルドすると直る」のに、数日するとまた壊れる。私の環境では、生成物を同期対象に含めたままだと、この再発率は体感でほぼ100%でした。原因が生成物の同期にある限り、対処療法では止まりません。
同期から外すべきディレクトリ
判断基準はシンプルです。再生成できるものは同期しない。 ソースと設定ファイルだけ同期し、コマンド一発で作り直せるものは全て除外します。
| ディレクトリ | 正体 | 再生成方法 |
| node_modules/ | npm 依存パッケージ | npm install |
| ios/Pods/ | CocoaPods 依存 | pod install |
| ~/Library/Developer/Xcode/DerivedData/ | Xcode 中間生成物 | ビルドで再生成 |
| ios/build/ · android/build/ | ビルド成果物 | ビルドで再生成 |
| .gradle/ · android/.gradle/ | Gradle キャッシュ | ビルドで再生成 |
| .expo/ · .expo-shared/ | Expo のローカルキャッシュ | 起動時に再生成 |
| *.xcworkspace/xcuserdata/ · .swiftpm/ | ユーザー固有状態・SPM 作業領域 | 不要/再生成 |
.git は同期しても致命傷にはなりませんが、私は履歴の競合を避けるため、クラウド同期とは別に GitHub をリモートとして使い、.git 自体も除外対象に含めています。この構成を推奨します。
Dropbox:拡張属性で個別に除外する
Dropbox には、フォルダごとに「同期しない」と指定できる拡張属性があります。macOS なら xattr コマンドで、対象フォルダに com.dropbox.ignored を立てます。手順は次の通りです。
- 既存の競合コピーを削除して状態を綺麗にする
- 対象フォルダに
com.dropbox.ignored の属性を立てる
- 属性が立っているかを
xattr -p で確認する
# 対象フォルダを Dropbox の同期対象から外す(macOS)
# 値に 1 を立てると、そのフォルダはローカルに残るが同期されない
xattr -w com.dropbox.ignored 1 ios/Pods
xattr -w com.dropbox.ignored 1 node_modules
# 立っているか確認する(1 が返れば除外済み)
xattr -p com.dropbox.ignored node_modules
# => 1
注意点として、除外してもフォルダはローカルに残ります。ビルドはこれまで通り動き、ただクラウドには上がらなくなります。既に競合コピーが散らばっている場合は、先にそれらを削除してから属性を立てると綺麗になります。
# 既存の競合コピーを一掃してから除外属性を立てる
find . -name "*競合コピー*" -o -name "*conflicted copy*" | while read f; do
echo "removing: $f"
rm -rf "$f"
done
iCloud Drive:命名規約と退避で外す
iCloud Drive には Dropbox のような専用属性がありません。代わりに2つの現実的な対処があります。
ひとつは、末尾が .nosync のファイル/フォルダは iCloud が同期しないという規約を使う方法。ただしフォルダ名を変えるとビルドツールが参照先を見失うため、生成物ディレクトリそのものには使いにくい。
もうひとつが確実です。プロジェクトの作業ディレクトリを iCloud の外に置く。 ソースは ~/Developer/ のような同期対象外の場所に置き、成果物だけを必要に応じて iCloud にコピーする運用にします。私はこの場合、リポジトリを丸ごと ~/Developer/ に clone し直す方を選びます。生成物を「外そう」とするより、生成物を「最初から同期領域に作らない」方が事故が少ないからです。
再発を止める:プロジェクト作成時に流す1本のスクリプト
一度手で除外しても、新しいプロジェクトを作るたびに忘れます。私は新規セットアップの最後に必ず流す小さなスクリプトを1本用意し、生成物を機械的に除外するようにしました。
#!/usr/bin/env bash
# dropbox-ignore-build-artifacts.sh
# プロジェクトルートで実行し、再生成可能なディレクトリを Dropbox 同期から外す。
set -euo pipefail
# 除外対象(再生成できるものだけを列挙する)
TARGETS=(
"node_modules"
"ios/Pods"
"ios/build"
"android/build"
"android/.gradle"
".gradle"
".expo"
".expo-shared"
)
for dir in "${TARGETS[@]}"; do
if [ -d "$dir" ]; then
xattr -w com.dropbox.ignored 1 "$dir"
echo "ignored: $dir"
else
# まだ生成されていないものは、空ディレクトリを作って先回りで属性を立てる
mkdir -p "$dir"
xattr -w com.dropbox.ignored 1 "$dir"
echo "pre-created & ignored: $dir"
fi
done
echo "done. 除外済みフォルダはローカルに残りますが同期されません。"
先回りで空ディレクトリを作って属性を立てているのが小さな工夫です。node_modules がまだ無い状態で属性だけ立てておけば、その後の npm install が展開する中身は最初から同期対象外になります。生成物が生まれる前に除外を確定させるのが、競合コピーを1つも作らせない解決策でした。
効いているかを確かめる
属性を立てたら、少し放置して観察します。私は3日ほど普段通りに開発とビルドを回し、競合コピーが1つも増えないことを確認しました。
# 競合コピーが増えていないか定期的に数える
find . \( -name "*競合コピー*" -o -name "*conflicted copy*" \) | wc -l
# => 0 が続けば成功
数字が 0 のまま張り付けば、同期とビルドの綱引きは終わっています。ダッシュボードのような派手な成果ではありませんが、朝いちばんにビルドが赤い、という一番やる気を削がれる事故が消えました。
まとめ
まずは今開いているプロジェクトで node_modules に競合コピーが混ざっていないか、find . -name "*競合コピー*" を一度流してみてください。1つでも見つかったら、それが不定期なビルド失敗の温床です。
個人開発では開発環境を自分で整える手間が全部自分に返ってきます。私自身、原因が掴めないまま「クリーンして再ビルド」を何度も繰り返していました。生成物を同期から外すという一手は地味ですが、一度決めれば静かに効き続けます。お読みいただきありがとうございました。