仕組みから理解するgit rebase

  • 75
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

こんばんは!

Git Advent Calendar 2015の初日を担当させていただきますtrebyです。初日からコケるのではないかと心配されていた皆様お待たせしました。そしてごめんなさい。(12/1は23:59までなのでまだセーフです……よね?)

この記事ではcommitとはなんなのかという視点からGitの仕組みについて紹介し、その上でrebaseを業務で使いこなすためのテクニックを一つ紹介します。お楽しみください!

Gitの内部構造

Gitは分散型のバージョン管理システムであるということは今さら説明の必要はないかと思います。
バージョン管理というのはここでは、誰が何を変更したのかということを記録していくことであり、分散型というのは、バージョン管理対象のプロジェクト(ソースコードなどのドキュメントのまとまり)が記録される場所、つまりリポジトリが複数存在しうるということでしたね。

では、Gitでは履歴情報を変更の差分としてではなく、その時点でのスナップショットを丸ごと保持していることをご存知でしょうか。

プロジェクトの変更履歴を管理する際、差分で記録を残そうとするアプローチは割と自然なことです。
イメージとしてはプロジェクトの初めの状態から変更履歴という名のパッチの連なりをどんどん適用していけば最終的に今の状態に復元できるというもので、多くのVCS(バージョン管理システム)で採用されている考え方です。

他方、Gitではプロジェクトの変更履歴を前後の差分ではなく、その時々のスナップショットとして丸ごと記録します。すなわちバージョン管理を考えた時の自然な発想である、「差分を保存していく」というアプローチを採用していません

もちろんスナップショットといっても、毎度のコミットの度にプロジェクトの全ファイルを保存していてはすぐにディスク容量が足りなくなってしまいます。そこでGitでは変更のあったファイル、ディレクトリの情報のみ新規作成して保存するというアプローチが取られています。

リポジトリに記録されるオブジェクト

もう少し掘り下げてみましょう。

そもそもGitでは、すべてのものがリポジトリにオブジェクトとして保存されます。
ここでオブジェクトとは

  • blob …… 単一のファイル等を表す
  • tree …… ディレクトリを表す
  • commit …… 履歴そのものを表す

の三つのことを指します。

特にblobとtreeは普段使っているディレクトリ(フォルダ)、ファイルといった一般的なファイルシステムの考え方と同じなので直感的かと思います。tree(ディレクトリ)は複数のtreeもしくはblob(ファイル)を持ち実際のコードなどはblobに含まれます。

commitの実体

VCS特有の概念といえるのがcommitオブジェクトです。commitはスナップショットそのものを指すといって差し支えないでしょう。情報としては、

  • 前のcommitへの参照
  • プロジェクトのルートを表すtreeへの参照
  • 変更を行った人(author)と変更日時
  • コミットを行った人(commiter)とコミットした日時
  • コメント

といったものが含まれます。

commitにプロジェクトルートのtreeの情報があるから、その時の状態を簡単に特定できますし、前のcommitへの参照があるからこそ変更の履歴をたどることができます。さらに人や時間やコメントという情報が含まれるからこそcommitの背景や意図を知ることができるのです。

さて、commitオブジェクトの説明において「参照」という表現を使いましたがcommitを含むGitのオブジェクトは全て40桁の16進数で表されます。前に「参照」としていたのはこの40桁の16進数のことで、値はblob、tree、commitのそれぞれの内容(中身については後述)をSHA-1ハッシュ化することで求めることができます。

gitの動きを観察する

何はともあれ実際に試しながら見ていくのが理解への近道かもしれません。

適当にプロジェクトディレクトリを作り、その中に何かファイルを作ります。

$ mkdir mixnuts && cd mixnuts
$ echo 'Dear...' > konomi.txt
$ git init
Initialized empty Git repository in /Users/treby/mixnuts/.git/

git initコマンドは実行したディレクトリ直下にリポジトリ(.gitディレクトリのこと)を作成します。
すなわちこのコマンドを実行した場所がプロジェクトのルートとして扱われ、プロジェクトルート以下のファイルがバージョン管理対象となります。

