チャチャチャおもちゃの抹茶っちゃ

ゲームのこととかプログラミングのこととか。気が向いたら書く。ブログタイトルは友人が考えました。

【Git入門】Gitを使い始めたい人へ「ローカルリポジトリの操作3」 ~A Beginner to Beginners~ 【その4】

【Git入門】Gitを使い始めたい人へ「ローカルリポジトリの操作2」 ~A Beginner to Beginners~ 【その3】 : 前 次 : 【Git入門】Gitを使い始めたい人へ「リモートリポジトリをつくる(GitHub)」 ~A Beginner to Beginners~ 【その5】

目次

Gitの操作もだいぶ学んできた。このへんで頭の中を一旦整理しつつ、細かい話をしていきたい。

HEADとブランチ

前回、HEADとブランチと呼ばれているものを移動させてワークツリー(作業ディレクトリ)の内容をコントロールしてきた。
復習がてら確認しておくと、ブランチを切り替えればそのブランチが指し示すコミット(記憶のかたまり)の状態に変更してくれる、というものだった。HEADを前の状態に戻していけば、その前の状態がワークツリー(作業ディレクトリ)に復元された。
今までコミットをどちらも指し示している曖昧な状態で進めてきたが、これらは一体どういうものなんだろうか。

コミットの正体

これまで作成してきた「コミット(記憶のかたまり)」に含まれる情報は次のようなものである。

  • ワークツリーの状態
  • コミットを作成したユーザーの情報(ユーザー名、E-mailアドレス)
  • コミットメッセージ
  • 親コミットの名前

図で表すとこう
f:id:mattyan1053:20180721224606j:plain
コミットには自分の一つ手前のコミット(親コミット)の名前が記録されている。つまり、今いるコミットから親コミットへ順番にたどっていけば最初のコミットまでたどり着けるというわけだ。逆に、自分の後ろのコミットについての情報は全く含まれていない。したがって、親コミットから子コミットのほうは認識できないということである。
今まで

$ git log

としたとき、今いるコミットより下のコミットの情報は表示されなかったはずだ。
たとえば
f:id:mattyan1053:20180721003349j:plain
この状態でみると

$ git log --oneline
0d5bf59 Third commit
393f3a1 Second commit
f0c1e0e first commit

と表示されたが、一つもどってログを見ると

$ git reset --hard HEAD~
$ git log --oneline
393f3a1 Second commit
f0c1e0e first commit

f:id:mattyan1053:20180721005314j:plain

となり、Third commitは表示してくれない。この原因はコミットに含まれる情報にあったわけだ。

現在の状態について

Gitにおける現在の状態について整理しよう。
現在の状態は、

  • 現在のコミット
  • 現在のインデックス
  • 現在のワークツリー

の3つに分けることができる。
「現在のコミット」は問題ないだろう。通常、最後にコミットしたものを指し示し、git resetなどを使って移動した場合はその移動先のコミットを表す。
「現在のインデックス」はステージ(インデックス(変更したものリスト)に追加)されたもののことだ。git addで追加したりできる。
「現在のワークツリー」とは作業ディレクトリの状態である。普段ファイルを開いたり編集したりできるあれ。コミットしたあとにまた変更してたりしてなかったり。 コミットの中身は変更されることは基本ないがこちらは随時変更されていくだろう。 図で表してみるとこんな感じ。
f:id:mattyan1053:20180721231840j:plain
これを把握した上で次からを読み進めてもらいたい。

ブランチの正体

前回、ブランチについて説明した。ブランチは枝のことだと説明してきたが、実はあまり正しくない。
ブランチとは、コミットを指し示すものである。次の図を見てみよう。
f:id:mattyan1053:20180721232751j:plain
この図では「master」がコミット「0d5bf59」を、「test-branch」がコミット「84507d3」を指し示している。
通常、ブランチは枝一つにつきブランチを一つ用意する。そしてそのブランチが枝の先端を指し示すように動かすものなのだ。
逆に言えば「master」や「test-branch」を別の枝を指し示すように移動することもできるということだが、無意味なのでやらない。

HEADの正体

最後にHEADの正体について考える。ブランチの流れから察している人もいるかもしれないが、HEADもなにかを指し示すものだ。HEADは指し示すものが複数存在し、同時に指し示している。指し示しているのは次の3つだ。

  • 現在のブランチ
  • コミット直後のインデックス
  • コミット直後のワークツリー

「現在のブランチ」を指し示すとはどういうことか。現在「master」ブランチにいればHEADは「master」を指し示し、現在「test-branch」にいればHEADは「test-branch」を指し示す。これを「HEAD->master」などと表していて、git logなどで見たことがあるのではないだろうか。図で表すと、
f:id:mattyan1053:20180721234105j:plain
この状態ではHEADは「master」を指し示しているが、

