🔄

双方向 sync の baseline をどこに置くか

に公開

はじめに

複数のマシンで同じ設定ファイル群を共有したい、というのはよくある話です。私の場合は Claude Code を Mac と Windows の両方で使っていて、グローバル設定・共通ルール・memory ファイル群・カスタムコマンドを両 OS で同じ状態に保ちたいという事情がありました。

これは本質的には dotfiles 同期と同じ「双方向 sync」の問題です。そして双方向 sync を素朴に自作すると、片側の編集が無音で消える事故が起きます。私の場合、実際にデータを上書きで失ったのは 2 回でしたが、安定する設計にたどり着くまでに 3 つの失敗の型を踏みました(2 つは実際に事故り、もう 1 つは実装する前に気づきました)。さらに、安定したと思っていた後日、今度は同期そのものが何日も止まる別種の事故も経験しています(記事の最後に後日談として書きます)。

この記事はツールにもOSにも依存しない設計の話です。リモートに何を使うか(私は git の private repo を使いました)や実装言語は本質ではありません。「双方向 sync で片側が消えるのはなぜか」「何を基準(baseline)に持てば防げるか」を、具体的なコードを出さずに整理します。同じものを自作しようとしている人、あるいは chezmoi・yadm などの既存ツールが内部で何をしているのか理解したい人向けです。


問題設定 ― 双方向 sync は何が難しいか

まず用語を決めます。

  • Mac/Windows ― 同期したい 2 台のマシン
  • リモート ― 中継となる共有リポジトリ(私の例では GitHub の private repo)
  • clone ― 各マシン上のローカル作業ツリー(同期処理が読み書きする実体)

両者がリモートを介して設定ファイルを同期します。

  • Mac で編集 → リモートへ反映 → Windows が取り込む
  • Windows で編集 → リモートへ反映 → Mac が取り込む

片方向(たとえば「常に Mac が正、Windows はミラー」)なら簡単です。難しいのは両側で編集が起こりうることです。「いまcloneにあるファイルが、ユーザーが編集した結果なのか、それとも単に古いまま取り込みが遅れているだけなのか」を取り違えると、新しい内容を古い内容で上書きしてしまいます。

同期対象は「両マシン共通で揃えるもの」と「マシンごとに別物で同期しないもの」に分けておきます(私の例では OS 依存の hook 設定などは後者)。以下の話は前者、共通で揃えたいファイルだけが対象です。


第1の事故 ― ミラー方式は「片側が正」を暗黙に仮定している

最初の実装は素朴なミラーでした。自作の同期スクリプトが走るたびに、cloneファイルの内容でリモート側の対象ファイルを強制的に上書きしてコミットする。差分があれば反映される。一見動きます(ここでいう反映は Git の通常の push ではなく、スクリプトがファイル内容を直接書き換える方式です)。

ところがある日、Mac で行った数ファイル分の編集が、Windows 側の同期処理ですべて消えました。リモートの履歴にはこういう流れが残ります。

14:48 Mac     Mac の編集を反映
15:55 Mac     Mac の編集を反映
16:57 Mac     Mac の編集を反映
17:01 Windows 古いcloneで上書き

ミラー方式には「cloneが常に正しい」という暗黙の前提があり、双方向同期では成り立ちません。片側にしか編集が起きないなら問題ないのですが、両側で編集しうる以上、取り込みが遅れている側の同期処理が必ず爆弾になります。

応急処置として履歴から復元したあと、「反映する前に取り込む」という方針へ切り替えました。


第2の落とし穴 ― 「反映前に取り込む」と編集中の側が消える

第1の事故のあと、次の手として考えたのが「同期処理の冒頭で、まずリモートの内容でcloneファイルを上書き(自作スクリプトによる強制取り込み)してから、cloneの内容を反映する」という方式です。ここでも「取り込み」は Git の安全な merge ではなく、スクリプトがリモートの内容でcloneファイルを書き換える処理です。これなら「取り込み遅れの側が他方の編集を消す」第1の事故は防げます。

ただ、この方式にはこの方式で、逆向きの穴があります。いま編集したばかりの側で同期処理が走ると、その編集が消えてしまうのです。

第1の事故が「取り込み遅れの側」で起きるのに対し、これは「いま編集した側」で起きます。実際に走らせる前に気づけたのですが、ここで見えてきたのは、「冒頭で無条件に取り込む」も「無条件に反映する」も、どちらも片側を犠牲にする、ということでした。

重要なのは、このとき編集していたのがたまたま Mac だっただけで、Windows が編集側になることもあるという点です。つまり「どちらが正か」を固定で決められないのが双方向 sync の本質で、実行のたびに『cloneとリモート、どちらが変わったのか』を判定するしかありません。


第3の事故 ― 「取り込む前の状態」を基準にすると区別できない

そこで「ファイル単位で、何が変わったかを比較する」方式にしました。各ファイルについて 3 つの状態を見ます。

  • BASE: 比較の基準となる内容
  • LOCAL: いまのcloneの内容
  • REMOTE: いまのリモートの内容(他方が反映した結果が入っているかもしれない)

新規作成や削除も、実装上は「内容が存在しない」という特殊な値を含めて同じ 3 点比較で扱えます。

そして 2 つの真偽値を作ります。

  • local_changed = (LOCAL が BASE と違う)
  • remote_changed = (REMOTE が BASE と違う)

この 2 つの組み合わせで「反映 / 取り込み / 衝突 / 何もしない」を決めれば、両側の編集を取り違えないはずです。問題は BASE に何を選ぶか、でした。

最初は BASE を「取り込む直前のリモートの状態」にしました。これが落とし穴です。リモートが最新になっているのにcloneがまだ古いままで同期処理が走ると、BASE もリモートと同じ最新内容になるので、LOCAL ≠ BASE を「編集された」と誤検出し、古いcloneで最新のリモートを上書きしてしまいます。