$ tree .git/
.git/
:
├── objects
│   ├── info
│   └── pack
:

リポジトリを作成した段階では、当然ですがリポジトリに記録されているものはありません(objectsディレクトリ以下に特にめぼしいものがありませんね)。

commitの前段階としてステージングエリアにあげるのがgit add .コマンドです。

$ git add .
$ tree .git/
.git/
:
├── objects
│   ├── 73
│   │   └── 29739edb85570f42421100a14b49724ed3e798
│   ├── info
│   └── pack
:

実行後に改めてリポジトリを確認してみると何やらオブジェクトができています。そうです、これがまさしく先ほど作成したファイルそのものなのです。とはいえ、中身はzlib圧縮されているためそのままでは意味不明になります。さくっと解凍してから覗いてみましょう。

$ ruby -e 'require "zlib"; open(".git/objects/73/29739edb85570f42421100a14b49724ed3e798") { |f| puts Zlib::Inflate.inflate(f.read).inspect }'
"blob 8\x00Dear...\n"

実際にはblobオブジェクトにはそれがblobオブジェクトで表す文字列とオブジェクトのサイズ、そしてファイルの中身が含まれます。オブジェクトのファイル名は上記の内容のSHA-1ハッシュを計算することで求められます。

$ ruby -e 'require "digest/sha1"; puts Digest::SHA1.hexdigest("blob 8\x00Dear...\n")'
7329739edb85570f42421100a14b49724ed3e798

ちなみにハッシュ値の最初2桁がディレクトリ、残りがファイル名となっているのはファイルシステム上の都合です。最初2桁をディレクトリ名にしてオブジェクトをある程度分散させることで1つのディレクトリ以下に大量ファイルを置かれないようにするロードバランスの意味合いがあるようです。

次にgit commitを実行してみます。

$ git commit -m 'Sexy leader!'
[master (root-commit) d1e40aa] Sexy leader!
 1 file changed, 1 insertion(+)
 create mode 100644 konomi.txt
$ tree .git/
.git/
:
├── objects
│   ├── 73
│   │   └── 29739edb85570f42421100a14b49724ed3e798
│   ├── d1
│   │   └── e40aa45e0c7b96a64ce6b9232455e59356fac4
│   ├── f1
│   │   └── 9b924c9746e36353cfb1d284e5af6db14a4d1f
│   ├── info
│   └── pack
:

新たに増えている二つのオブジェクトについて、git commit時のアウトプットにもでているd1e40aa..というのがcommitオブジェクト、他方のf1から始まるディレクトリ以下にあるファイルがプロジェクトルートへのtreeオブジェクトです。

これらオブジェクトの中身をblobの時のように構造に即したスクリプトを書いて調べても良いのですが、記事が長くなってしまうのでサクッとgit cat-fileコマンドを使います。

$ git cat-file -p d1e40aa
tree f19b924c9746e36353cfb1d284e5af6db14a4d1f
author Hiroaki Ninomiya <ninomiya@example.com> 1448961735 +0900
committer Hiroaki Ninomiya <ninomiya@example.com> 1448961735 +0900

Sexy leader!

$ git cat-file -p f19b924
100644 blob 7329739edb85570f42421100a14b49724ed3e798    konomi.txt

簡単に中身を覗くことができました。commitオブジェクトには前述したような内容が、treeオブジェクトには、プロジェクトルートに含まれているtreeやblobといった情報(ここでは1個blobがあるのみですが)が含まれていることがわかりますね。

ここから(プロジェクトを成長させていく想定で)編集とcommitを続けていっても

### 2番目のcommitを作る
$ mkdir songs
$ echo "M I X, N U T S" > songs/dreamtraveler.txt
$ git commit -m "Twinkle"
[master 8c91944] Twinkle
 1 file changed, 1 insertion(+)
 create mode 100644 songs/dreamtraveler.txt

### 3番目のcommitを作る
$ echo "5/22" > mami.txt
$ echo "6/12" >> konomi.txt
$ git commit -m 'birthdays'
[master c90308a] birthdays
 2 files changed, 2 insertions(+)
 create mode 100644 mami.txt