$ git checkout test-branch

とするとヘッドの指し示すブランチは「test-branch」に変わり
f:id:mattyan1053:20180721234204j:plain
となる。
ブランチを指し示していれば実質コミットも指し示すことができているということになる。

「コミット直後のインデックス」はそのままの意味であり、ステージされているものすべてを表す。
「コミット直後のワークツリー」も同様そのままの意味を示し、最後のコミットから変更されている部分も含むことになる。

C言語などの「ポインタ」がわかる人へ

C言語などの「ポインタ」がなにか知らない人は飛ばして次へ Gitのリポジトリを自在に操る
実は、これらの「指し示す」という考えはC言語の「ポインタ」の概念と照らし合わせるとよく理解できる。HEADはブランチ、空のインデックス、元のワークツリーへのポインタをメンバにもつ構造体のように捉えるとわかりやすいかもしれない。「HEAD->master」のような表現にアロー演算子やん!って反応した人はある意味正しい。ブランチはコミットへのポインタであり、コミットは親コミットへのポインタ、ワークツリーへのポインタなどを保持していることになる。つまるところポインタのオンパレード

Gitのリポジトリを自在に操る

ここまでの説明をしたことで、リポジトリをうまく操るために準備が整った。今までの知識をフル活用して読んでいきたい。

revert

はじめてみるコマンドかもしれないが、便利なので掲載。
最終コミットの内容を取り消したコミットを作成するのに使う。
一度コミットしたものを取り消すのだが、その取り消したこともログに残したいことがあるだろう。そういうときのコマンドである。
具体的には直近のコミットを取り消したコミットを作り出すことになる(一つ前のコミットができあがる)。
f:id:mattyan1053:20180722013104j:plain

$ git revert HEAD

git reset

さて、ここまできて今まで幾度とみてきたgit resetについて考える。とても重要なコマンドなので詳しくしっておきたい。
git resetの使い方は

$ git reset [オプション] <コミットを指すもの>

という形だ。「コミットを指すもの」としては「HEAD」、「ブランチ」、「コミットの名前(ハッシュ値)」とその相対位置(HEADの一つ前、ブランチの一つ前など)を指定できる。一つ前を指し示すときは「~」をつけるなどする。例えばHEADが指し示すコミットの一つ前のコミットを指定したいときは

$ git reset HEAD~

というようにすれば良い。
オプションはとりあえず--hard --soft --mixed(つけなくても良い、デフォルト)の3つを把握しておけばよい。

[--mixed]オプション

まず--mixedについて説明する。これはデフォルトなので、オプションを何もつけないでgit resetした場合は自動的に--mixedオプションをつけたときと同じ扱いになる。
このオプションをつけると、現在のインデックス、及びコミットが現在の状態から指定したコミットの作成直後までリセットされる。ワークツリーはリセットされないので、変更したファイルはそのまま残っている。どういうことか、図で表してみると次のようになる。
f:id:mattyan1053:20180722004610j:plain
コミットとインデックスはHEADの状態に書き換えられているが、ワークツリーは現在の状態のままなことがわかる。

次の例を見てみよう。

【その2】で説明したように、

$ git add <filename>

で現在のインデックス(変更したものリスト)に変更したファイルを追加できた。
ここで、変更したけどまだコミットしたくないものを間違って「add」してしまったときのコマンドとして

$ git reset HEAD <filename>

を紹介したとおもう。これはどういうことかというと「<filename>で指定したファイルを、HEADの指し示す状態にリセットする」ということだ。コミット直後は当然インデックスは空であるから、指定したファイルをコミット直後の状態に戻すとまだステージされる前の状態に戻る。けれどもワークツリーは何も変化しないので、書き換えた部分は取り消されることはない。したがって、結果的には「add」を取り消すことができるようになる。同様にして、すべてのインデックスを取り消す場合は

$ git reset HEAD

とすればいいことがわかる。

[--soft]オプション

次に--softについてだ。このオプションでは、コミットのみ後ろに書いたコミットの状態にリセットする。インデックスやワークツリーは書き換えられない。つまりコミットだけリセットされるということだ。
図で表してみよう。
f:id:mattyan1053:20180722005121j:plain
コミットの部分だけHEAD~の状態にリセットされ、ほかはそのままなことがわかる。図の状態をコマンドで打つなら

$ git reset --soft HEAD~