「cloneがまだ最新に追いついていない(未追従)」状態と、「cloneがユーザーによって編集された」状態が、『取り込む直前のリモート』を基準にすると区別できない。どちらも「LOCAL ≠ BASE」に見えてしまう。

「取り込む直前」という基準は、その時点ですでに他方の編集を含んでいることがあり、基準として遅すぎたのです。


最終解 ― 「前回同期が成立した時点」を基準にする

答えは、BASE を「取り込む直前」ではなく 「このマシンで前回、同期が確実に成立した時点のリモートの状態」 にすることでした。

各マシンが「前回の同期成立点」をローカルに記録しておきます(リモートには共有しない、マシンごとの状態)。同期が成功するたびにこの記録を更新します。判定はこの記録を BASE にして行います。

先ほどの誤検出シナリオを、新しい BASE で追い直すと、LOCAL = BASE(cloneは編集していない)と REMOTE ≠ BASE(リモートが進んだ)が正しく判定され、取り込み side でcloneが最新へ更新されます。

「未追従」と「編集」がはっきり分離できるようになりました。鍵は 基準を『時点』として固定し、その時点をマシンごとに覚えておくことです。「いま取り込む直前」のような動く基準ではいけません。

4 状態の判定表

local_changed と remote_changed の組み合わせは基本 4 通りですが、両方 true の場合だけ LOCAL と REMOTE が同じかどうかで扱いを分けます。

cloneの変化 (local) リモートの変化 (remote) アクション
なし なし 何もしない
あり なし 反映(clone → リモート)
なし あり 取り込み(リモート → clone)
あり あり・内容が違う 衝突(CONFLICT) ― 自動解決せず両方を温存
あり あり・内容が同じ 同期成立として BASE を更新(内容は変更しない)

衝突を検出したときは「前回同期成立点」の記録を更新しません。cloneとリモートを両方ともそのまま残します。記録を更新しなければ、次回の同期でも同じ衝突が再検出されるので、ユーザーが手動で解決するまで状態が保たれます。


conflict を自動解決しないのはなぜか

両側が同じファイルを別方向に編集していた場合、片方を機械的に採用するロジックを書きたくなりますが、書かない方がいいです。

設定ファイルの同期では、どちらの編集も意図のある変更であることがほとんどです。機械が「新しい方を採用」「行数が多い方を採用」のような基準で片方を捨てると、捨てられた側の編集は無音で消え、ユーザーは消えたことにすら気づけません。第1〜第3の事故と同じ構図です。

だから衝突時は何も解決しません。両方の現状をそのまま残し、ログに「衝突した」とだけ書く。あとは人間が中身を見て手でマージする。同期ツールにできる最善は「壊さないこと」と「気づかせること」であって、「賢く解決すること」ではない、というのがこの設計の立場です。

ただし、頻繁に両側で編集されるファイルは衝突が出やすく、放置すると両マシンの内容が乖離していきます。「衝突が出たら手動マージする」という運用が前提になる点は、自動同期の限界として理解しておく必要があります。


第4の事故(後日談)― 同期対象ですらないファイルが、同期を止める

ここまでで上書き事故は構造的に解けたはずでした。ところが安定運用に入った後日、同期が 2 日間まったく成立しない、という別種の事故が起きました。

原因は、同期対象に含めていないファイル(セットアップ手順のメモ)の編集が、clone の作業ツリーにコミットされないまま残っていたことでした。git pull --rebase は作業ツリーに未コミット変更があると、それだけで実行を拒否します。同期対象外なので処理が触ることもなく、毎回同じ場所で弾かれ続けていました。データは壊れていません(中断するので「壊さない」設計は効いていた)が、両マシンの内容は乖離し続けます。

たちが悪いのは、ログには「rebase conflict(取り込みの内容衝突)」とだけ書かれていて、本当の原因(対象外ファイルの未コミット変更)が見えなかったことです。だから 2 日間気づけませんでした。

対処は 2 つ。取り込みの前に未コミット変更を --autostash で一時退避することと、退避したファイル名をログに明示すること。同期の正しさは「同期対象のファイル」だけでは閉じず、clone の作業ツリー全体が綺麗である必要がある、と気づかされた事故でした。


まとめ

双方向 sync を自作するときの要点は、ツールや言語によらず次の点に集約できました。

  • ミラー(片側が正)で書かない。 両側で編集が起こりうる前提で、毎回どちらが変わったかを判定する
  • 基準(BASE)を「前回同期が成立した時点」に固定する。 「いま取り込む直前」のような動く基準では、未追従と編集を区別できない
  • clone・リモートそれぞれが基準から変化したかの 2 値で、反映 / 取り込み / 衝突 / 何もしない を分ける
  • 衝突は自動解決せず両側を温存し、基準点も更新しない。 ツールの仕事は「壊さない」と「気づかせる」まで
  • 同期対象外のファイルでも、ローカル clone の作業ツリーが汚れていれば同期は止まる。 取り込みの前に未コミット変更を退避し、止まった理由をログに正確に書く(誤った文言は「気づかせる」を無効にする)
  • 机上の検証では完結しない。 「これで穴はない」と思った直後ほど新しい穴が出る。実機で再現テストを通すまでは検証が終わっていないと扱う

リモートに git を使えば「内容のハッシュ」や「ある時点のファイル内容」は標準機能で取れるので、最小構成なら小さく書けます。汎用の dotfiles 同期ツールでも同等のことは実現できますが、扱うパスやマシンごとの差分が特殊だったため、私は最小限の自作で済ませました。要は、どんな実装でも「前回同期した時点」という固定基準を持てているかどうかが、片側を消すか消さないかの分かれ目です。

Discussion