各オブジェクトのhash値さえ分かれば様々な情報を追うことができます。
例えば任意のcommitにはその親となるcommit hashが含まれていますし(ここで示している子から親への参照の連なりがGitにおける「歴史」を作ります)、

### 2番目のcommit hashを指定
$ git cat-file -p 8c91944
tree 657e7c499a9acc5682b7f194f5b6727a8b8cd906
parent d1e40aa45e0c7b96a64ce6b9232455e59356fac4   # ← 最初のcommit hash
author Hiroaki Ninomiya <ninomiya@example.com> 1448964263 +0900
committer Hiroaki Ninomiya <ninomiya@example.com> 1448964263 +0900

Twinkle

### 3番目のcommit hashを指定
$ git cat-file -p c90308a
tree 664a51525b54d16a7ebe4ef1d72abc4bbd25024f
parent 8c919445f6922da14a32c10c83218ddb4f369a6a   # ← 2番目のcommit hash
author Hiroaki Ninomiya <ninomiya@example.com> 1448964443 +0900
committer Hiroaki Ninomiya <ninomiya@example.com> 1448964443 +0900

birthdays

commitオブジェクトのtree hashを追っていけば、commitした時点でのプロジェクトの状態が特定できるようになっています。

### 3番目のcommitに含まれるtree hashを指定
$ git cat-file -p 664a515
100644 blob 4d21655ba254de8a77b971353676dfc5ee47a34f    konomi.txt
100644 blob f929557c1f02fb6ff5521da5f27c93b2f2b7eb3b    mami.txt
040000 tree 85d9d3a54f21f2f3c1f5d484b3222ab347a2bac3    songs

### さらに'konomi.txt'の blob hashを指定
$ git cat-file -p 4d21655
Dear...
6/12

ちなみに上のサンプルでは各オブジェクトを指定する際にhash値の一部のみしか指定していませんが、これは「hash値の先頭からある程度の長さがあり、かつ一意にオブジェクトが識別できれば、あとは勝手にgitが補完してくれる」というgitの仕様を利用しています(手元で確かめた感じだと4桁くらいからいけるようです)

このような感じで、細かい部分ではさらにオブジェクトを圧縮したりといった工夫がなされているのですが、Gitの基本はこんなところになります。蛇足ですが、オブジェクトの構造が分かれば自分でcommitが作れます(手前味噌ですが)。

業務に応用する

さて、以上が仕組み的な話となります。次にどうやって業務に応用するかについて典型的なものを一つ挙げます。

ここまでの知識があれば普段使っているコマンドで何をしているのかイメージしやすくなりますし、commitさえしていれば例えmasterブランチを削除してしまってもなんとかなりそうなことが想像つきます。

branchやtagのそれ自体はcommit hashを保存しておくもの(commitへのポインタ)でしかありません。故にcommitをはじめとするオブジェクト、.gitディレクトリ(リポジトリ)が無事ならなんとかなります。

肝心のcommit hashがわからない場合は、git reflogなどを駆使すると良いでしょう。commitしていない場合は、どうしましょうかね……がんばってください><

想定ケース:開発するうちに変更が大きくなってきたため、分割してリリースすることにした

最初は一つのまとまりとしてリリースしたかったが、開発を進めているうちにどんどん変更が大きくなってしまった。

しかも変更の少なくない部分は本筋というよりは、本筋を分かりやすくするためのリファクタリング成分を多く含んでいる。この状態で一度に全ての変更を出すと何かあった時の原因の切り分けが難しいため、先にコードの挙動に変わりのない(はずの)リファクタリング成分だけリリースしたい。

リファクタリング分のリリースで問題なければ、その上に成り立つ挙動改修のコードをリリースする。

以上のようなケース、よくありますよね?私自身、何度か経験があります。
この時のbranch構成としては、メインのbranchから分岐させたリファクタリングbranchと、そこから分岐させた改修branchというものが出てきます。

rebaseしたbranchにrebaseしようとすると上手くいかない

