gitを始めた当初、最初コミットの概念がよくわからず理解がはかどらなかったことを思い出したのでここにまとめます。
単にコミットと書くと「コミットする」ことと「コミットした結果できる履歴」のどちらかがわかりにくいので、便宜上後者をコミットオブジェクトと呼びます。
大前提:コミットオブジェクトはあるディレクトリ以下の単なるスナップショット
cvs, subversionやbazaarなどとgitが根本的に違うのは、履歴が変更の積み重ねの記録ではなく単にその瞬間瞬間の全ファイルのスナップショットであることです。なので過去のコミットで作ったファイルなどを故意に削除などしても最新コミットオブジェクトを参照している限りは問題なく動きます(rebaseなど過去のコミットオブジェクトを参照する場合にエラーが起きそうですが)。
なぜこのような仕組みになっているかというとgitの開発者でlinuxの開発者でもあるlinusさんが「今のバージョン管理システムはmergeが遅すぎて使えない、もう自分たちで作るしかない!」といって開発し始めたことに関係があります。
これまでの差分の積み重ねベースのVCSは各コミットオブジェクトがそれより前のコミットオブジェクトに依存しており、merge操作などが非常に遅いという欠点がありました。
gitは各コミットオブジェクトの独立性を高めることでmergeなどが高速に行えるようになっています。
あれ、でもだとしたらcherry-pickすると変更の適応ではなく全ファイルの上書きになるんじゃないの?
コミットオブジェクトが特定ディレクトリ以下のスナップショットだとすると、cherry-pickやrebaseの動作には疑問が浮かびます。
単純に特定のコミットオブジェクトを引っ張ってきて今のコミットオブジェクトのあとにつけるとリポジトリ内のすべてのファイルがcherry-pick先のコミットオブジェクトの状態に上書きされてしまうんじゃないでしょうか?
ここがgitの巧妙かつややこしいところです。
各コミットオブジェクトは実は内部に直前のコミットオブジェクトへのポインタを持っており、cherry-pickコマンドを実行した場合は実際には対象のコミットオブジェクトとその直前のコミットオブジェクトで差分を取り、その差分を今のコミットオブジェクトへ適応してるのです。擬似的にコードっぽく書くとこんなかんじでしょうか。
val cherry-pick後のコミットオブジェクト =
今のコミットオブジェクト.apply(
diff(cherry-pick先のコミットオブジェクト.prev, cherry-pick先のコミットオブジェクト)
)
なのでcherry-pickは実際にはその指定したコミットオブジェクトを適応してるのではなく、指定したコミットオブジェクトとその直前との差分を適応してるんですね。
gitのサブコマンドの中にはこのcherry-pickのように実際にはスナップショットであるにも関わらず、コミットオブジェクトが差分に見えてしまうものがいくつもあります。
コミットオブジェクトをスナップショットだと理解すればcherry-pickしたコミットオブジェクトのハッシュが変わるのも自然
もしコミットオブジェクトがファイルシステムへの変更の差分だとすれば、cherry-pickする先のコミットオブジェクトのハッシュ値とcherry-pickしてできたコミットオブジェクトのハッシュ値は一致してないとおかしいです(同じ変更内容ならば同じハッシュ値になるはず)。
しかし、実際にはコミットオブジェクトはスナップショットなのでその両者は違ったハッシュ値になるわけです(両者のディレクトリ以下のファイル状態が違うため、スナップショットのハッシュ値も当然違う)
まとめ
gitのいろいろなコマンドを理解するためには以下を理解しておくといろいろはかどります
- コミットオブジェクトは実際にはgit initしたディレクトリ以下のスナップショット
- gitコマンドの中にはコミットオブジェクトを差分っぽく扱うものがあるが、実際には対象コミットオブジェクトとその直前のコミットオブジェクトの差分を見ている