となる。これはファイルの変更を維持したまま直前のコミットを取り消す動作を意味する(コミット1は消えてしまうが、sample.cの変化は生きていて、ステージされたまま)。
この操作に関しては、実は後述する

$ git add sample.c
$ git commit --amend

のほうが便利だったりする。

[--hard]オプション

今までかなり多用してきたように見える[--hard]オプションについてだ。--hardオプションでは後ろに書いたコミットの状態にリセットし、なおかつインデックス、ワークツリーも元の状態にリセットする。図で表すと
f:id:mattyan1053:20180722005752j:plain
この場合、一つ前のコミットの状態を完全に上書きしていることになる。これまで、一つ前のコミットに戻る時や指定のコミットに戻る時

$ git reset --hard HEAD~

$ git reset --hard 0d5bf59

などとしてきたのは、現在の状態を指定の状態に上書きしたということになる。結果的には指定のコミットに移動したのと同じになるが、コミットしていないワークツリーの内容やインデックスの内容は完全に消えてしまうので注意が必要である(コミットは残るがハッシュ値を直接入力しないと戻れない)。
もっとも、現在のコミットを別のブランチにマージして、そのあと一つ前のコミットに戻って作業を進めた後再度マージすると競合が発生することになりかねないので、「コミットの内容を取り消す」のならばgit reset [--hard] HEAD~をするよりも前述したgit revertを利用することで「コミットの内容を取り消した履歴」を残しておくほうがベターであろう。

$ git reset --hard HEAD~

の場合
f:id:mattyan1053:20180722141151j:plain

$ git revert HEAD

の場合
f:id:mattyan1053:20180722223307j:plain
競合を回避するためには変化の過程が一意的でなければならない。

ブランチの移動

さて、ここまでやってきたところで注意したいところなのだが、git reset [option] HEAD~などとHEADの位置をリセットしてきた。このとき、HEADの位置変更と同時にブランチの位置も変わっていることに注意したい。例えば現在「master」ブランチをHEADが指しているのなら

$ git reset --hard HEAD~

としたときはHEADのみでなく「master」も一つ手前のコミットを指すように変化していることになる。また、

$ git reset --hard master~

などと指定することもでき、この場合は「master」ブランチの一つ手前にHEADとHEADの指し示すブランチが移動することになる。つまり、現在「master」ブランチにいるときに強引に

$ git reset --hard test-branch~

とすれば、HEADと「master」は「test-branch」が指し示しているコミットの一つ手前に移動することになる。
f:id:mattyan1053:20180722152014j:plain
まあこんなこと決してやってはいけないのだが・・・・・・。

git commit

今までさんざん行ってきたgit commitだが、コミットを修正したいことがある。図で表すと
f:id:mattyan1053:20180722011847j:plain
こんな感じ。「sample.c」をコミットしたあと再度変更したが、その変更も前のコミットにいれてしまいたくなったときの方法。やり方は単純で、変更したものをインデックスに追加したあと、もう一度コミットするのだが、オプションで[--amend]をつけることである。こうすることで変更したものリストの内容を新規のコミットではなく直前のコミットの内容に上書きしてくれる。

$ git add sample.c
$ git commit --amend

使わなくなったブランチ

分岐して使っていたブランチをマージし、そのブランチが不要になったときはブランチを破棄することができる。

$ git branch -d <ブランチの名前>

実際の開発とブランチの使い分け

これで自分の操作しているリポジトリはほぼ自由に操ることができるようになった。では実際の開発ではどのようにするのがいいのだろうか。
f:id:mattyan1053:20180722014615j:plain
基本的にはmasterで作業をせずに、別のブランチ(develop)で作業をして、良いところでmasterとマージさせていく。途中の機能追加などはdevelopから更に別のブランチに分岐して作業をしてdevelopにマージしていく、という流れが開発としてはやりやすいだろう。こういったルールはチームで決めていきたい。
上記のものは「A successful Git branching model」というのを参考にした。ググれば日本語になっているやつも出てくると思う。

まとめ

コミットやHEAD、ブランチの正体について学習し、それを踏まえていくつかコマンド(特にgit reset)について説明した。git resetは結局

$ git reset [オプション(どこをリセットするか)] <リセット元のデータはどこにあるか>

というふうにまとめられる。他のコマンドも同様、現在のHEADがどうなっているかに注視していれば良い。

次回からリモートリポジトリについて進めていく。

【Git入門】Gitを使い始めたい人へ「ローカルリポジトリの操作2」 ~A Beginner to Beginners~ 【その3】 : 前 次 : 【Git入門】Gitを使い始めたい人へ「リモートリポジトリをつくる(GitHub)」 ~A Beginner to Beginners~ 【その5】