はじめに
こんにちは、株式会社インティメート・マージャー新卒 1 年目の @Elise3993 です。
今回はレビューが通った PR をマージしたら、何故か関係ないファイルが消滅した話について、原因や解決策も交えて話そうと思います。
経緯
これはとある社内システムの新規機能を開発していた時の話です。
PR が無事に承認され、意気揚々とマージボタンを押した数日後のことでした。手元のコードエディタで README の些細な修正を行い、PR を出そうとなんとなく develop ブランチを確認しに行くと……
「...あれ?過去に実装した機能関連のファイルが見当たらない...」
確かに数日前、別の機能を develop にマージしたはずです。(便宜上、この「別の機能」を機能 A とします)
しかし、そのファイル群が消えています。
原因として考えられるのは、さきほどマージしたばかりの「機能 B」の PR くらいです。
当然、機能 A を消した覚えはありません。もしそんな変更が入っていたのであればコードレビューの段階で弾かれているはずです。
機能 B の PR 画面を確認しても、ファイルの変更差分に機能 A を削除するような表示は一切ありませんでした。
しかし、Git のログを追うと、確かに機能 B のマージによって「機能 A の実装内容を完全に打ち消す変更」が適用されていました。
再現
こちらは後から本事象を再現したものになります。
すでに develop には機能 A がマージされています。

GitHub 上でみると、develop とブランチ B のファイルの差分は feat_b.py だけで、このままだと develop は機能 A と機能 B の両方が実装されそうに見えます。

しかし、実際にマージしてみると機能 B のみが残り、機能 A は忽然と消えてしまっているのです。

ここで develop のマージコミットを確認してみると、機能 A の削除がされていることがわかります。混乱の原因はここで、PR の更新ファイル上ではファイルが消えることがわからないのに、実際にマージされると消えてしまうのです。

なぜファイルが消えてしまったのか
結論から言うと、ローカル環境での一時的なマージとマージコミットのリバートが原因でした。
登場ブランチ
- develop: 開発のメインブランチ
- ブランチ A: 数日前にマージされた機能
- ブランチ B: 今回マージして事故を起こした地雷ブランチ
当時、私はブランチ A とブランチ B を並行して開発していました。ブランチ B のコードレビューにて「ブランチ A と同じ実装方針にした方が良いのでは?」という指摘をいただきました。
「じゃあ、実装の参考にするためローカル環境で一時的に A を B にマージして、同じエディタで確認しながら作業しよう」と考えた私は深い考えもなしに、開発環境でブランチ A を B に一時的にマージし、修正作業後にマージコミットを revert することにしました。すなわち、
- ローカルのブランチ B に、ブランチ A を
git merge - ブランチ A とブランチ B のコードを比較しつつ修正を実行
- コードを参考にした後、「マージした状態を元に戻す」ために
git revert -m 1 <merge_commit_id>を実行
という形で進めていったのですが...
これが失敗でした。
revert は変更を取り消す操作ではありません。指定したコミットの変更を打ち消すように上書きする操作です。
つまり、ブランチ B には ブランチ A の変更内容を「削除する」というコミット が入ってしまいました。これを develop にマージした結果、既に develop に存在していたブランチ A のファイル群が、正規の手順で「削除」されてしまいました。
なぜ PR の変更差分(Diff)に表示されなかったのか
これは、GitHub の PR の変更差分表示の仕様によるものでした。
git diff コマンドには、ツードット (git diff A..B) とスリードット (git diff A...B) という 2 つの比較方法があります。 GitHub に対する pull request ではスリードットの差分が表示されます。
スリードット比較では、両方のブランチ (マージ ベース) の最新共通コミットとトピック ブランチの最新バージョンの差分が表示されます。
つまり、PR の差分で見えていたのは、「マージ先(develop)とマージ元(ブランチ B)の比較」ではありません。「二つのブランチが分岐した地点(共通の祖先)」と「マージ元(ブランチ B)」の比較だったのです。
こちらは develop にブランチ B がマージされる直前の、git tree を簡略化したものです。develop にブランチ B をマージする際にファイルを比較していたのは、丸で囲んだ 2 つの地点の情報だったというわけですね。
現在の develop に A のファイルは存在するけれど、ファイル差分上では develop 側に存在していないのです。なので、ブランチ B の「ブランチ A の変更内容を削除する」によって差分が発生しなかったというわけでした。
解決策
こちらの記事を参考にし、A のマージコミットの revert をさらに revert し、develop にマージすることで、無事解決に至りました。
今回は開発環境(develop)での発覚だったため事なきを得ましたが、これがもし本番環境へのデプロイ後に発覚していたら……、CI/CD で本番環境に影響が出ていたら……と考えるとゾッとします。
対策
どうすれば、今回の事態を防げたのでしょうか?
取り消すこと前提の「一時的なマージ」は行わない
どのような状況であれ、「あとで取り消す」ことを前提とした一時的なマージは避けるべきでした。
マージは変更を取り込む操作であり、Git の履歴を変更してしまいます。「あとで取り消せばいい」という考えは、操作ミスや履歴の汚染、そして今回のようなファイル消失事故のもとになります。
マージせずに他ブランチを参照するには
それでは、別のブランチの内容を同時に参照するにはどうしたら良かったのでしょうか?
- 参照したいファイルだけコピーして、後で消す
- 別ディレクトリに
git cloneする
といった力技しか思いつかず困っていたところ、弊社の CTO から「今なら git worktree を使うともっと楽だよ」と教えていただきました。
git worktree を活用する
通常、1つのリポジトリにつき、チェックアウトできるブランチは1つだけです。しかし、Git の標準機能である git worktree を使うと、同じリポジトリに紐づいた別の作業ディレクトリを作成し、そこで別のブランチをチェックアウトできます。
今回のように「作業はブランチ B で進めつつ、ブランチ A の中身も参照したい」という場合、作業ブランチをブランチ B にした状態で、以下に続くようにコマンドを実行します。
まず、add コマンドで「展開先のパス」と「見たいブランチ」を指定します。以下は、現在のディレクトリの親階層に ../feat_a_ref というフォルダを作って展開する例です。
# git worktree add <展開先のパス> <見たいブランチ名>
$ git worktree add ../feat_a_ref feat_a
これで、../feat_a_ref にブランチ A のファイル群が展開されます。
メインのエディタとは別に、このフォルダを開けば、自由にコードを参照・コピペできます。
参照が終わったら、remove コマンドでワークツリーを削除します。
# git worktree remove <addで指定したパス>
$ git worktree remove ../feat_a_ref
こうすることで、メインの作業ブランチの履歴を一切汚さずに安全に確認作業ができます。
まとめ
今回のやらかしの根本的な原因は、Gitに対する私の理解の甘さにありました。
入社して半年が経ち、add, commit, push, merge といった基本的な Git 操作がなんとなくで出来るようになっていた時期だったからこそ、Git の一操作一操作がどう影響するかを軽視していたと感じます。
本番環境を一瞬で破壊しかねない強力なツールを扱っていることを忘れずに、コマンドを打つ前に「この操作で Git のグラフがどう変化するか」といったことを考えていかなければと思いました。
Advent Calendar の投稿はまだまだ続くので、今後もぜひお楽しみにしておいてください!