人それぞれの好みですが私は個人的に、最近ではメインのbranch以外はどんどんcommitをまとめちゃいたいと考える派です。
commitはしたのだけど、後から軽微なバグやtypoが見つかった場合、メインのbranchに混ぜていない限りは割とカジュアルにrebase & forced pushを利用します。

メインのbranchから分岐させたbranchのみを取り扱っている場合、この運用は特に問題ありません。
しかし、今回のようにメインのbranchから派生させたbranchとさらにそこから派生させたbranchを並行して取り扱う場合、状況によっては思い通りの結果が得られないことがあります。

一例として、リファクタリングのbranchの歴史を明快にするためにrebaseを使って修正することを考えましょう。
この時単純に、

$ git checkout refactoring
$ git rebase -i master
:
ここでごにょごにょ行う
:
$ git checkout improve_after_refactoring
$ git rebase refactoring

のようにしても、conflictが発生して上手くいきません。

rebaseを理解する

git cherry-pickというコマンドがあります。これは別のbranchで行った修正を一部取り込みたい場合や特定のcommitを文字通りつまみ食いしたい時などに使います。

挙動としては対象のcommitとその親commitとのそれぞれのスナップショットのdiffをとり、そのdiffをパッチとして今のプロジェクトに当ててコメントなどはそのままに新しいcommitを作成するということが行われます。

簡単にいってしまえば、任意のcommitでの変更分を今のプロジェクトに適用してくれます。

git rebaseは基本的にgit cherry-pickをまとめてやってくれるものだと考えると腑に落ちやすいです。
「歴史を綺麗にする」と表現されることもありますが、rebase先のbranchと今自分がいる側のbranchの共通の祖先commit、すなわち枝分かれした地点から自分側のcommitの連なりを古い方からどんどんrebase先のbranchにgit cherry-pickしていっているのです。
そうすれば例え差分としては同じでも、commitとしては別であることが分かりやすいかと思います。

適用したいのcommit群を指定した上でのrebase

では、今回のようなケースではどのようにすれば良いのでしょうか。
もちろん、一つや二つ程度のcommit数であれば一つ一つcommitをcherry-pickしても良いですが、改修分のcommitがそれなりの数になってきたらそれもやっていられません。

そんな時に便利なのが、--ontoオプションです。
--ontoオプションを使えば、指定した「基準としたいcommit」の上に「適用し始めたいcommitを指すref」から今の自分のcommitまでを順次適用してくれます。

$ git rebase --onto <基準としたいcommitをref> <適用し始めたいcommitを指すref>

ただ少し注意が必要で「適用し始めたいcommitを指すref」については単純にcommit hashでも良いのですが、これはrebaseの度に変更何度も使い回すのに適していません。

$ git rebase --onto refactoring <適用し始めたいcommit hash>
$ git checkout refactoring
$ git rebase -i master # 再び何かしらのrebase
$ git rebase --onto refactoring <適用し始めたいcommit hash>   # 先ほどとcommit hashが違うため上手くいかない

何回もrebaseするようなケースではrefactoringブランチから派生して何個commitが連なっているかを考えて相対的な参照で指定するのが有効です。

$ git rebase --onto refactoring HEAD~5

そして晴れてrefactoringのbranchがmergeされたら

$ git rebase --onto master HEAD~5

とすれば、新しいPull Requestを作ることができますね。

まとめ

かなり我流なところも含まれつつ、Gitの中身の入門的なところと、その知識を踏まえた上での業務への活かし方を紹介しました。内容について、もしツッコミなどございましたらそっとお教えいただけますと幸いです。

またこんなテクニックもあるよというのがありましたら是非共有してください。という意味で明日以降の記事が楽しみです:)

気がつけば師走、年の瀬を間近に控え忙しさを増してきている今日この頃です。皆様におかれましては、くれぐれも体調に気をつけて(一段と寒くなりましたよね)良き年末をお過ごしくださいませ。

そして明日はtoshi0383さんによる「Gitのmanページとか読んでみよう」です。よろしくお願いします!

それでは、みりおっつー!

参考資料

この投稿は Git Advent Calendar 20151日目の記事です。