初めに
git add
、git commit
、git push
といった基本的なコマンドはわかるけど、実際にGitではどんなことをやっているのか、ということが気になる人向けの記事です。この記事は実用Gitをメインに、Gitの公式ドキュメントなどを参考にしてGitとはどういうものかを解説していきます。勉強を兼ねてこの記事を書いているので、何か間違いがあったらコメント等で知らせていただけると幸いです。
Gitの基本的な概念
・リポジトリ
Gitリポジトリは、プロジェクトのリビジョンと履歴を維持管理するのに必要な全情報を含む、まさにデータベースと言える。Gitのリポジトリは、オブジェクト格納領域とインデックスという二つの基本的なデータ構造を持つ。このリポジトリのデータは全て作業ディレクトリ最上位の.gitディレクトリの中に保存される。
・gitの「オブジェクト」の種類(以下の4つ)
- ブロブ(blob) 大きなバイナリオブジェクト(binary large object)の短縮表現。ファイルの各バージョンはブロブで表される。
- ツリー(tree) 1階層分のディレクトリ情報を表現する。再帰的に他のツリーオブジェクトを参照することができ、これによりファイルとサブディレクトリからなる完全な階層構造を構築することができる。
- コミット(commit) リポジトリに加えられた各変更のメタデータを保持する。各コミットは、あるツリーオブジェクトを指し示し、このツリーオブジェクトは単一の完全なスナップショットとして、コミットが実行された時点におけるリポジトリの状態を記録している。
- タグ(tag) 特定のオブジェクトに対して、おそらく人が読める名前をつける。名前をつけるオブジェクトは通常コミットオブジェクトで、例えば9da581d9109c...とかをVer1.0-alphaとかにする。
・インデックス
リポジトリ全体のディレクトリ構造が記述された、一時的かつ動的なバイナリファイルのこと。インデックスはある瞬間のプロジェクト全体の構造を捉えている。
段階的開発とコミットの分離をどのようにするのか。開発者はGitコマンドを実行してインデックスに変更をステージする(git add
とか)。インデックスは変更を記録して保持し、コミットできる段階になるまで、安全な状態にしておく。インデックスへの変更を削除したり置き換えることもできる。このようにインデックスによって開発者は、ある複雑なリポジトリの状態から、おそらくより良い別の状態へ、徐々に移行させていくことができる。インデックスはマージにおいて重要な役割も果たす。
・内容参照可能な名前
オブジェクト格納領域の各オブジェクトは、オブジェクトの内容にSHA1を適用して作られたSHA1ハッシュ値から生成される一意な名前を持っている。SHA1の値は40桁の16進数として表される160ビットの値。Gitのユーザは、SHA1とハッシュコード、そして時にはオブジェクトIDも同じ意味で使う。ハッシュ値は同一の内容に対しては、その場所に限らずいつも同じIDが計算される。
・Gitは内容を追跡する
Gitは単なるバージョン管理システムというより、内容の追跡システムである。これはGitの設計の理念を導く。
Gitの内容追跡は、二つの重要な意味を持つ方針によって表される。
- Gitのオブジェクト格納領域は、もともとのユーザのファイル構成におけるファイル名やディレクトリ名に関係なく、オブジェクトの内容に対するハッシュ計算に基づいている。もし2つの違うディレクトリに位置する全く同じ内容のファイルがある時は、Gitはその内容のコピーを一つだけ、オブジェクト格納領域のブロブに持つ。
- Gitの内部データベースは、ファイルがあるリビジョンから次のリビジョンへ移る際、それらの差分ではなく、各ファイルの各バージョンを効率的に保存する。Gitでは、作業やオブジェクト格納領域のエントリが、ファイルの内容の一部だけやファイルの2つのリビジョン間の差分を基礎とするわけにはいかない。
ブロブとツリー
実際の例を通じてブロブやツリーなどを見てみる。
mkdir hello
cd hello
git init
echo "hello world" > hello.txt
git add hello.txt
hello.txt
ファイルのオブジェクトを生成する(git add
する)場合、Gitはそのファイル名がhello.txt
だということは気にしない。Gitはそのファイルの内容のhello worldと最後の改行だけを気にする。SHA1ハッシュを計算し、ハッシュの16進数表現にちなんだ名前ファイルとしてオブジェクト格納領域へ入れる。.git/objects/を見るとわかるが、ハッシュ値の最初の二つをとったディレクトリを作って配置する。ファイルシステムによっては同じディレクトリにたくさんファイルを入れすぎると動作が遅くなるので、均一分布を持つオブジェクトの全てを固定的に256分割する名前空間を作っている。Gitがファイルの内容をにほとんど何もしていない証拠として、ハッシュを使っていつでもオブジェクト格納領域からファイルの内容を取り出すことができる。
git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
とするとhello worldが表示される。cat-file
コマンドで、ハッシュ値を与えることでブロブ、ツリー、コミット、タグといった全てのオブジェクトの中身を見ることができる。
Gitは「ツリー」と呼ばれる別のオブジェクトでパス名を追跡する。git add
を使うとGitは追加した各ファイルに対してオブジェクトを1つずつ作る。しかし、オブジェクトはすぐには作らない。代わりにGitはインデックスを更新する。インデックスは.git/indexにあり、ファイルのパス名と対応するブロブを追跡し続ける。git add
やgit rm
、git mv
のようなコマンドを実行すると、Gitは毎回インデックスを新しいパス名と対応するブロブの情報で更新する。そしていつでも好きな時に、その時点でのインデックスからツリーオブジェクトを作ることができる。ツリーオブジェクトを作るには、低レベルコマンドであるgit write-tree
を使い。現在のインデックスの情報のスナップショットをとる。
git ls-files -s
でインデックスがみれる。
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 hello.txt
と表示される。
インデックスの状態を捉えて、ツリーオブジェクトへ保存するには
git write-tree
というコマンドを用いる。
すると
68aba62e560c0ebc3396e8ae9335232cd93a3f60
と表示される。
ツリーはオブジェクトなので、ブロブと同様に内容を見るには低レベルコマンドを使う。
git cat-file -p 68aba6
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
最初の100644という数字はファイル属性(例えば下3桁の644はrw-r--r--)を表す。3b18e512...はhello worldというブロブオブジェクトの名前で、hello.txtというのがブロブに関連づけられた名前。
次にsubdirというサブディレクトリを生成して、その内容をみる。
mkdir subdir
cp hello.txt subdir
git add subdir/hello.txt
ここでgit write-tree
とすると
492413269336d21fac079d4a4672e55d5d2147ac
と表示され
git cat-file -p 4924132
とすると
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
040000 tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60 subdir
となるが、subdirのオブジェクト名が先ほどのgit write-tree
コマンドでみた値と同じになっている。これは新しいツリーであるsubdirは、先ほどまでの最上位のツリーと完全に同じものになるから。
コミットオブジェクト
echo -n "Commit a file that says hello\n" | git commit-tree 492413
とするとコミットオブジェクトが作られる。
今までと同様にgit cat-file -p
を使ってその中身を見れる。実際にこの例を行った人ならわかると思うが、生成されるコミットは作成者の名前や時間を含むので、ハッシュ値は自分で作成したものと完全に同じにはならない。しかし、コミットは同じツリーを持っている。異なるコミットが同じツリーを指すことがある。
実際には低レベルのgit write-tree
やgit commit-tree
は使う必要がないし、使うべきではない。これらは勉強のためであり、実際にはgit commit
コマンドを使うべし。
基本的なコミットオブジェクトはかなり単純。そして現実的なリビジョン管理システムに必要とされる最後の要素がこのコミットオブジェクト。コミットオブジェクトは次のものを含む
・関連するファイルを実際に表すツリーオブジェクトの名前
・新しいバージョンの作成者の名前と作成された時間
・新しいバージョンをリポジトリにおいた人(コミッターのこと)の名前と、コミットされた時間
・このリビジョンを作った理由の説明(コミットメッセージ)
git show --pretty=fuller
を使えば、上記の情報がみれる
さらにコミットオブジェクトはグラフ構造で保存される。しかし、ツリーオブジェクトで使われている構造とは根本的に違う。新しいコミットを作る際に1つ以上の親コミットを与えることができる。親のチェーンを辿っていくことでプロジェクトの履歴を見ることができる。
タグオブジェクト
Gitが取り扱うオブジェクトの最後はタグ。Gitではタグオブジェクトは1種類しか実装されていないが、2種類の基本的なタグ型がある。これらのタグは普通は軽量タグと注釈付きタグと呼ぶ。
軽量タグは単純にコミットオブジェクトを指し示し、通常はリポジトリ内にプレイベートなものとみなされる。これらのタグは、オブジェクト格納領域に永続的なオブジェクトを作らない。
注釈付きタグは、より内容のあるもので、オブジェクトを生成する。注釈付きタグにはメッセージを書いて含めることもできる。
Gitはコミットに名前をつける目的では、軽量タグも注釈付きタグも同じように扱う。しかし、デフォルト多くのGitコマンドが注釈付きタグに対してのみ動作する。注釈付きタグは「永続」オブジェクトとみなされているため。
コミットに対して注釈付きのタグをメッセージ付きで作るには、git tag
コマンドを使う。
git tag -m "Tag version 1.0" V1.0 b59df5f
今までと同じようにgit cat-file -p
コマンドで、タグオブジェクトを見ることができる。タグオブジェクトのSHA1値はgit rev-parse
コマンドを使って見れる。
git rev-parse V1.0
と打てば
e4011b454a0442ffce1ed18b5a233c3c4122474c
が返ってくる。
git cat-file -p e4011b
とすれば
object b59df5f79d3cebc7896c104f3e189338fe6f9e74
type commit
tag V1.0
tagger git taro <hello@example.com> 1573473785 +0900
Tag version 1.0
と返ってくる。最後のログメッセージと作者の情報に加えて、タグはb59df5fというコミットオブジェクトを指し示す。
なおgit rev-parse
はあらゆる形式のコミット名(つまりタグ、相対名、省略形など)を正確なハッシュ値に戻してくれるコマンド。
git configについて
・git commit -m 'message' --author="taro <abc@example.com>"
という風にコミットごとにauthorとメアドを決めることも可能だが、git config user.name 'taro'
とかgit config user.email "aa@example.com"
のようにconfigファイルに書いておく方が良い。--authorはメアドを書かないとダメ。
git config
コマンドだけではなく、環境変数のGIT_AUTHOR_NAME
やGIT_AUTHOR_EMAIL
での設定という方法もある。
・Gitの設定ファイルは全て.ini形式の簡単なテキストファイル。優先順位の高い方から以下
- .git/config リポジトリごとの設定で、
--file
オプションを使って操作する - ~/.gitconfig ユーザーごとの設定で、
--global
オプションを使って操作する - /etc/gitconfig システム全体の設定で、
--system
オプションを使って操作する。もしかしたら/usr/local/etc/gitconfigとか別の場所かも。そもそもない可能性もある。
gitconfig -global user.name "taro"
のように使う。
git config -l
で全ての設定ファイルの値を反映した設定値の一覧を見ることができる。
--unset
オプションで設定を削除することができる。
git config --global --unset user.email
など
・エディタを決定する時の順序
1 GIT_EDITOR環境変数 (例えばbashでexport GIT_EDITOR=emacs
)
2 core.editorの設定値 (例えばgit config --global core.editor emacs
)
3 VISUAL環境変数
4 EDITOR環境変数
5 viコマンド
・エイリアスの設定方法
git config --global alias.show-graph 'log --graph --abbrev-commit --pretty=oneline'
これでshow-graph
というエイリアスが作成されて、git log
にたくさんオプションをつけたものが実行される(abbrev-commit
はハッシュ値を短く表示するもので、pretty=oneline
はlog
の表示を1行で簡潔に表示するもの)
ファイル管理とインデックス
Linus TorvaldsはGitのメーリングリストで、まずインデックスの目的を理解しないと、Gitの能力を把握してきちんと評価することはできないと主張している。
git diff
は作業ディレクトリに残っていて、ステージされていない変更を表示する。一方git diff --cached
はステージされていて、次のコミットに使われる予定の変更を表示する。ステージの変更作業において、git diff
の二つの用法が作業の助けとなる。最初はgit diff
は全ての変更が入った大きな集合で、--cached
は空の状態。そして、ステージをするにつれて、前者は小さくなり、後者は大きくなる。
Gitではファイルを3つのグループに分ける
- 追跡(tracked) 追跡ファイルとは、すでにレポジトリに入っているか、インデックスに登録されているファイル。新しいファイルをこのグループに入れるには
git add
コマンドを使う。 - 無視(ignored) 無視ファイルは、リポジトリにおいて明示的に「見えない」「無視されている」と宣言しておく必要がある。宣言されたファイルは作業ディレクトリに存在していても、無視される。Gitにファイルを無視させるには、
.gitignore
という名前の特別なファイルに無視させたいファイルの名前を加える。例えばecho main.o > .gitignore
とか。初めて.gitignoreを作る際は、.gitignore
自身は未追跡ファイルになる。 - 未追跡(untracked) 未追跡ファイルは、上の二つのグループのどちらにも属さないファイル。
.gitignoreを作って実際に試してみる。
mkdir my_stuff
cd my_stuff
echo "New data" > data
git status
touch main.o
echo main.o > .gitignore
git add data .gitignore
この状態で、オブジェクトモデル的な観点では、各ファイルはgit add
を使った瞬間に全てオブジェクト格納領域にコピーされ、格納によって生じるSHA1値によってインデックスが作成される。そこでファイルをステージすることは、「ファイルをキャッシュする」「ファイルをインデックスへ入れる」と呼ばれることもある。
git ls-files
を使えば、オブジェクトモデルの内側を見ることができる。ステージされたファイルのSHA1値も見ることができる。
git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0 .gitignore
100644 534469f67ae5ce72a7a274faf30dee3c2ea1746d 0 data
ここで、dataを編集する。cat data
とした時に
New data
And some more data now
となるように編集する。git hash-object
というコマンド(普段は使うことはないコマンド)によって、新しいバージョンのファイルのハッシュ値を計算することができる。
git hash-object data
e476983f39f6e4f453f0fe4a859410f63b58b500
まだgit add
していないため、オブジェクト格納領域とインデックスにある元のバージョンは534469fというハッシュ値を持っているが、git add
するとこの値になる。
git add data
git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0 .gitignore
100644 e476983f39f6e4f453f0fe4a859410f63b58b500 0 data
ここからわかるのは、git add
は「指定したファイルを追加する」のではなく、「指定した内容を追加する」とみなすべきであるということ。
コマンドラインでコミットログメッセージを指定しないと、Gitはエディタを起動し、ログメッセージを書くように促す。コミットログメッセージを書いている最中に何らかの理由でコミットしないことにした場合は、セーブせずにエディタを終了すれば良い。すでに保存してしまった場合は、ログメッセージ全体を削除してもう一度保存すれば良い。
・git rmの使用
ファイルをステージ状態からアンステージ状態にするため、git rm --cached
コマンドを使用する。このコマンドはファイルをインデックスから削除するが作業ディレクトリには残す。作業ディレクトリにファイルを残して未追跡状態にするのは危険で、それはそのファイルが追跡されなくなったことを忘れる可能性があるからである。
Gitはファイルを削除する前に、作業ディレクトリのファイルのバージョンが現在のブランチの最新のバージョン(GitのコマンドがHEADと呼んでいるバージョン)かどうかをチェックする。このチェックはファイルに対してなされていた変更が、事故によって失われるのを防ぐ。git rm
が動作するには、作業ディレクトリのファイルがHEADかインデックスの内容と一致しなければならない。
・git mvの使用
mv stuff newstuff
git rm stuff
git add newstuff
この上記の3つのコマンド(mv
, git rm
, git add
)と次のコマンド(git mv
)は等価
git mv stuff newstuff
git mv
したファイルを新しくコミットする。
git mv data mydata
git commit -m "moved data to mydata"
git log mydata
commit 916336aec421a6923e4ff1d53afab343afcb1f96 (HEAD -> master)
Author: git taro <hello@example.com>
Date: Tue Nov 12 01:10:14 2019 +0900
moved data to mydata
このようにmydataの履歴を見ると、dataの頃の履歴が失われたように見えるが実際には失われていない。dataの頃の履歴をみるには--followコマンドを使えば良い。
git log --follow mydata
commit 916336aec421a6923e4ff1d53afab343afcb1f96 (HEAD -> master)
Author: git taro <hello@example.com>
Date: Tue Nov 12 01:10:14 2019 +0900
moved data to mydata
commit d98e81e11877e16d2bdd6e42853a6344b8ba299c
Author: git taro <hello@example.com>
Date: Tue Nov 12 00:44:20 2019 +0900
hello
とちゃんとその前のコミットも表示される。
・.gitignoreファイル
.gitignoreファイルは、リポジトリの最上位ディレクトリだけでなく、そのサブディレクトリ内でも置いて良い。最上位に置くことで、リポジトリの至るところでファイルを無視するようにできる。
.gitignoreファイルの形式は以下
- 空行は無視、#で始まる行はコメントになる。行の途中から#を使ってもコメントにはならない。
- ディレクトリ名は、末尾の/によって表される。これはサブディレクトリにもマッチする。ただしファイルやシンボリックリンクにはマッチしない。
- *のようなシェルのグロブ文字を含む場合は、シェルのグロブパターンとして展開される。
- 行頭の感嘆符は、行の残りの部分のパターンの意味を反転させる。加えて、これより前のパターンで除外されたファイルのうち、反転したルールにマッチする者は再び含める。
除外パターンが何らかの形であなたのリポジトリに特化したものであり、他の人のリポジトリクローンに適用すべきでない場合は、パターンを.git/info/excludeファイルに入れるべきである。そうすることで、クローン操作(git clone
)によって伝搬されなくなる。
コミット
・コミットの絶対名
コミットのハッシュIDはコミットの名前として最も厳格なもの。例えばある開発者が自身のリポジトリの中のとあるコミットIDを参照するような連絡をしてきた時に、あなたのリポジトリの中にそのコミットがあったとしたら、2人は内容まで全く同じコミットを見ているのだと言える。コミットIDの算出に使われるデータは、リポジトリのツリー全体を含んでおり、さらに以前のコミットの状態も含んでいるので、帰納的に考えると、あなたとその開発者は、そのコミットに至るまでの開発ラインも含めて完全に同じものを見ているとも言える。
・参照とリンボリック参照
参照とは、Gitのオブジェクト格納領域内で、オブジェクトを参照するSHA1ハッシュのIDのこと。シンボリック参照とは、Gitオブジェクトを間接的に指し示す名前。例えばローカルのトピックブランチ、リモート追跡ブランチ、タグの名前は全て参照。
シンボリック参照は全てrefs/で始まる明示的な絶対名を持っている。そしてリポジトリの.git/refs/ディレクトリに階層化して置かれる。refs/以下には基本的に三つの異なる名前空間がある。ローカルブランチのためのrefs/heads/ref、リモート追跡ブランチのためのrefs/remotes/ref、タグのためのrefs/tags/refである。例えばv2.6.23のようなタグはrefs/tags/v2.6.23を短縮したもの。
・相対的なコミット名
master^はmasterブランチの上から2番目のコミットを常に参照する。master^^とかも可能。最初のコミットのルートコミット以外では、各コミットは1つ以上の親コミットを持つ。マージ操作では2つ以上の親コミットを持つ。ある世代において、異なる親を選択するのに^(カレット)を使う。祖先をたどり前の世代の選択をするのに~(チルダ)が使われる。master^もmaster~も省略形であり、master^はmaster^1、master~はmaster~1の意味。
git rev-parse master~3^2^2^
のように複数組み合わせることもできる。
コマンドgit rev-parse
は究極的に、あらゆる形式のコミット名(つまりタグ、相対名、省略形など)を、オブジェクトデータベース内の実際の絶対的にコミットハッシュIDに翻訳する。
・git logについて
コミットの履歴を見るにはgit log
。引数を指定しないとgit log HEAD
のように振る舞う。git log commit
のようにあるコミットを指定した時は、指定したコミットから後方へ向かっって表示される。例えばgit log master
とか。
範囲を指定するにはsince..until
という形式でコミットの範囲を指定できる。
git log --pretty=short --abbrev-commit master~12..master~10
--prettyではoneline、short、fullのように選べて、コミットの表示量を調整できる。--abbrevはハッシュIDを簡略化して表示する。
-pを使うと、コミットによる差分が表示される。-nというオプションで、出力を最新のnコミットだけに制限できる。
オブジェクト格納領域のオブジェクトを表示する別のコマンドとして、git show
がある。
例えばgit show HEAD^2
とか。タグのハッシュIDとかツリーのハッシュIDを指定してもちゃんと表示してくれる。
・コミットグラフを見るにはgitk
コマンドがある。ただしこのコマンドが入っていない時もあるので注意。
・Gitのコマンドの多くはコミット範囲を指定できる。範囲はピリオドを二つ使い、start..end
のように表す。先ほどの例も同じ(master~12..master~10
の部分)。startかendを指定しないと、そこにはHEADが入ることになる。
git log
に対してコミットYを指定すると、実際には到達可能な全てのコミットログを見ることができる。(到達可能とはグラフ理論の用語で、グラフの辺を辿って到達できることを表す)^Xという表現は、コミットXとXに到達可能な全てのコミットを除くことができる。git log ^X Y
はgit log X..Y
と同じ意味。Yから到達できるコミットから、Xも含めてXに通じるコミットはいらない、という意味で、数学的には^X YとX..Yは等価。集合の引き算として考えても良い。
A...BはAとBの対象差を表す。AかBのどちらかから到達可能であるが、両方からは到達可能でないコミットを表す。
さらに強力なコミットの集合の演算をコマンドラインから行うことができる。範囲指定ができるコマンドへは、コミットの「含む」「含まない」を自由に並べて指定することができる。例えばmasterブランチにあってdev、topic、bugfixのいずれにもないコミットはgit log ^dev ^topic ^bugfix master
という方法で選ぶことができる。
・git bisect
git bisect
コマンドは任意の検索条件により、特定の欠陥のあるコミットを分離するための強力な道具である。git bisect start
で探索を始め、git bisect goodで良い状態にあるコミットを指定し、git bisect badでダメな状態にあるコミットを指定する。これを複数回続けることで、特定のコミットを探す。終わる時はgit bisect reset
とすれば、HEADがもとに戻る。
・git blame
git blame
コマンドを使っても特定のコミットを見つけることができる。git blame filename
というコマンドで、filenameの各行を最後に編集した人とどのコミットでその変更が起きたかを表示することができる。-L nというオプションをつければ、n行目から表示される。
・つるはし(git log
の-Sオプション)
git log -S string
を使うとファイルの差分の履歴を遡って文字列string
を検索できる。リビジョン間の実際の差分に検索をかけるので、追加と削除のどちらの変更でも見つけることができる。git log
への-Sへのオプションはツルハシと呼ばれ(pickaxe)、力づくで発掘をするのに使える。
ブランチ
ブランチを作る理由は様々。一般的に考えられる理由は以下
- 個々の顧客向けのリリースを表す
- プロトタイプリリース、ベータリリース、安定リリース、実験リリースなどの開発フェーズを表す
- 1つの機能の開発は特定の複雑なバグの調査を分離するために使うこともある
- 個々のブランチは、別々の作業者による成果を表す
Gitは今あげたようなブランチをトピックブランチや開発ブランチと呼ぶ。トピックという語は、単にリポジトリのそれぞれのブランチが特定の目的を持っていることを示している。またGitには追跡ブランチというリポジトリの複製を同期し続けるための概念もある。
ブランチ名に割り当てる名前は基本的に自由だが、いくつかの制限がある。リポジトリのデフォルトブランチにはmasterという名前がついており、ほとんどの開発者はこのブランチを、リポジトリ内で最も堅牢で信頼できる開発ラインとするように努めている。拡張性やカテゴリによる整理をサポートするために、Unixパス名に類似した階層的なブランチ名をつけることもある。これの利点はGitがワイルドカードをサポートしているために、bug/pr-1012やbug/pr-17というブランチがある時にgit show-branch 'bug/*'
とかができるということ。
・ブランチの作成
git branch branch_name [starting-commit]
starting-commit
が指定されていない場合、カレントブランチでの最新コミットが使われる。git branch
コマンドは単にリポジトリに新しいブランチ名を導入するだけで、作業ディレクトリが新しいブランチを使うように変更することはしない。
git branch
コマンドは、リポジトリ内に見つかったブランチの一覧を示す。なおリポジトリにはここで表示されるものだけでなく、リモート追跡ブランチというのが存在する。これを見たい時には-rオプションを、両方見たい時には-aオプションをつける。
git show-branch
コマンドではgit branch
よりも詳細な情報を表示する。
* [master] add func9
! [origin/HEAD] add func9
! [origin/feature-D] beta and func8
! [origin/master] add func9
----
*+ + [master] add func9
*+++ [origin/feature-D] beta and func8
----の上側のセクションはブランチを表し、*がついているブランチはカレントブランチである。----の下側のセクションは個々のブランチ内に存在するコミットを示す表が表示される。プラス記号は上に対応するブランチに存在することを、アスタリスクはコミットがアクティブブランチ上に存在することを示す。
git show-branch
が起動すると、表示対象となる全てのブランチ上の全てのコミットを走査し、全ブランチ上に共通に存在する最新のコミットが見つかった時点で一覧表示を終了する。標準では、最初の共通コミットが見つかった時点で表示を終了するが、経験上これは合理的である。そのような共通地点まで到達したら、ブランチが相互にどうやって関連しているのかを理解するのに十分な情報が得られる、と推定できるから。
git show-branch
には引数として複数のブランチ名を取ることもできる。
・ブランチのチェックアウト
ある時点での作業ディレクトリは1つのブランチのみを反映する。違うブランチでの作業を始めるには、git branch
コマンドを実行する。git branch branchname
という感じ。
ただしコミット前の変更がある時にチェックアウトをしようとすると、チェックアウトが拒否される。
error: Your local changes to the following files would be overwritten by checkout:
readme.md
Please commit your changes or stash them before you switch branches.
Aborting
このようにエラーメッセージが出る。作業ディレクトリの内容が失われてもよく、強制的にチェックアウトしたい時は-fオプションをつければ良い。このエラーはgit add
をして変更をステージしていても出ることに注意。よって変更をコミットしてからチェックアウトするのが1つの解決法であるが、この変更を異なるブランチに反映させるために-mオプションをつけるという方法もある。
git checkout -m bug/pr-11
Gitはローカルの変更を新しい作業ディレクトリに持ち込もうとする。この際、ローカルの変更とチェックアウト対象ブランチ(上の例ならbug/pr-11)の間でマージ操作が実行される。この時Gitはファイルを修正して、マージ競合を表す印を残している。例えばこんな感じ
++<<<<<<< master
+func10
++=======
+ bug_fix
+ func10
+ bug_pr11
+ func11
++>>>>>>> local
もう一つのかなり一般的なシナリオとして、新しいブランチを作ると同時に、そのブランチに切り替えたいということもある。ショートカットとして、-b new-branch
というオプションをつければ良い(new-branch
は新しいブランチの名前を入れる)。これも先ほどと同じように変更した後にgit checkout -b bug/pr-7
という風にコマンドを打てば、変更をコミットしないでも無事にチェックアウトできる。
・git branch -d branchname
というコマンドで、ブランチを削除できる。ただしカレントブランチは削除できない。
Gitはカレントブランチに存在しないコミットを含むブランチを削除できない。こうすることで、ブランチの削除で失われるコミットに含まれる成果物が、意図せずに削除されることを防ぐ。削除したいブランチの内容が、すでに他の内容のブランチ上に存在しているなら、そのブランチにチェックアウトしてから、そのブランチを削除すれば良い。
強制的に削除したい時は、git branch -D branchname
というコマンドを使う。
差分(diff)について
まずは普通のdiff
コマンドについて、これは-uオプションをつけることでunified diff形式になる。
比較のためにinitialというファイルとrewriteというファイルを作成する。
initiralというファイルはこちら
Now is the time
For all good men
To come to the aid
Of their country
rewriteというファイルはこちら
Today is the time
For all good men
and women
To come to the aid
Of their country
この時、普通のdiff initial rewrite
コマンドは
1c1
< Now is the time
---
> Today is the time
2a3
> and women
という表示だけど、-uオプションをつけたdiff -u initial rewrite
コマンドは
--- initial 2019-11-16 17:12:35.000000000 +0900
+++ rewrite 2019-11-16 17:13:03.000000000 +0900
@@ -1,4 +1,5 @@
-Now is the time
+Today is the time
For all good men
+and women
To come to the aid
Of their country
となる(git diff
で出てくる表示に似ている)。
オリジナルのファイルに---が、新しいファイルに+++がついている。
@@で始まる行は、両方のファイルの行番号を示す。マイナス記号から始まる行は新しいファイルを作るためにオリジナルのファイルから削除する行、プラス記号から始まる行は追加する行、空白から始まる行は、両方のファイルで同じ内容の部分。
git diff
コマンドは、4種類の基本的な比較操作を実行できる。
-
git diff
作業ディレクトリとインデックスの差異を表示する。これは、作業ディレクトリ内でダーティなもの、つまり次のコミットのためのステージ候補を示す。 -
git diff commit
作業ディレクトリと指定されたコミットcommit
の差異を要約して出力する。よくあるのは、commit
としてHEADを指定すること。 -
git diff --cached commit
インデックスにステージされた変更と、指定されたコミットcommit
の際を示す。--staged
というオプションでも可。これもcommit
にはHEADがよく指定される。 -
git diff commit1 commit2
任意の二つのコミットを比較したい時に、このコマンドを使う。インデックスと作業ディレクトリは無視される。
git diff
は数多くのオプションがある。有用なものを紹介する。
-
-M
オプション 名前の変更を検知する。ファイルの削除とそれに続くファイルの追加を単純化し、ファイルの名前変更として出力する。 -
-w
オプション(--ignore-all-space
オプションも同じ) 空白の変更を無視して比較する。 -
--stat
オプション 2つのツリー状態の差分に関する統計情報を出力する。統計情報としては、変更行数、追加行数、削除行数が簡潔な形式で出力される。例としては
readme.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
こんな感じ。
git log -p
オプションによっても差分の表示もできる。ただしgit log -p master..bug/pr11
ではmasterからbug/pr11を引いたコミットの差分を表示するが、git diff master..bug/pr11
は各々のブランチに存在するコミットを含む。
git diff master~5 master Documentation/git-add.txt
のようにあるファイルに限定して差分を見ることもできるし、git diff -S"octopus" master~50
のように-S"string"というオプションを使ってstringを含むコミットを検索できる。
・対象ブランチbranch
に他のブランチother_branch
をマージするには、対象ブランチbranch
をチェックアウトし、他のブランチother_branch
をそこへマージする。
git checkout branch
git merge other_branch
マージを始める前に、作業ディレクトリをクリーンにするのが良い。必ずしもクリーンなディレクトリで開始する必要はなく、マージ操作で影響を受けるファイルとは関係のないファイルが作業ディレクトリに散らばっていてもマージは実行できるが、基本的にクリーンな方が良い。
マージの実際例
以下ではmergeのための準備を行う。
mkdir conflict
cd conflict
git init
cat > file
Line 1 stuff
Line 2 stuff
Line 3 stuff
git add file
git commit -m"Initial 3 line file"
coflictディレクトリを作成し、最初のコミットをする
cat > other_file
Here is stuff on another file!
git add other_file
git commit -m"Another file"
masterブランチにおいて、2つ目のコミットをする
git checkout -b alternate master^
git branch
git diff
git status
cat >> file
Line 4 alternate stuff
git commit -a -m"Add alternate's line 4"
alternateブランチを作成するが、master^、つまり現在の先頭から1つ前のコミットで分岐して、そこでコミットをする。この時点でブランチが二つあり、それぞれには異なる成果物がある。2つの変更は同じファイルの同じ部分に影響を与えるようなものではないため、マージは特に問題なく行われる。
git checkout master
git merge alternate
git log --graph --pretty=oneline --abbrev-commit
* 7bd0249 (HEAD -> master) Merge branch 'alternate'
|\
| * 74ac5f2 (alternate) Add alternate's line 4
* | 71a8b30 Another file
|/
* 0973695 Initial 3 line file
このように表示される。
・競合を伴うマージ
git checkout master
cat >> file
Line 5 stuff
Line 6 stuff
git commit -a -m "Add line 5 and 6"
このようにmasterブランチにコミットする。
git checkout alternate
cat >> file
Line 5 alternate stuff
Line 6 alternate stuff
git commit -a -m "Add alternate line 5 and 6"
このようにalternateブランチにコミットする。この二つをマージするためにmasterブランチへチェックアウトする。
git checkout master
git merge alternate
ここでコンフリクトが起きる。
Auto-merging file
CONFLICT (content): Merge conflict in file
Automatic merge failed; fix conflicts and then commit the result.
この時点でgit diff
を行うと
diff --cc file
index 4d77dd1,802acf8..0000000
--- a/file
+++ b/file
@@@ -2,5 -2,5 +2,10 @@@ Line 1 stuf
Line 2 stuff
Line 3 stuff
Line 4 alternate stuff
++<<<<<<< HEAD
+Line 5 stuff
+Line 6 stuff
++=======
+ Line 5 alternate stuff
+ Line 6 alternate stuff
++>>>>>>> alternate
と表示されるので、fileを修正しgit add file
とgit commit
でちゃんとマージされる。
・マージ競合への対処
Gitが競合の解決を助けるために提供しているツールを見るために、似たようなマージの例を作る。
mkdir conflict2
cd conflict2
git init
echo hello > hello
git add hello
git commit -m"Initial hello file"
git checkout -b alt
echo world >> hello
echo 'Yay!' >> hello
git commit - -m"One world"
git checkout master
echo worlds >> hello
echo 'Yay!' >> hello
git commit -a -m"All worlds"
git merge alt
Auto-merging hello
CONFLICT (content): Merge conflict in hello
Automatic merge failed; fix conflicts and then commit the result.
Gitは問題のある個々のファイルを追跡しており、競合した、あるいは未マージ(Unmerged)であるという印をインデックス内につけている。git status
コマンドやgit ls-files -u
コマンドで作業ツリーで未マージのファイルを表示できる。
git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: hello
no changes added to commit (use "git add" and/or "git commit -a")
git ls-files -u
100644 ce013625030ba8dba906f756967f9e9ca394464a 1 hello
100644 e63164d9518b1e6caf28f455ac86c8246f78ab70 2 hello
100644 562080a4c6518e1bf67a9f58a32a67bff72d4f00 3 hello
未マージのものを表示するのにgit diff
を使うこともできるが、そのコマンドでは個々の競合を詳細まで全て表示する。
競合が発生すると、個々の競合ファイルの作業ディレクトリ内のコピーは3way diffによってマージマーカで装飾される。(<<<<<<<と=======と>>>>>>>を使った装飾)
cat hello
hello
<<<<<<< HEAD
worlds
=======
world
>>>>>>> alt
Yay!
git diff
diff --cc hello
index e63164d,562080a..0000000
--- a/hello
+++ b/hello
@@@ -1,3 -1,3 +1,7 @@@
hello
++<<<<<<< HEAD
+worlds
++=======
+ world
++>>>>>>> alt
Yay!
これは2つのdiff
の単純な結合になっている。1つのdiff
はHEADに対するdiff
で、もう1つは2つ目の親であるaltに対するdiff
。Gitは2つ目の親にMERGE_HEADという特別な名前を付与する。
git diff HEAD
(もしくはgit diff --ours
でも良い。これは私たちのバージョンとマージされたバージョンの差異を表示するという意味)を実行すると
diff --git a/hello b/hello
index e63164d..1f2f61c 100644
--- a/hello
+++ b/hello
@@ -1,3 +1,7 @@
hello
+<<<<<<< HEAD
worlds
+=======
+world
+>>>>>>> alt
Yay!
git diff MERGE_HEAD
(もしくはgit diff --theirs
)
diff --git a/hello b/hello
index 562080a..1f2f61c 100644
--- a/hello
+++ b/hello
@@ -1,3 +1,7 @@
hello
+<<<<<<< HEAD
+worlds
+=======
world
+>>>>>>> alt
Yay!
ここでhelloを直す。
cat hello
hello
<<<<<<< HEAD
worlds
=======
world
>>>>>>> alt
Yay!
上の内容を下の内容にする(マージマーカを消した)。
cat hello
hello
worldly ones
Yay!
そして差分を見る。
git diff
ここで何も表示されない!(普通ならまだステージされていない変更が全て表示されるはずである)
競合ファイルに対してgit diff
を用いた場合、本当に競合が発生しているセクションだけが表示される。大きなファイルの全体にわたって様々な変更が散らばっている場合には、そのほとんどは競合しない。これはマージ対象ブランチの、どちらか片方だけがそのセクションを変更しているのである。競合を解決する際には、これらのセクションについて気にする必要はない。git diff
は単純な経験則に基づき、興味を持つ必要がない場所を取り除く。この経験則とは、一方からの変更のみを含むセクションは表示しないというもの。git diff
はいまだに競合しているセクションだけを表示する。
競合中に、変更がどこでどのように起きたのかを正確に把握するにはgit log
に特別なオプションをつける。--mergeオプションで、競合を引き起こしたファイルに関するコミットだけが表示される。リポジトリが複雑で、競合ファイルが複数ある場合には、コマンドライン引数として興味のあるファイル名を渡すこともできる。例えばgit log --merge hello
など。
Gitはどのようにして競合したマージに関する全ての情報を正確に追跡し続けているのか。
・.git/MERGE_HEADには現在マージ中のコミットのSHA1ハッシュが含まれている。
・.git/MERGE_MSGには、競合解決後にgit commit
を使う際のデフォルトマージメッセージが含まれている。
・Gitのインデックスは個々の競合ファイルのコピーを3つ保持している。マージ基点、ourバージョン、theirバージョンの3つ。これらの3つのコピーはそれぞれ、ステージ番号(stage number)として1、2、3が割り振られている。git ls-files -u
で表示された番号がそれ。簡単にいうと、helloファイルは3回格納されており、それぞれが3つの異なるバージョンに応じた3つの異なるハッシュ値を持っているということ。詳しい内容はgit cat-file -p
コマンドで見れる。
git cat-file -p e63164d9
hello
worlds
Yay!
競合の起きているバージョンは(マージマーカやその他全て)は、インデックスには格納されない。代わりに作業ディレクトリのファイル内に格納される。
マージするにはインデックスに記録されている競合ファイルの全てにわたって、何らかの処理を行う必要があり、未解決の競合がある限り、コミットはできない。なお、競合マーカが残ったままgit add
を実行すると、コミットできるようになるが、ファイルは正しい状態ではないので気を付ける。
cat hello
hello
everyone
Yay!
git add hello
git ls-files -s
100644 ebc56522386c504db37db907882c9dbd0d05a0f0 0 hello
ここでSHA1とパス名との間に離れてある0は、競合していないファイルのステージ番号が0であることを示している。
cat .git/MERGE_MSG
Merge branch 'alt'
# Conflicts:
# hello
git commit
[master 199fcb8] Merge branch 'alt'
となる。
・マージの中断と再開
マージ操作を再開したものの、何らかの理由でマージを完了したくなくなった時のために、Gitはマージを中断するための簡単な方法を用意している。
git reset --hard HEAD
このコマンドは、作業ディレクトリとインデックスの状態をgit merge
コマンドが実行される前の状態に直ちに戻す。マージの完了後(つまりマージコミットの作成後)にマージを中断、もしくは破棄したい場合には、次のコマンドを使う。
git reset --hard ORIG_HEAD
こうした操作を可能にするため、Gitはマージ操作を開始する前に元のブランチのHEADをORIG _HEADとして保存している。.git/ORIG_HEADを見るとそこにハッシュ値が入っていることがわかる。ただしクリーンな作業ディレクトリとインデックスでマージを初めていない場合は、ディレクトリ内のコミットの変更は失われる。
ここまでの例ではブランチは多くて2つで、そこまで難しくはなかった。しかし3人以上となると交差マージといった面倒臭いことが起きる。
already up-to-dateとfast-forwardと呼ばれる2つの一般的な縮退シナリオがある。どちらもgit merge
後に新しいマージコミットを作らない。
・Already up-to-date
他のブランチ(のHEAD)からのコミットが全て、すでに対象ブランチに含まれている場合、対象ブランチは「すでに最新(Already up-to-date)」と呼ばれる。これは対象ブランチ自身の履歴がさらに進んでいたとしてもである。
マージを実行した後で直ちに全く同じマージをする時とか。先ほどの例なら
git merge alt
をもう一度行うと
Already up to date.
と表示される。
・Fast-forward
fast-forwardマージはあなたのブランチのHEADがすでに他のブランチ上に全て存在している時に起こる。これは、already up-to-dateの逆で、先ほどの例ならgit checkout alt
してgit merge master
をしようとすると
Updating 309b43d..199fcb8
Fast-forward
hello | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
となる。あなたのHEADはすでに他のブランチ上に存在しているので、Gitは単にあなたのHEADに他のブランチのコミットを付け足し、HEADを最新の新しいコミットを指すように移動させる。
・通常マージ
以下の三つのマージ戦略(resolve, recursive, octopus)は、全て結果としてコミットを作り出し、カレントブランチに追加する。追加されるコミットは、結合されたマージの状態を表す。
-
resolve
resolve戦略は、2つのブランチのみを扱う。共通の祖先をマージ基点とし、マージ基点から他のブランチまでの変更をカレントブランチに適用することで、直接3wayマージを実行する。 -
recursive
recursive(再帰)戦略は、同時に2つのブランチのみを扱えるという点では上記と似ている。しかし、再帰戦略は2つのブランチに2つ以上のマージ基点が存在するような場合を扱うように設計されている。このような場合に、Gitは全てのマージ基点の元になった共通的な位置に一時的なマージを作り出し、そこをマージ基点として通常の3wayマージで2つのブランチの最終的なマージを作り出す。
例えばAとBという二つのブランチがあり、BのノードbとAのノードaをマージしてBのノードを作る。同様にaとbからAのノードを作る。この時交差マージが発生する。この場合、recursive戦略では、aとbをマージして一時的なマージ基点を作り出し、AとBのマージ基点として使う。aとbは同じ問題を持つ可能性があり、これらをマージした後でもさらに古いコミットをマージする必要が出てくることがある。これが、このアルゴリズムを再帰的と呼ぶ理由。 -
octopus
octopus戦略は、2つ以上のブランチを同時にマージするために設計されている。概念としてはとても単純で、recursiveマージを複数回呼び出すだけ。
Gitは採用する戦略をどのように決めているのか。Gitはまず最初にalready up-to-dateとfast-forwardの採用を試みる。次にカレントブランチにマージする他のブランチを3つ以上選んだなら、octopus戦略をとる。この戦略は唯一3つ以上のブランチを同時にマージできるから。これでもダメなときはrecursive戦略をとる。もともとGitはresolve戦略だったが、2005年に標準が変わった。もしresolve戦略を取りたいのなら、git merge -s resolve branchname
のように指定する。
・マージドライバ
ここまで見てきた個々のマージ戦略は、個々のファイルの競合解決やマージのために、その操作の基本となるマージドライバを使っている。マージドライバは、共通の祖先、対象ブランチのバージョン、他のブランチのバージョンを表す3つの一時ファイル名を受け取る。ドライバは対象ブランチのバージョンを修正し、マージされた結果を作る。
- textマージドライバは、通常の3wayマージマーカ(<<<<<<<<、=========、>>>>>>>>のこと)を残す。
- binaryマージドライバは、単純に対象ブランチのファイルを保存し、インデックスに競合の印を残す。
- unionマージドライバは、単純に双方のバージョンの全ての行をマージされたファイルに残す。
Gitの属性メカニズムを通じて、Gitは特定のファイルやファイルパターンと特定のマージドライバを関連づけることができる。ほとんどのテキストファイルはtextドライバが扱い、ほとんどのバイナリファイルはbinaryドライバが扱う。アプリケーション独自のマージ操作を保証したいという特別な要求のためには、独自のカスタムマージドライバを作成して指定することもできる。
・スカッシュマージ
ほとんどのシステムでは、あるブランチAをあるブランチBにマージする際にはたった一つのdiff
を作り出し、単独のバッチとしてBに適用して履歴に新しい要素を1つだけ追加する。これは個々のAのコミット全てを1つの大きな変更に押しこむ(squash)ので、スカッシュコミットと呼ばれる。Aの履歴は、Bの履歴から失われてしまう。Gitはこのようなことはなく、双方の完全なコミット履歴を保持する。もし望むなら、Gitはスカッシュコミットを行うことが可能である。git merge
やgit pull
に--squashオプションを指定すれば良い。
コミットの変更
git resetコマンドはリポジトリと作業ディレクトリを既知の状態に変更する。このコマンドの本質は、HEAD、インデックス、作業ディレクトリを既知の状態に復元すること。このコマンドには3つの主なオプションがある。
git reset --soft commit_name
--softはHEADの参照を指定されたコミットに変更する。インデックスと作業ディレクトリの内容は変更されない。
git reset --mixed commit_name
--mixedはHEADを指定されたコミットを指すように変更する。インデックスの内容もそのコミットが表すツリー構造に沿うように変更されるが、作業ディレクトリの内容は変更されない。このバージョンでは、インデックスはそのコミットが表す変更を全てステージした直後であるかのような状態になり、またそのインデックスに対して作業ディレクトリに残されている変更を教えてくれる。git resetのデフォルトのモード。
git reset --hard commit_name
--hardはHEADの参照を指定されたコミットを指すように変更し、インデックスの内容も作業ディレクトリの内容もそのコミットが表すツリーの状態を反映するように変更される。
git reset
コマンドは、元のHEADの値を.git/ORIG_HEAD内に保存する。これはHEADのコミットログメッセージを元にして、後に続くコミットを行いたい場合などで役に立つ。
git reset
の例を挙げる。
あるディレクトリにおいて間違えてgit add foo.c
を実行してしまい、foo.cをインデックスにあげてしまったとする。
git ls-files
main.c
foo.c
ここでgit reset
を行う。
git reset HEAD foo.c
git ls-files
main.c
HEADが表すコミットにはfoo.cは存在しておらず、「ファイルfoo.cについて、インデックスを現在の状態ではなくHEADで見えていたようにしてください」という意味。
・git reset
のよくあるもう一つの例は**、ブランチ上の最新コミットをやり直すか、取り除く**という例である。
git init
echo foo >> master_file
git add master_file
git commit -m"Add master_file"
echo "more foo" >> master_file
git commit master_file -m"Add more foo"
git show-branch --more=5
[master] Add more foo
[master^] Add master_file
今2番目のコミットが間違っていることに気付いて、やり直すとする。これはgit reset --mixed HEAD^
の古典的な適用例。
git reset HEAD^
Unstaged changes after reset:
M master_file
cat master_file
foo
more foo
git reset HEAD^
の実行後、Gitはmaster_fileを新しい状態のまま残し、作業ディレクトリ全体を「Add more foo」のコミットを行う直前に戻す。
--mixedオプションはインデックスをリセットするので、新しいコミットに含めたい変更は再度ステージしなければならない。
echo "even more foo" >> master_file
git commit master_file -m"Update foo"
git show-branch --more=5
[master] Update foo
[master^] Add master_file
コミットは二つだけになる。
同様にインデックスを変更する必要はないものの、コミットメッセージだけを修正したい時はgit reset --soft HEAD^
のように--softを用いる。
2番目のコミットを完全に削除したくなり、その内容も必要ないとする。この時は--hardオプションを使う。
git reset --hard HEAD^
HEAD is now at cb02246 Add master_file
cat master_file
foo
一応git reset
はリポジトリの任意のコミットに対して適用できるので、他のブランチのコミットも指定できる。ただしこの時は、ブランチのチェックアウトをされるわけではなく、ずっと同じブランチ上にいるので注意がいる。
他のブランチを交えてgit reset
を使う様子を示すためにdevという名前の2つ目のブランチを追加する。
git checkout master
git checkout -b dev
echo bar >> dev_file
git commit -m"Add dev_file to dev branch"
[dev 38fe01a] Add dev_file to dev branch
1 file changed, 1 insertion(+)
create mode 100644 dev_file
git checkout master
cb02246d7211c80b964bd15b5876f84a49942483
git rev-parse HEAD
cb02246d7211c80b964bd15b5876f84a49942483
git reset --soft dev
git rev-parse HEAD
38fe01a70eb10a2e1cb39974af8f5b84944512e8
git show-branch
! [dev] Add dev_file to dev branch
* [master] Add dev_file to dev branch
--
+* [dev] Add dev_file to dev branch
ここでmasterブランチ上でHEADがdevを指すように変更する(git reset --soft dev
のコマンド)
この時点でコミットすると、HEADはdev_fileを含んだコミットを指しているが、masterブランチにはそのファイルは存在しない。
echo "Funny" > new
git add new
git commit -m "which commit parent?"
[master 854d9dc] which commit parent?
2 files changed, 1 insertion(+), 1 deletion(-)
delete mode 100644 dev_file
create mode 100644 new
Gitはnewを追加しmdev_fileはこのコミットに含まれないと判断している。しかし、dev_fileはもともとここに存在していなかったから削除したというのは語弊があるのではないか。Gitはなぜそのファイルを削除することにしたのか。Gitは新しいコミットが作られた時点で、HEADが指していたコミットを使うからである。
git show-branch
! [dev] Add dev_file to dev branch
* [master] which commit parent?
--
* [master] which commit parent?
+* [dev] Add dev_file to dev branch
git cat-file -p HEAD
tree 948ed823483a0504756c2da81d2e6d8d3cd95059
parent 38fe01a70eb10a2e1cb39974af8f5b84944512e8
author ...
committer ...
which commit parent?
このコミットの親は38fe01a7で、これはdevブランチの先頭であって、masterブランチのものではない。masterの先頭が、devの先頭を指すように変更された。この時点でこの最新のコミットは完全な誤りで、削除すべきと決めたとする。これはgit reset --hard
を使うべき。
git log
commit 854d9dc44cc9f49186325c2220a16af504a5144d (HEAD -> master)
Author: git rato <hello@example.com>
Date: Sun Nov 17 16:19:46 2019 +0900
which commit parent?
commit 38fe01a70eb10a2e1cb39974af8f5b84944512e8 (dev)
Author: git taro <hello@example.com>
Date: Sun Nov 17 14:00:44 2019 +0900
Add dev_file to dev branch
commit cb02246d7211c80b964bd15b5876f84a49942483
Author: git taro <hello@example.com>
Date: Sun Nov 17 12:02:48 2019 +0900
Add master_file
ただし普通に使うとHEAD^はdev HEADを指しているのでgit reset --hard HEAD^
は使えない。
git rev-parse HEAD^
38fe01a70eb10a2e1cb39974af8f5b84944512e8
git reset --hard cb02246d7
を使うのも一つの手だが、他の方法もある。例えばgit reflog
を使う方法。これはリポジトリ内の参照の変更履歴を表示する。
git reflog
854d9dc (HEAD -> master) HEAD@{0}: reset: moving to 854d9dc44cc9
38fe01a (dev) HEAD@{1}: reset: moving to HEAD^
854d9dc (HEAD -> master) HEAD@{2}: commit: which commit parent?
38fe01a (dev) HEAD@{3}: reset: moving to dev
cb02246 HEAD@{4}: checkout: moving from dev to master
38fe01a (dev) HEAD@{5}: commit: Add dev_file to dev branch
cb02246 HEAD@{6}: checkout: moving from master to dev
cb02246 HEAD@{7}: reset: moving to HEAD^
aeef304 HEAD@{8}: commit: Update foo.
cb02246 HEAD@{9}: reset: moving to HEAD^
3c711b8 HEAD@{10}: commit: Update foo
cb02246 HEAD@{11}: reset: moving to HEAD^
e1402b4 HEAD@{12}: commit: Add more foo
cb02246 HEAD@{13}: commit (initial): Add master_file
この例でいくと、5行目にdevブランチからmasterブランチへの変更が記録されている。その時点ではcb02246がmaster HEADだった。よってcb02246を使うか、シンボル名であるHEAD@{4}を使うことができる。
git rev-parse HEAD@{4}
38fe01a70eb10a2e1cb39974af8f5b84944512e8
git reset --hard HEAD@{4}
HEAD is now at cb02246 Add master_file
git show-branch
! [dev] Add dev_file to dev branch
* [master] Add master_file
--
+ [dev] Add dev_file to dev branch
+* [master] Add master_file
・cherry-pickコマンド
git cherry-pick commit
コマンドは指定されたコミットcommit
が持ち込んだ変更をカレントブランチに適用する。このコマンドはリポジトリ内の既存の履歴を変更するのではなく、別個の新しいコミットを作成する。マージ操作などと同じように、指定されたcommit
による変更を完全に適用するには、競合を解決しなければならないこともある。
典型的には、リポジトリ内のある別のブランチのあるコミットを、他のブランチへ持ち込むために使う。
例えばdevブランチにはA、B、C、D、Eというコミット群があるとする。Bから分岐したrel2_3というブランチには、B、V、W、Xというコミット群があるとする。この時、devブランチのDで修正されたバグをrel2_3ブランチにも持ち込みたい時には、
git checkout rel2_3
git cherry-pick dev~
というコマンドで、rel2_3ブランチの先頭にDの同じ内容であるD'コミットが作られる。
他の一般的な用途として、あるブランチからコミットをまとめて選び、新しいブランチ上に適用することで、一連のコミットを再構築することが挙げられる。my_devブランチにある複数のコミットをmasterブランチに再構築する例を挙げる。
git checkout master
git cherry-pick my_dev~
git cherry-pick my_dev~3
git cherry-pick my_dev~2
git cherry-pick my_dev
このようにmasterに好きなコミットを好きな順番で並べることができる。
・git revert
git revert commit
コマンドは、git cherry-pick commit
コマンドと似ているが、1つの大きな違いがある。それは指定されたコミットcommit
の逆を適用する、つまり指定されたコミットの効果を打ち消す新しいコミットを導入するというもの。これもcherry-pickと同様に、リポジトリ内の既存の履歴を変更しない。
・reset、revert、checkoutコマンドの違い
この三つは混乱を招く可能性がある。どれも同じ操作をしているように見えるし、他のバージョン管理システムではreset、revert、checkoutが異なる意味を持っているという理由もある。これらのコマンドをいつ使い、いつ使うべきでないか、という指針がある。
- 異なるブランチに移行したいなら
git checkout
を使うべき。 -
git reset
コマンドではブランチを移動しない。git reset
はカレントブランチのHEAD参照をリセットすることを目的としている。 -
git reset --hard
は既知の状態を復元するように設計されているので、失敗したマージを元に戻すことができるが、git checkout
はそうはならない。
git checkout
にまつわる混乱は、オブジェクト格納領域からファイルを取り出して作業ディレクトリへ配置し、作業ディレクトリのバージョンを置き換えるという付加的な能力によるもの。そのファイルのバージョンはカレントのHEADバージョンのこともあれば、以前のバージョンのこともある。
# インデックスからfile.cをチェックアウト
git checkout -- path/to/file.c
# リビジョンv2.3からfile.cをチェックアウト
git checkout v2.3 -- some/file.c
Gitはこれをパスのチェックアウトと呼ぶ。
前者の場合、オブジェクト格納領域から現在のバージョンを取得する点がリセット操作であるかのように見える。ローカルの作業ディレクトリのファイルの編集が破棄され、カレントのHEADのバージョンに「リセット」される。
後者の場合、ファイルの以前のバージョンがオブジェクト格納領域から取り出され、作業ディレクトリに配置される。これはそのファイルの「取り消し」作業に見える。
どちらの操作も、git reset
やgit revert
とみなすのは適切ではない。どちらもファイルは特定のコミットであるHEADとv2.3からそれぞれ「チェックアウト」されている。git revert
コマンドはコミット全体に対して作用するのであり、ファイルに対して作用するのでない。
他の開発者があなたのリポジトリをクローンするか、コミットをいくつかフェッチした場合には、コミット履歴の変更と密接に関係する。この時、あなたはリポジトリ内の履歴を変更するようなコマンドを使うべきではない。代わりにgit revert
を使うべき。git reset
やgit commit --amend
も使うべきでない。
・先頭コミットの変更
カレントブランチで最新コミットを変更するもっとも簡単な方法の一つが、git commit --amend
。一般にamendはコミットが基本的に同じ内容を持っているものの、いくつかの調整を要する部分があることを意味する。コミット後にミスタイプを修正するためによく使われるが、普通のコミットと同じようにリポジトリ内のどんなファイルでも修正できるし、ファイルの追加や削除も可能。git commit --amend
はコミットメッセージの修正に備えて、通常のgit commit
コマンドと同様にエディタを起動してメッセージ入力を促す。
コミットのリベース
git rebase
コマンドは一連のコミットとの元となるもの(基点)を変更する際に使う。これの一般的な使用は、あなたが開発している一連のコミットを、他のブランチに関して最新の状態に保つというもの。他のブランチとは、通常masterブランチか、他のリポジトリからの追跡ブランチ。
例えばmasterブランチとtopicブランチの二つがあるとする。masterブランチはA、B、C、D、Eというコミット群で、topicブランチはB、W、X、Y、Zであるとする。一連のコミットの基点をコミットBではなく、コミットEにすることで、masterブランチについて最新の状態に保つことができる。
git checkout topic
git rebase master
もしくは
git rebase master topic
この操作によってmasterブランチはそのままA、B、C、D、Eであるが、topicブランチはmasterの最後を起点とするE、W'、X'、Y'、Z'になる。これを前方移植(forward-port)と呼ばれている。この例ではtopicブランチがmasterブランチの前方へ移植されており、rebaseコマンドは後方移植(back-port)も可能である。
またgit rebase
コマンドはある開発ラインを全く異なるブランチへ完全移植するためにも使われる。この場合は--ontoコマンドをつける。
競合が見つかるとリベース操作は一時停止し、競合の解決が可能になる。競合を解決し、解決結果でインデックスを更新したら、git rebase --continue
コマンドでリベース操作を再開できる。このコマンドは解決された競合をコミットして操作を再開し、リベース対象の一連のコミットの次のものへ進める。
リベースによる競合を調べながら、特定のコミットが必要ないと決めたならgit rebase --skip
でそのコミットをスキップし、次に進むことができる。
もしリベース操作が完全に間違っていることに気付いた時には、git rebase --abort
で操作を破棄し、リポジトリを元のgit rebase
前に戻すこともできる。
なお、先ほどの例のように2つの直線のブランチをリベースする時は良いが、自分自身のブランチの内部を起点としている他のブランチがある時は要注意である。サブブランチを含んだブランチ全体をリベースしたい時は--preserbe-mergeオプションを使う。
またマージを含むブランチのリベースも混乱を産む原因となりやすいので気を付ける。
- リベースは既存のコミットを書き換えて、新しいコミットを作る
- 到達不可能になった古いコミットは削除される
- リベース前の古いコミットのユーザーは取り残されてしまう
- リベース前のコミットを使っているブランチがあるなら、それも同様にリベースする必要があるかもしれない
- 異なるリポジトリにリベース前コミットのユーザーがいる場合、あなたのリポジトリではすでに移動済みだとしても、そのリポジトリにはコミットのコピーが残ったままです。このため、彼らも同様にコミット履歴を修正しなければならなくなるろう
git rebase -iの実際の例
git rebase -i
を用いる例を作成する。
mkdir haiku
cd haiku
git init
cat haiku
Talk about colour
No jealous behaviour here
git add haiku
git commit -m"Start my haiku"
一度俳句を作るが、colourをcolorに変更する。
git diff
diff --git a/haiku b/haiku
index 088bea0..958aff0 100644
--- a/haiku
+++ b/haiku
@@ -1,2 +1,2 @@
-Talk about colour
+Talk about color
No jealous behaviour here
git commit -a -m "use color instead of colour"
俳句を追加して完成させる。
echo I favour red wine >> haiku
git diff
diff --git a/haiku b/haiku
index 958aff0..cdeddf9 100644
--- a/haiku
+++ b/haiku
@@ -1,2 +1,3 @@
Talk about color
No jealous behaviour here
+I favour red wine
git commit -a -m "Finish my colour haiku"
ここで綴りで再び迷い、英国式のouを米国式のoに変更するとする。
git diff
diff --git a/haiku b/haiku
index cdeddf9..064c1b5 100644
--- a/haiku
+++ b/haiku
@@ -1,3 +1,3 @@
Talk about color
-No jealous behaviour here
-I favour red wine
+No jealous behavior here
+I favor red wine
git commit -a -m"Use American spellings"
最終的なコミット履歴は以下になる。
git show-branch --more=4
[master] Use American spellings
[master^] Finish my colour haiku
[master~2] use color instead of colour
[master~3] Start my haiku
ここでコミット履歴を、俳句の完了→綴りの修正という形にしたいと思ったとする。
[master] Use American spellings
[master^] use color instead of colour
[master~2] Finish my colour haiku
[master~3] Start my haiku
ただ単語の綴りを修正しているコミット履歴が二つもあるのはよくないと思い、masterとmaster^を一つのコミットへ圧縮したいと思ったとする。
[master] Use American spellings
[master^] Finish my colour haiku
[master~2] Start my haiku
コミットの並び替え、編集、削除、複数コミットの1つのコミットへの圧縮、1つのコミットの複数コミットへの分割はgit rebase
コマンドに-iもしくは--interactiveオプションをつけることで実行できる。
pick 002a668 Finish my colour haiku
pick 1431447 Use American spellings
# Rebase 505057b..1431447 onto 505057b (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# . create a merge commit using the original merge commit's
# . message (or the oneline, if no original merge commit was
# . specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out
pick 002a668 Finish my colour haiku
pick d36a1d4 use color instead of colour
pick 1431447 Use American spellings
の順番に並べ替える。そうするとgit show-branch --more=4
で
[master] Use American spellings
[master^] use color instead of colour
[master~2] Finish my colour haiku
[master~3] Start my haiku
となり、適切に順番を変更できる。
つぎに行うことは綴り関する2つのコミットを1つのコミットへ圧縮すること。
git rebase -i master~3
pick ed2232c Finish my colour haiku
pick dcea66f use color instead of colour
squash 7279eaf Use American spellings
保存するとコミットメッセージの書き換えに移る。
# This is a combination of 2 commits.
# This is the 1st commit message:
use color instead of colour
# This is the commit message #2:
Use American spellings
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date: Sun Nov 17 23:09:56 2019 +0900
#
# interactive rebase in progress; onto 505057b
# Last commands done (3 commands done):
# pick dcea66f use color instead of colour
# squash 7279eaf Use American spellings
# No commands remaining.
# You are currently rebasing branch 'master' on '505057b'.
#
# Changes to be committed:
# modified: haiku
#
コミットメッセージを書き換える。
git show-branch --more=4
[master] Use American spellings
[master^] Finish my colour haiku
[master~2] Start my haiku
これで求めたいコミット履歴になった。今回はgit rebase -i master~3
を2回呼び出したが、一度に行うことも可能である。
リモートリポジトリ
これまではほぼ一つのローカルリポジトリしか扱わなかったが、これからは分散機能も見ていく
クローンとは、リポジトリのコピーをさす。クローンは、元のリポジトリのオブジェクトを全て含んでおり、その結果としてそれぞれ独立した自律的なリポジトリであり、元のリポジトリと真の意味で対象な関係を持っている。リポジトリをクローンすることは、コード共有の最初の一歩にすぎない。データの交換経路を確立するために、あるリポジトリを別のリポジトリに関連づける必要がある。Gitはこのリポジトリの接続を「リモート」を介して実現する。
リモートとは、別のリポジトリへの参照、あるいはハンドルネームである。長くて複雑なGitのURLの短縮名として、リモートを使用する。1つのリポジトリの中にはリモートをいくつも定義できる。したがって、リポジトリを共有する精巧なネットワークを作成することができる。一度リモートが確立されると、Gitはプッシュモデルまたはプルモデルを使用して、あるリポジトリから別のリポジトリへとデータを転送することができる。例えばクローンの同期を保つために。クローンから元のリポジトリへとデータを転送するためにリモートを作成したり、双方向に情報を交換するために両方のリポジトリを設定したりすることもできる。
他のリポジトリからのデータを追跡するために、Gitは追跡ブランチ(tracking branch)を使用する。リポジトリ内の追跡ブランチは、それぞれがリモートブランチにある特定のブランチへのプロキシとして動作するローカルブランチになる。
またリポジトリを他のユーザーに提供することができ、Gitではこれを一般にレポジトリの公開と呼ぶ。方法は何種類かある。
リポジトリの概念(ベアリポジトリと開発リポジトリ)
Gitのリポジトリは、ベアリポジトリか、非ベアである開発リポジトリのどちらかである。
開発リポジトリは通常の開発に使用される、普通のブランチ。開発リポジトリはカレントブランチの概念を持っており、作業ディレクトリ内における現在のブランチのチェックアウトされたコピーを提供する。今まであげたリポジトリは基本的に全て開発ブランチ。
ベアリポジトリは作業ディレクトリを持っておらず、通常の開発には使用されない。ベアリポジトリにはチェックアウトされたブランチの概念もなく、ベアリポジトリにコミットを直接実行するべきではない。ベアリポジトリは、共同開発における信頼できる基盤として、重要な役割を持つ。開発者はベアリポジトリからクローンやフェッチを行い、更新をプッシュする。複数の開発者による変更をプッシュするためのリポジトリを立ち上げるなら、それはベアリポジトリにすべき。
git clone
コマンドに--bareオプションをつけるとベアリポジトリが作成され、それ以外の場合は開発リポジトリが作成される。
デフォルトではGitはreflog(参照への変更記録)を開発リポジトリで有効にするが、ベアリポジトリでは有効にしない。このことからもベアリポジトリで開発が行われないことが確認できる。
リポジトリのクローン
git clone
コマンドは、指定された元となるリポジトリに基づき、新しいGitのリポジトリを作成する。ただし元リポジトリの全ての情報をクローンにコピーせず、元のリポジトリのみで永続性を持つ情報は無視する。例えば元のリポジトリのrefs/remotes/にある追跡ブランチはコピーされない。また設定ファイルやreflog、元のリポジトリの隠し情報もコピーされない。git clone
によって元リポジトリのrefs/heads/に格納されているローカルな開発ブランチは、新しいクローンのrefs/remotes/下のリモート追跡ブランチとなる。また、元のリポジトリのタグもクローンにコピーされる。
git clone https://github.com/pytorch/examples.git
デフォルトでは、新しいクローンはそれぞれoriginと呼ばれるリモートを介して、親のリポジトへのリンクを保持する。これはGithubとかでクローンしてきたリポジトリの.git/configを見れば書いてある。なお、元リポジトリは、いずれのクローンについての情報も、また、クローンへのリンクも持たない。またoriginという名前に特別な意味はない。この名前を変更したい時は、クローンの際に--origin nameオプションを使えばnameに変更できる。
Gitはデフォルトのfetch refspecを使って、デフォルトのoriginリモートを設定する。
# .git/configを見る
fetch = +refs/heads/*:refs/remotes/origin/*
このrefspecを確立することで、元のリポジトリから変更をフェッチすることによるローカルリポジトリの更新を継続したいという意思が示される。この場合、リモートリポジトリのブランチは、クローンにおいてorigin/masterやorigin/devといったorigin/で始まるブランチ名で利用できる。
リモート
現在作業中のリポジトリはローカルリポジトリ(カレントリポジトリ)と呼ばれ、ファイルを交換する相手のリポジトリはリモートリポジトリと呼ばれる。
Gitは、リモートと追跡ブランチの両方を使用して、他のリポジトリへの「接続」を参照し、手助けする。リモートはリポジトリに親しみやすい名前を提供する。この名前を実際のリポジトリURLの代わりに使うこともできるし、リモートはそのリポジトリに対する追跡ブランチの名前の一部になる。
git remote
コマンドを使って、リモートを作成、削除、操作、閲覧することができる。作成したリモートは全て.git/configファイルに記録され、git config
コマンドを使って操作できる。
git clone
に加えてリモートリポジトリを参照する他の一般的なコマンドは以下
-
git fetch
リモートリポジトリからオブジェクトとそれに関連したメタデータを取得する -
git pull
git fetch
と似ているが、それに加えて対応するブランチに変更をマージする -
git push
オブジェクトとそれに関連したメタデータをリモートリポジトリに転送する -
git ls-remote
リモート内の参照を表示する
追跡ブランチ
一度リポジトリをクローンすると、例えローカルでコミットしたり、ローカルブランチを作成したりした場合でも、元のソースリポジトリの変更に追随することができる。さらにソースリポジトリ(上流(upstream)リポジトリ)で作業している開発者が、仮にtestというブランチを作成済みの場合でも、testという名前でローカルブランチを作成できる。Gitでは追跡ブランチを通して、両方のtestブランチに追随することができる。
クローン処理では、Gitは元リポジトリにある各トピックブランチに対するリモート追跡ブランチを、クローンに作成する。ローカルリポジトリは追跡ブランチを使って、リモートリポジトリにある各トピックブランチに対するリモート追跡ブランチを、クローンに作成する。ローカルリポジトリは追跡ブランチを使って、リモートリポジトリに加えられた変更を追跡する。追跡ブランチはその固有の名前空間にまとめられるので、リポジトリ内に作成したブランチ(トピックブランチ)と、実際にじゃ別のリモートリポジトリに基づいているブランチ(追跡ブランチ)の間には、明確な区別がある(リモートブランチはrefs/remotes/という名前空間に保持されるので、リモート追跡ブランチorigin/masterは実際にはrefs/remotes/origin/masterとなる)。
通常のトピックブランチ上で実行できる全ての操作は、追跡ブランチ上でも実行可能だが、いくつかの制限と指針がある。追跡ブランチは他のリポジトリからの変更を追跡するために使われるので、追跡ブランチに対するマージやコミットはすべきではない。これを行うと追跡ブランチがリモートリポジトリと同期しなくなる。
・他のリポジトリを参照する
リポジトリを他のリポジトリと協調されるために、リモートが定義される。リモートとは、リポジトリのconfigファイルに格納される、名前付きの実体である。リモートは2つの異なる部分から構成される。1つは他のリポジトリの名前をURL形式で示す。2つ目の部分は、refspecと呼ばれ、参照を、あるリポジトリの名前空間から他のリポジトリの名前空間へと対応づけるための方法を指定する。
Gitはリモートリポジトリを指定することができるいくつかの形式のURLをサポートしている。これらの形式では、アクセスプロトコルとデータの場所または、アドレスの両方を指定する。厳密にはGitのURL形式はURL(Uniform Resources Locator)にもURI(Uniform Resource Identifier)にも該当しない。しかし、Gitが使うURL亜種は、Gitリポジトリの位置を指定することに多目的に使える有用性のために、通常Git URLと呼ばれている。
一番簡単なGit URLはローカルファイルシステム上のリポジトリを参照する。
file:///path/to/repo.git
他の形式のGit URLではリモートシステム上のリポジトリが参照される。最も効率的なデータ転送形式は、Gitネイティブプロトコルと呼ばれ、以下のような例がある。
git://example.com/path/to/repo.git
安全かつ認証された接続を行うために、GitネイティブプロトコルをSSH接続越しにトンネルすることもできる
ssh://user@example.com:port/path/to/repo.git
Gitはscp
コマンドのような構文によるURLもサポートしている。
user@example.com:/path/to/repo.git
HTTPとHTTPSのURL形式も完全にサポートされているが、Gitネイティブプロトコルほど効率的ではない
http://example.com/path/to/repo.git
https://example.com/path/to/repo.git
refspec
参照は通常、ブランチの名前になる。refspecは、リモートリポジトリ中のブランチ名を、ローカルリポジトリ中のブランチ名に対応づける。refspecは、ローカルリポジトリとリモートレポジトリのブランチを両方同時に指定することが必要である。このため、refspecでは完全なブランチ名を使用することが一般的で、しばしば必須となる。refspecでは、開発ブランチの名前がref/heads/という接頭語を持ち、追跡ブランチの名前がrefs/remotes/という接頭語を持つのが典型的。refspecの構文は以下
[+]source:destination
refspecは基本的に転送元の参照(source ref)、コロン、転送先の参照(destination ref)から構成される。プラス記号をつけた場合は、転送中にfast-forwardによる安全性チェックが実行されなくなる。また*を使ってブランチ名に一致させるワイルドカードを制限付きの形式で適用できる。
refspec自体は常に「source:destination」という形式だが、「source」と「destination」の役割はGitの操作によって異なる。
例えばgit push
なら、sourceはプッシュされるローカル参照でdestinationは更新されるリモート参照になるのに対し、git fetch
なら、sourceはフェッチされるリモート参照でdestinationは更新されるローカル参照となる。
典型的なgit fetch
コマンドでは、次のようなrefspecを使う。
+refs/heads/:refs/remotes/origin/
このrefspecは次のように言い換えられる。
リモートリポジトリの名前空間refs/heads/にあるソースブランチは全て(1)originの名前から作成された名称を使ってローカルリポジトリに対応づけられ、(2)refs/remotes/origin名前空間の元に置かれる
このrefspecはアスタリスクを含むため、リモートのrefs/heads/*で見つかった複数のブランチに適用される。
refspecはgit fetch
(git pull
)とgit push
の両方で使用される。git fetch
とgit push
のコマンドラインに複数のrefspecを渡すこともできる。
・リモートリポジトリの使用例
簡単のために、1つのパソコン上で複数のリポジトリを動かすことにする。別に他の形式のリモートURL記法でも、物理的に異なるマシン上のリポジトリで同一のメカニズムが適用できる。
全ての開発者が権威があると考えられるリポジトリを構築するとする。この合意された権威あるコピーはしばしばdepot(デポ)と知られる特別なディレクトリに置かれる。このリポジトリ上で並行して開発が進む様子を示すために、2番目の開発者がクローンを行い、ローカルリポジトリで作業し、変更を全員が利用できるようにdepotにプッシュするものとする。
好きなディレクトリ(例えば/tmp/Depot)を権威あるdepotにすることにする。/tmp/Depotディレクトリ直下やそこにあるリポジトリ内で、実際の開発作業を行ってはいけない。ローカルクローンの中で個別に作業するべき。
最初は/tmp/Depotを作成して、初期リポジトリを用意する。仮に~/public_htmlがGitのリポジトリとしてすでに用意されており(このリポジトリはgit init
で作ったものとする)、その中でWebサイトのコンテンツを編集したいとする。この時~/public_htmlリポジトリのコピーを作成し、/tmp/Depot/public_htmlにおく。
cd /tmp/Depot/
git clone --bare ~/public_html public_html.git
Cloning into bare repository 'public_html.git'...
done.
このclone
コマンドはGitのリモートリポジトリを、~/public_htmlから現在のカレントディレクトリである/tmp/Depotにコピーする。慣習により、ベアリポジトリは.gitという接尾辞をつけて命名する。(なお、git clone --bare ~/public_html
と.gitをつけなくてもpublic_html.gitが作成される)
cd ~/public_html
ls -a
. .. .git index.html test
ベアリポジトリには作業ディレクトリがないので、ファイル構成はより単純になる。
cd /tmp/Depot/public_html.git
ls -a
. .. HEAD branches config description hooks info objects packed-refs refs
今後はこのベアリポジトリ/tmp/Depot/public_html.gitを権威付きのリポジトリとして扱うことができる。
cat config
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true
これで2つのリポジトリができたが、初期リポジトリには作業ディレクトリが存在し、ベアクローンには存在しないという点が異なる。~/public_htmlリポジトリはgit init
で作られたものであるので、originが存在しない。
cd ~/pulic_html
cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
git remote add origin /tmp/Depot/public_html
cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = /tmp/Depot/public_html.git/
fetch = +refs/heads/*:refs/remotes/origin/*
このgit remote
操作でoriginと呼ばれる新しいremoteセクションが設定に追加された。リモートはカレントリポジトリからリモートリポジトリへのリンクを確立する。この場合、url値に記録されているようにリモートリポジトリは/tmp/Depot/public_html.gitにある。これによりリモートリポジトリの短縮名としてoriginが使用できる。この時、ブランチ名の変換についての慣習に従うデフォルトのfetch refspecも追加されていることに注意。
originリモートの準備を完成させる。リモートリポジトリからのブランチを表現するため、元のリポジトリに新しい追跡ブランチを構築する。初めにmasterブランチが予想通り1つだけ存在することを確認する。
git branch -a
* master
git remote update
Fetching origin
From /tmp/Depo/public_html.git
* [new branch] master -> origin/master
git branch -a
* master
remotes/origin/master
Gitはorigin/masterと呼ばれる新しいブランチをリポジトリに用意する。これはoriginリモート内の追跡ブランチ。この中で開発を行うことはない代わりに、リモートのoriginリポジトリのmasterブランチに加えられたコミットを保持し、追跡する。
git remote update
コマンドは、リモートで指定された各リポジトリの新しいコミットをチェックし、フェッチを行うことでリポジトリ内の全てのリモートの更新を行う。
ここで、リポジトリの中で開発を進めて、fuzzy.txtを追加する。
cd ~/public_html
git show-branch -a
* [master] add text
! [origin/master] add text
--
*+ [master] add text
cat fuzzy.txt
Fuzzy Wuzzy was a bear
Fuzzy Wuzzy had no hair
Fuzzy Wuzzy wasn't very fuzzy,
Was he?
git add fuzzy.txt
git commit -m"Add a hairy poem"
[master 8cd52b8] Add a hairy poem
1 file changed, 4 insertions(+)
create mode 100644 fuzzy.txt
この時点で興味深いのは、このリポジトリに二つのブランチ(masterとorigin/master)があるということ。masterは新しいコミットを含む方で、origin/masterはリモートリポジトリを追跡する方。
変更をpushする。
git push origin
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 366 bytes | 366.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To /tmp/Depot/public_html.git/
2fd0efb..8cd52b8 master -> master
上の出力は、Gitがmasterブランチの変更を受け取って、それをひとまとめにし、originという名前のリモートリポジトリに送信したことを意味している。Gitはこの時、もう一つの処理を実行する。それは同じ変更をあなたのリポジトリのorigin/masterブランチにも追加するということ。この追跡ブランチをfast-forwardしている。これでローカルブランチmasterと、origin/masterの両方にリポジトリの同一のコミットが反映される。
git show-branch -a
* [master] Add a hairy poem
! [origin/master] Add a hairy poem
--
*+ [master] Add a hairy poem
cd /tmp/Depot/public_html.git
git show-branch
[master] Add a hairy poem
ここで新しい開発者Bobを追加する。
cd /tmp/Bob
git clone /tmp/Depot/public_html.git
Cloning into 'public_html'...
done.
ls
public_html
cd public_html
fuzzy.txt index.html test
git branch
* master
git log -1
commit 8cd52b8d5f2b1badc632547625165723d10f08a2 (HEAD -> master, origin/master, origin/HEAD)
Author: git taro <hello@example.com>
Date: Sun Dec 8 00:30:00 2019 +0900
Add a hairy poem
Bobは自分のリポジトリの中で、originリモートの詳細を調べることができる。
git remote show origin
* remote origin
Fetch URL: /tmp/Depot/public_html.git
Push URL: /tmp/Depot/public_html.git
HEAD branch: master
Remote branch:
master tracked
Local branch configured for 'git pull':
master merges with remote master
Local ref configured for 'git push':
master pushes to master (up to date)
設定ファイルを見ると、クローンがoriginリモートを含んでいる様子がわかる。
cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = /tmp/Depot/public_html.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
git branch -a
* master
remotes/origin/HEAD -> origin/master
remotes/origin/master
Bobのリポジトリには、originリモートに加えて、いくつかのブランチもある。masterブランチが、Bobのメインの開発ブランチで、通常のローカルトピック。remotes/origin/masterブランチは、originリポジトリのmasterブランチからコミットを追跡するための追跡ブランチ。remotes/origin/HEADブランチは、シンボル名を通じてリモートがアクティブブランチとして扱うブランチがどれかを示す。(アクティブブランチとは、作業ディレクトリにどのファイルがチェックアウトされているかを決めるブランチ)
Bobがfuzzy.txtを書き換えて。コミットさせ、それをメインのDepotにpushする。
git diff
diff --git a/fuzzy.txt b/fuzzy.txt
index 0d601fa..608ab5b 100644
--- a/fuzzy.txt
+++ b/fuzzy.txt
@@ -1,4 +1,4 @@
Fuzzy Wuzzy was a bear
Fuzzy Wuzzy had no hair
Fuzzy Wuzzy wasn't very fuzzy,
-Was he?
+Wuzzy?
git commit fuzzy.txt
[master 6a36716] Make the name pun complete!
1 file changed, 1 insertion(+), 1 deletion(-)
git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 346 bytes | 346.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0)
To /tmp/Depot/public_html.git/
8cd52b8..6a36716 master -> master
Bobの変更を受けとってリポジトリを最新の状態にしたいとする。このための基本的なコマンドがgit pull
となる。
cd ~/public_html
git pull
remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /tmp/Depot/public_html
8cd52b8..6a36716 master -> origin/master
Updating 8cd52b8..6a36716
Fast-forward
fuzzy.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
完全な形式のgit pull
は、リポジトリと複数のrefspecの両方を指定することができる。これはgit pull options repository refspecs
という形式。コマンドラインにおいてrepository
を指定しなかった時は、デフォルトであるoriginが使用される。さらにコマンドラインでrefspec
も指定しなかった場合は、リモートのfetch refspecが使用される。一方リポジトリを指定し、refspec
を指定しなかった場合は、GitはリモートのHEAD参照をフェッチする。
git pull
の動作は2つの段階からなる。
1つ目の段階はgit fetch
である。2つ目の段階はgit merge
かgit rebase
である。デフォルトではgit merge
を行う。よって git pull
とgit push
は反対の関係にならない。git push
とgit fetch
が反対の関係となる。git fetch
とgit merge
を2つの別々の操作として実行したい時がある。例えば更新をリポジトリにフェッチして内容を確認したいけど、すぐにはマージする必要がない時など。こういう時はgit pull
は使わないで、git fetch
して、追跡ブランチ上でgit log
やgit diff
などをしてから、準備ができた時にgit merge
を実行すればよい。この2つの段階をもう少し詳しく見ていく。
・fetchの段階
Gitはfetchの段階で、まずリモートリポジトリを特定する。特にコマンドライン等で指定されなければ、デフォルトのリモート名originになる。設定ファイルの[remote "origin"]の部分からソースリポジトリのURLを判断する。次にリモートリポジトリにどのような新しいコミットがあり、どれがあなたのリポジトリに無いかを特定するために、Gitはソースリポジトリとの間でプロトコルネゴシエーションを行う。これは。refs/heads/*の参照を全てフェッチするという、fetch refspecに基づいて行われる。
cat .git/config
[remote "origin"]
url = /tmp/Depot/public_html.git/
fetch = +refs/heads/*:refs/remotes/origin/*
なおremote:で始まる行は、ネゴシエーション、圧縮、そして転送プロトコルを示す。
この2行の出力行は、Gitがリモートリポジトリ/tmp/Depot/public_html.gitを見にいき、その位置のmasterブランチを取得し、その内容をあなたのリポジトリに転送し、あなたのorigin/masterブランチに格納したことを示す。なので、git fetch
した後、git checkout origin/master
して作業ディレクトリを見るとその変更内容を見ることができる。
・mergeまたはrebaseの段階
上の例では、Gitはfast-forwardという特殊な種類のマージを使って、追跡ブランチのorigin/masterの内容をmasterブランチにマージする。
Gitはこれらのブランチを特定するために、設定ファイルを用いる。
cat .git/config
[branch "master"]
remote = origin
merge = refs/heads/master
現在のチェックアウトされたブランチがmasterの場合は、更新をfetchする際にデフォルトリモートとしてoriginを使用すること、そしてgit pull
のマージの段階ではmasterブランチとマージするデフォルトブランチとしてリモートのrefs/heads/masterを使用すること、ということが述べられている。
リモート追跡ブランチを元に新しいブランチを作る(git branch
コマンドを実行する)と、Gitはその追跡ブランチが新しいブランチにマージされるべきであることを示すため、branch項目を自動的に追加する。
git branch mydev origin/master
Branch 'mydev' set up to track remote branch 'master' from 'origin'.
cat .git/config
[branch "mydev"]
remote = origin
merge = refs/heads/master
リベースをブランチの通常作業にするには、rebase設定変数をtrueにする。リベースすると、マージコミットを作らないように変えられるので、そこは開発の取り決め次第。
git config branch.mydev.rebase true
[branch "mydev"]
remote = origin
merge = refs/heads/master
rebase = true
・リモートブランチの追加と削除
リモートブランチ上でブランチの追加と削除を同様に実行するためには、git push
コマンドで異なるrefspecを指定する必要がある。refspecの構文は以下。
[+]source:destination
転送元(source)の参照だけのrefspec(つまりdestinationを指定しない)refspecを使ってgit push
を実行すると、リモートリポジトリに新しいブランチが作成される。
cd ~/public_html
git branch foo
git push origin foo
Total 0 (delta 0), reused 0 (delta 0)
To /tmp/Depot/public_html.git/
* [new branch] foo -> foo
転送先の参照だけ(つまり転送元のsourceを指定しない)refspecを使ってgit push
を実行すると、リモートリポジトリから転送先の参照が削除される。参照が転送先のものであることを示すために:(コロン)の区切りを必ず指定する必要がある。
git branch -a
foo
* master
mydev
remotes/origin/foo
remotes/origin/master
git push origin :foo
To /tmp/Depot/public_html.git/
- [deleted] foo
git branch -a
foo
* master
mydev
remotes/origin/master
・リモートの設定
上で見てきたようにGitはリモートに関する情報を.git/configに記録している。これはgit remote
コマンド、git config
コマンドで書き換えることができる(もちろんemacsなどで直接編集しても良い)。
git remote
コマンドはリモートに特化し、設定ファイルのデータを書き換える専用のコマンド。git remote aaaa
のように適当にコマンドを打つとUsageが表示される。
git remote aaaa
usage: git remote [-v | --verbose]
or: git remote add [-t <branch>] [-m <master>] [-f] [--tags | --no-tags] [--mirror=<fetch|push>] <name> <url>
or: git remote rename <old> <new>
or: git remote remove <name>
or: git remote set-head <name> (-a | --auto | -d | --delete | <branch>)
or: git remote [-v | --verbose] show [-n] <name>
or: git remote prune [-n | --dry-run] <name>
or: git remote [-v | --verbose] update [-p | --prune] [(<group> | <remote>)...]
or: git remote set-branches [--add] <name> <branch>...
or: git remote get-url [--push] [--all] <name>
or: git remote set-url [--push] <name> <newurl> [<oldurl>]
or: git remote set-url --add <name> <newurl>
or: git remote set-url --delete <name> <url>
-v, --verbose be verbose; must be placed before a subcommand
git remote add
では新しいリモートを追加する、git remote show
ではリモートの情報を取り出し、git remote update
でリモートリポジトリで利用可能な全ての更新をローカルリポジトリにフェッチする。git remote rm
では指定されたリモートとそれに関連する全ての追跡ブランチをローカルリポジトリから削除する。その他の細かいコマンドはマニュアルを見てくれ。
例えばpublishという名前のリモートを、公開したい全てのブランチに対するプッシュrefspecを使って追加するには次のようにする
git config remote.publish.url 'ssh://git.example.org/pub/repo.git'
git config remote.publish.push '+refs/heads/*:refs/heads/*'
これを行うことによって.git/configの中には部分的に以下のリモート定義が含まれるようになる
[remote "publish"]
url = ssh://git.example.org/pub/repo.git
merge = +refs/heads/*:refs/heads/*
なおgit config -l
によって設定ファイルの内容を一覧表示することもできる。
・ベアリポジトリとgit push
git push
コマンドが、プッシュを受信する側のリポジトリのファイルをチェックアウトしないことに注意すべき。受信する側のレポジトリがベアリポジトリの時は、更新される作業ディレクトリが存在しないため、これが想定通りの挙動となる。しかし、git push
を受信する側のリポジトリが開発レポジトリの時は、問題が起きることもある。プッシュ操作は、HEADコミットも含めて、リモートリポジトリの状態を更新することがある。つまり、リモートの開発者が何もしていない場合でも、ブランチの参照とHEADが変更され、チェックアウトされたファイルやインデックスと同期しなくなる場合がある。結果としてgit push
はベアリポジトリのみに行うことが奨励される。
パッチ
コミットを交換し、分散されたリポジトリの同期を保つための仕組みはGitネイティブプロトコルやHTTPプロトコルだけではない。これらのプロトコルが使用できない時もあり、プロトコル以外に「パッチと適用」の操作もサポートしている。これは初期のUnix開発の時代に用いられていた方式を取り入れたもので、電子メールを使ってデータを交換する方法。
3つの特別なコマンドを実装している
-
git format-patch
は、パッチを電子メール形式で生成する -
git send-email
は、SMTPフィードを通じて、Gitのパッチを送信する -
git am
は、電子メール中に見つかったパッチを適用する
基本的なシナリオは以下
あなたと1人以上の他の開発者が、共通のリポジトリのクローンをもとに共同開発を始める。あなたが何らかの作業を行い、自分のリポジトリコピーに幾つかのコミットを実行する。そして、他の開発者に共有したいコミットを電子メールで送る。メールが送られてきた開発者はそのパッチを適用しないか、いくつかを適用する、ということが選べる。
・なぜパッチを使用するのか
Gitのプロトコルの方が効率的であるにもかかわず、パッチを使う理由として、少なくとも2つの理由がある
- ある状況では、リポジトリ間で、プッシュ操作やプル操作等を用いてデータを交換するための手段としてGitネイティブプロトコルとHTTPプロトコルのどちらも使用できないことがある。例えば、企業のファイアウォールによって外部サーバへの接続を開くことができないなど。
- ピアツーピア開発モデルの利点の1つは、共同作業である。特に、パッチを公開メーリングリストへ送信することで、変更の提案を公に広め、査読を受けることができる。例えば直接プッシュやプルなどの便利な交換ができたとしても、あえて「パッチ、電子メール、レビュー、適用」の枠組みを採用したいと思うこともあるかもしれない。
・パッチの生成
git format-patch
コマンドは、パッチを電子メールメッセージの形式で生成する。このコマンドは、指定された各コミットに対して、それぞれ1つの電子メールを作成する。
一般的なコミットの指定方法は以下
・指定された数のコミット。-2
など。
・コミットの範囲。master~4..master~2
など。
・単一のコミット。多くの場合master
などのブランチ名。
git format-patch
コマンドの心臓部には、Gitのdiff機構がある。これはgit diff
と比較として2つの重要な意味で異なる。
-
git diff
は、選択されたコミット全ての結合された差分の1つのパッチとして生成するが、git format-patch
は、選択された各コミットに対して、1つずつ電子メールメッセージを生成する。 -
git diff
は、電子メールのヘッダを作成しない。git format-patch
は実際の差分の内容に加えてコミットの作者やコミット日時、そして変更と関連付けられたコミットログメッセージを一覧にしたヘッダを持つ、完全な電子メールを生成する。
この差を見るために、git format-patch -1
で得られたパッチとgit log -p -1 --pretty=email
の出力を比較する
git format-patch -1
0001-F.patch
cat 0001-F.patch
From 749aa20326314f9920044b65d28515bf54f8fc2f Mon Sep 17 00:00:00 2001
From: git taro <hello@example.com>
Date: Mon, 9 Dec 2019 19:27:53 +0900
Subject: [PATCH] F
---
file | 1 +
1 file changed, 1 insertion(+)
diff --git a/file b/file
index 6ac15b9..65f21da 100644
--- a/file
+++ b/file
@@ -5,3 +5,4 @@ D
X
Y
Z
+F
--
2.21.0
git log -p -1 --pretty=email
From 749aa20326314f9920044b65d28515bf54f8fc2f Mon Sep 17 00:00:00 2001
From: git taro <hello@example.com>
Date: Mon, 9 Dec 2019 19:27:53 +0900
Subject: [PATCH] F
diff --git a/file b/file
index 6ac15b9..65f21da 100644
--- a/file
+++ b/file
@@ -5,3 +5,4 @@ D
X
Y
Z
+F
違いは履歴の概略と、バージョン情報が出ているだけである。
パッチの具体例
パッチを生成する簡単な例から始める。
mkdir patch
git init
echo A > file
git add file
git commit -mA
echo B >> file ; git commit -mB file
echo C >> file ; git commit -mC file
echo D >> file ; git commit -mD file
git show-branch --more=4 master
[master] D
[master^] C
[master~2] B
[master~3] A
最新のn件のコミットに対応するパッチを生成する最も簡単な方法は、-nオプションを使うこと
git format-patch -1
0001-D.patch
git format-patch -3
0001-B.patch
0002-C.patch
0003-D.patch
次に、BとDの間で加えた変更を全てパッチで送信したいとする。この時はgit format-patch master~2..master
というコマンドで良い。
git format-patch master~2..master
0001-C.patch
0002-D.patch
ここで0001-C.patchにはBとCの差分が、0002-D.patchにはCとDの差分が入っている。
cat 0001-C.patch
From 2f39214910fb2d71c1c3e837a79f7f80d1380b3e Mon Sep 17 00:00:00 2001
From: git taro <hello@example.com>
Date: Mon, 9 Dec 2019 19:12:13 +0900
Subject: [PATCH 1/2] C
---
file | 1 +
1 file changed, 1 insertion(+)
diff --git a/file b/file
index 35d242b..b1e6722 100644
--- a/file
+++ b/file
@@ -1,2 +1,3 @@
A
B
+C
--
2.21.0
次は2つ目のパッチを見る。
cat 0002-D.patch
From 7e14a3ff20b322d26415128f6067665e9cd26b39 Mon Sep 17 00:00:00 2001
From: git taro <hello@example.com>
Date: Mon, 9 Dec 2019 19:12:37 +0900
Subject: [PATCH 2/2] D
---
file | 1 +
1 file changed, 1 insertion(+)
diff --git a/file b/file
index b1e6722..8422d40 100644
--- a/file
+++ b/file
@@ -1,3 +1,4 @@
A
B
C
+D
--
2.21.0
git log --pretty=oneline --abbrev-commit
7e14a3f (HEAD -> master) D
2f39214 C
c26254d B
0d6aac2 A
ここで、コミットBに基づく別のブランチaltを追加して、状況をより複雑にしてみる。ブランチaltでは、Bから新たにX,Y,Zというコミットを追加する。
git checkout -b alt c26254d
echo X >> ; git commit -mX file
echo Y >> ; git commit -mY file
echo Z >> ; git commit -mZ file
git log --graph --pretty=oneline --abbrev-commit --all
* 2ac8c00 (HEAD -> alt) Z
* b12467c Y
* 37f38c6 X
| * 7e14a3f (master) D
| * 2f39214 C
|/
* c26254d B
* 0d6aac2 A
さらにmasterの開発者がコミットZにおけるaltを、コミットDにおけるmasterへとマージしてマージコミットEを作成したとする。
git checkout master
git merge alt
ここで、次の結果になるように競合を解決する。
cat file
A
B
C
D
X
Y
Z
git add file
git commit -m'All lines'
[master fe4c80f] All lines
echo F >> file ; git commit -mF file
[master 749aa20] F
1 file changed, 1 insertion(+)
コミットFも追加し、最終的なコミットグラフは以下のようになる。
git log --graph --pretty=oneline --abbrev-commit --all
* 749aa20 (HEAD -> master) F
* fe4c80f All lines
|\
| * 2ac8c00 (alt) Z
| * b12467c Y
| * 37f38c6 X
* | 7e14a3f D
* | 2f39214 C
|/
* c26254d B
* 0d6aac2 A
コミット範囲を指定する時、そこにマージが含まれる場合は注意が必要である。現在の例で言うと、範囲D..Fには、E(All linesのコミット)とFへの二つのコミットが含まれるかと思うが、実際はそうではない。
git show-branch --more=10
! [alt] Z
* [master] F
--
* [master] F
+* [alt] Z
+* [alt^] Y
+* [alt~2] X
* [master~2] D
* [master~3] C
+* [master~4] B
+* [master~5] A
git format-patch master~2..master
0001-X.patch
0002-Y.patch
0003-Z.patch
0004-F.patch
上の例ではDとFを指定しているにもかかわらず、パッチに出てくるのはX,Y,Z,Fのコミットとなる。コミット範囲の定義は、範囲の終点までに含まれる全てのコミットから範囲の始点までと、始点自体を含む全てのコミットを除外したものがコミット範囲となる。上の例では、Fに貢献しているコミット全て(つまり全コミット)から、DとDに貢献しているコミット(A,B,C,D)を引いたものになる。
・git format-patch
のコミット範囲の変形として、単一のコミットcommitを参照することもできるが、範囲はcommit..HEADと指定されたように解釈する。
git branch
alt
* master
git format-patch master~5
0001-B.patch
0002-C.patch
0003-D.patch
0004-X.patch
0005-Y.patch
0006-Z.patch
0007-F.patch
git checkout alt
Switched to branch 'alt'
git branch
* alt
master
git format-patch master~5
0001-B.patch
0002-X.patch
0003-Y.patch
0004-Z.patch
同じmaster~5を指定しているにもかかわらず、カレントブランチによって結果が変わっていることがわかる。なお、Aコミットを指定しているにもかかわらず、Aコミットのパッチは得られない。ルートコミットは特別なものであり、最初のルートコミットから指定したコミットend-commitまでの全てのパッチを生成したい時は--rootオプションを使用する。
git format-patch --root master
0001-A.patch
0002-B.patch
0003-C.patch
0004-D.patch
0005-X.patch
0006-Y.patch
0007-Z.patch
0008-F.patch
単一のコミット指定をcommit..HEADのように扱うことはあまりないように思えるが、特定の状況で役に立つ時がある。例えば現在チェックアウトしているブランチとは異なるブランチ上のコミットを指定すると、カレントブランチには存在するものの、指定したブランチには存在しないパッチが生成される。
git branch
alt
* master
git format-patch alt
0001-C.patch
0002-D.patch
0003-F.patch
masterにいる時にブランチaltを指定するだけで、masterブランチにあってaltブランチにないパッチの集合を生成することができる。これが使えるのは、指定されたコミットが、他の開発者のリポジトリからの追跡ブランチの先頭にあたる時である。例えばAliceのリポジトリをクローンし、masterの開発をAliceのmasterに基づいて行うとすると、Alice/masterのような追跡ブランチを持つことになる。このとき、自分のmasterブランチで幾つかのコミットを行ったあと、コマンドgit format-patch alice/master
で生成されたパッチをAliceに送るだけで、Aliceは自分の内容を全て把握することができるようになる。
・パッチとトポロジカルソート
git format-patch
が生成するパッチは、トポロジカルソートの順序によって発行される。この発行された順序に従ってパッチを適用していけば、そのリポジトリが正確な状態になるが、この順序は複数の候補がありうる。
A B C D X Y Z E F
A B X Y Z C D E F
A B X C Y D Z E F
などなど。より正確に記述するなら
A > B
B > C
C > D
D > E
B > X
X > Y
Y > Z
Z > E
E > F
という制約が満たされていれば、リポジトリは正確な状態になる。
Gitがそのパッチの順序を選択する場合でも、元のグラフの複雑さや分岐の度合いにかかわらず、Gitが選択された全てのコミットを線形化している。
パッチのメール送信
生成されたパッチファイルを別の開発者に送信したい場合、ファイルを送信する方法として1つある。
-
git send-email
を実行する方法 - メーラにパッチを直接参照させる方法
- パッチを電子メールの中に含める方法
1の方法について
例えばgit send-email -to hello@example.com 0001-A.patch
というコマンドで、0001-A.patchというパッチを指定したメアドに送ることができる。ただし普段シェルからメールを送ることがないという人は、SMTPなどの設定を行う必要がある。
git config --global sendemail.smtpserver smtp.my-isp.com
git config --global sendemail.smtpserverport 400
など。詳細な設定の仕方はググってみてください。
2の方法について
MUA(Mail User Agent)によっては、メールフォルダにパッチのファイル全体やディレクトリを直接インポートすることができ、サイズの大きなパッチや複雑な一続きのパッチを単純に送信できる。次の例では、git format-patch
コマンドを使って、伝統的なmbox形式のメールフォルダを作成し、それをメッセージ送信プログラムとなるmuttに直接インポートする
git format-patch --stdout master~2..master > mbox
mutt -f mbox
この1と2の方法は、パッチのメール送信として推奨されるやり方である。両方とも信頼性が高く、パッチの内容に干渉する恐れが少ないため。一方、3のように、例えばThunderbirdやgmailなどを用いて電子メールを新規作成し、送信する場合は、パッチの内容が勝手に自動改行されたり、HTML整形されたりしないようにする必要がある。
パッチの適用
下回りコマンドであるgit apply
を除けば、git am
がパッチを適用するために用いるコマンドとなる。これはカレントブランチ上でコミットを生成することに注意。
なお、git am
のam
は、Apply a series of patches from a mailboxの略らしい。ソースはこちら。
先ほどの例を使って実験を行う。具体的にはこのコミット状況である。
git log --graph --pretty=oneline --abbrev-commit --all
* 749aa20 (HEAD -> master) F
* fe4c80f All lines
|\
| * 2ac8c00 (alt) Z
| * b12467c Y
| * 37f38c6 X
* | 7e14a3f D
* | 2f39214 C
|/
* c26254d B
* 0d6aac2 A
ここでgit format-patch -o ~/patches master~5
によって生成された
~/patches/0001-B.patch
~/patches/0002-C.patch
~/patches/0003-D.patch
~/patches/0004-X.patch
~/patches/0005-Y.patch
~/patches/0006-Z.patch
~/patches/0007-F.patch
という7つのパッチを用いて、別のリポジトリに適用していく(なお上の例のようにgit format-patch
コマンドは、-oオプションであるディレクトリを作ってそれにパッチを入れることができる)。
mkdir am
cd am
git init
Initialized empty Git repository in ~/am/.git/
echo A >> file
git add file
git commit -mA
[master (root-commit) da99743] A
1 file changed, 1 insertion(+)
create mode 100644 file
まずは全く同じ内容の新しいリポジトリを作成する。これにgit am
を直接適用すると、問題が起こる。
git am ~/patches/*
Applying: B
Applying: C
Applying: D
Applying: X
error: patch failed: file:1
error: file: patch does not apply
Patch failed at 0004 X
hint: Use 'git am --show-current-patch' to see the failed patch
When you have resolved this problem, run "git am --continue".
If you prefer to skip this patch, run "git am --skip" instead.
To restore the original branch and stop patching, run "git am --abort".
これは、2つのブランチができていることをgit側が認識できていなためである。まずは状況を確認してみる。
git diff
# 何も表示されない
git show-branch --more=5
[master] D
[master^] C
[master~2] B
[master~3] A
cat file
A
B
C
D
本来のリポジトリはこのように分岐していることを思い出す。
* 749aa20 (HEAD -> master) F
* fe4c80f All lines
|\
| * 2ac8c00 (alt) Z
| * b12467c Y
| * 37f38c6 X
* | 7e14a3f D
* | 2f39214 C
|/
* c26254d B
* 0d6aac2 A
AからDまでは正しく適用できていることがわかる。git am
を適用すると、.git/rebase-applyディレクトリが作られる。そこには一連のパッチや各パッチの個別部分についての様々な情報が含まれている。
cat .git/rebase-apply/patch
---
file | 1 +
1 file changed, 1 insertion(+)
diff --git a/file b/file
index 35d242b..7f9826a 100644
--- a/file
+++ b/file
@@ -1,2 +1,3 @@
A
B
+X
--
2.21.0
Xのパッチを適用できるのは、AとBしか含まないバージョンであるが、現在はAからDまで含んでいるバージョンになっている。最終的な目標は、全ての文字が順番通りに並ぶファイルを作成することだが、Gitはそれを自動で処理できない。Gitは親切にも、このような状況での提案を表示してくれている。
hint: Use 'git am --show-current-patch' to see the failed patch
When you have resolved this problem, run "git am --continue".
If you prefer to skip this patch, run "git am --skip" instead.
To restore the original branch and stop patching, run "git am --abort".
ただし今回の場合には、解決し、回復すべきファイル内容の競合は存在しない。仮にgit am --skip
を用いても、Yに続くパッチは全て失敗する。もし、本当に絶望的な状況になった時にはgit am --abort
を行うことで結果をクリーンアップして元のブランチに復帰することができる。
コミットXが、コミットBから発生した新しいブランチに対して適用されていることを思い出す。これは、パッチXをそのコミット状態に対して再適用するのであれば、正しく適用できることを意味する。
試しに、一度コミットAまで戻って、BとXを適用してみる。
git reset --hard master~3
git reset --hard master~3
HEAD is now at da99743 A
rm -rf .git/rebase-apply/
git am ~/patches/0001-B.patch
Applying: B
git am ~/patches/0004-X.patch
Applying: X
コミットBに対して、コミットXはうまく適用できている。つまり、このBという基底ファイルをgit側に教えることで、問題が解決できる。
しかし、差分の適用先となる基底ファイルを特定することは難しいと感じるかもしれない。Gitはこれを技術的に容易に解決することができる。Gitによって生成されたパッチや差分のファイルをよく観察すると、伝統的なUnixのdiffのサマリーにはない、新しい追加情報があることに気づく。
cat ~/patches/0004-X.patch
From 37f38c6b2ccd51394b2d3ab84d2688ff93657718 Mon Sep 17 00:00:00 2001
From: git taro <hello@example.com>
Date: Mon, 9 Dec 2019 19:21:53 +0900
Subject: [PATCH 4/7] X
---
file | 1 +
1 file changed, 1 insertion(+)
diff --git a/file b/file
index 35d242b..7f9826a 100644
--- a/file
+++ b/file
@@ -1,2 +1,3 @@
A
B
+X
--
2.21.0
diff --git a/file b/fileの行の直後に、新しい行 index 35d242b..7f9826a 100644 を追加していることがわかる。この情報は、「このパッチを適用できる元の状態はどれですか」という質問に確信を持って答えられるように意図されたもの。
index行の最初の番号35d242bは、このパッチの適用先となるオブジェクト格納領域内におけるブロブのSHA1ハッシュ値である。つまり、35d242bは2つの行のみで構成された時点でのファイルを示している。
git show 35d242b
A
B
パッチXの適用先となるバージョンのファイルがわかる。リポジトリにこのバージョンのファイルがある場合、Gitはパッチを適用することができる。
この機構、つまりファイルの現在のバージョン、代替となるバージョン、そしてパッチの適用先となる基底のバージョンを用意する機構は、3wayマージと呼ばれている。Gitではgit am
に-3または--3wayオプションを渡すと、このシナリオを再構築することができる。
失敗した作業内容をクリーンアップする。最初のコミット状態であるAまでリセットする。
rm -rf .git/rebase-apply
git reset --hard da99743
HEAD is now at da99743 A
git am --3way ../patches/*
Applying: B
Applying: C
Applying: D
Applying: X
Using index info to reconstruct a base tree...
M file
Falling back to patching base and 3-way merge...
Auto-merging file
CONFLICT (content): Merge conflict in file
error: Failed to merge in the changes.
Patch failed at 0004 X
hint: Use 'git am --show-current-patch' to see the failed patch
When you have resolved this problem, run "git am --continue".
If you prefer to skip this patch, run "git am --skip" instead.
To restore the original branch and stop patching, run "git am --abort".
以前と全く同じように、ファイルへの単純なパッチへの適用は失敗したが、Gitはそこで処理を終了する代わりに、3wayマージへと移行している。今回、Gitはマージが実行可能であることを認識できているが、重複した行が2つの異なる方法で変更されているため、競合は残ったままである。
Gitはこの競合を正確に解決することはできないため、git am --3way
は一旦停止される。この時、コマンドに復帰する前に競合を解決する必要はある。
git status
On branch master
You are in the middle of an am session.
(fix conflicts and then run "git am --continue")
(use "git am --skip" to skip this patch)
(use "git am --abort" to restore the original branch)
Unmerged paths:
(use "git reset HEAD <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: file
no changes added to commit (use "git add" and/or "git commit -a")
cat file
A
B
<<<<<<< HEAD
C
D
=======
X
>>>>>>> X
ここで、fileには競合のマージ用マーカが含まれているので、エディタを使ってこれを解決しておく。
cat file
A
B
C
D
X
競合を解決してクリーンアップが終わった後、git am --3way
の実行に戻る。
git am --3way --continue
Applying: X
Applying: Y
Applying: Z
Using index info to reconstruct a base tree...
M file
Falling back to patching base and 3-way merge...
Auto-merging file
Applying: F
これで成功となる。
cat file
A
B
C
D
X
Y
Z
F
フック
Gitのフックを使うと、コミットやパッチのような特定のイベントがレポジトリで発生するたびに、1つ以上の任意のスクリプトを実行できるようになる。
フックは特定のリポジトリに所属し、そのリポジトリのみに作用する。またgit clone
によってコミットされることはない。よってプライベートレポジトリで構築したフックが伝搬することはなく、新しいクローンの挙動が変更されることはない。
フックは、現在のローカルリポジトリ上で動くこともあれば、リモートリポジトリ上で動くこともある。例えば、リモートリポジトリに変更をプッシュすると、リモートリポジトリのフックが実行されることになる。
Gitのフックの多くは、次の2つのに分類される。
- 事前フックは、動作が完了する前に実行される。この種類のフックは、変更が適用される前の段階で、それを承認したり拒否したり、または調整したりするために使用する。
- 事後フックは、動作が完了した後に実行される。これは、通知の引き金にしたり、ビルドの実行やバグのクローズのような追加処理を起動するために使用する。
原則として、事前フックが0以外のステータス(普通失敗を表す)で、終了した場合、Gitの動作は中断される。事後フックの終了ステータスは、動作の結果や完了状態にこれ以上影響を与えることがないため、通常は無視される。
ただし、Gitの開発者は、フックの慎重な利用を推奨している。彼らによればフックは最終手段であり、何らかの別の方法で同じ結果を達成できない場合にのみ使用すべきものである。例えばコミットやファイルのチェックアウト、ブランチの生成などを実行するたびに特定のオプションを指定したい場合は、フックは不要である。これと同じことがGitのエイリアスや、シェルスクリプトでgit commit
などを拡張することで対応できるからである。
一見したところ、フックは魅力的でわかりやすい方法に思える。しかし、フックの利用には様々な影響が伴う。
- フックはGitの挙動を変更する。フックに常識外れの操作を実行させていると、他の開発者があなたのリポジトリを使用した時に驚いてしまう。
- フックは、本来高速に実行できる操作を遅くする可能性がある。例えば開発者は誰かがコミットするたびに単体テストを実行するようなフックを導入したい誘惑に駆られるが、これはコミットの速度を遅くする。
- フックスクリプトがバグを含んでいると、作業と生産性が損なわれる可能性がある。
- リポジトリにあるフック群は、自動的には複製されない。したがって、リポジトリにコミットフックをインストールした場合、それを他の開発者のコミットにも作用させることはあてにできない。
フックはスクリプトになっており、リポジトリの.git/hooksディレクトリにある。各フックスクリプトは、それと関連付けられたイベントに由来する名前を持つ。例えばgit commit
操作の直前に実行されるフックは、.git/hooks/pre-commitという名前になる。
フックスクリプトは、Unixスクリプトの通常の規則に従う必要がある。スクリプトは実行可能でなければならず(chmod a+x .git/hooks/pre-commit
など)、スクリプトが書かれた言語を示す行で始まる必要がある(#!/bin/bash
や#!/usr/bin/perl
など)。
フックの用例
新しいバージョンのGitを使用している場合(筆者環境ではver2.21)は、リポジトリの作成時点で既にいくつかのフックが作られている。新しいリポジトリを作成する時、Gitは雛形のディレクトリからフックが自動的にコピーされる。筆者のMacでは/usr/local/git/share/git-core/templates/hooksの中からコピーされる(Ubuntuなどでは/usr/share/git-core/templates/hooksなど?)。
フックの用例については、以下の点を知っておくべき。
- 雛形のフックは、そのまま用いることはほとんどない。スクリプトを読んだり編集したりして学習することはできる。
- フックはデフォルトで作成されるが、最初は全て無効になっている。Macでは、ファイル名に.sampleが追加されており、実行可能状態になっている
- フックの用例を有効化するには、そのファイル名から接尾辞の.sampleを削除して(
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
など)、実行可能状態にする(chmod a+x .git/hooks/pre-commit
など)。
フック作成の具体例
mkdir hooktest
cd hooktest
git init
Initialized empty Git repository in ~/hooktest/.git/
touch a b c
git add a b c
git commit -m 'added a, b, and c'
[master (root-commit) 404383b] added a, b, and c
3 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 a
create mode 100644 b
create mode 100644 c
cat .git/hooks/pre-commit
#!/bin/bash
echo "Hello, I'm a pre-commit script!" >&2
if git diff --cached | grep '^\+' | grep -q 'broken'; then
echo "ERROR: Can't commit the word 'broken'" >&2
exit 1 # reject
fi
このスクリプトは、チェックインされる差分を全て一覧表示し、追加される行(+で始まる行、grep '^\+'
の部分)を抽出し、それらの行に単語「broken」がないかを調べる。
単語「broken」を調べる方法はいくつもあるが、自明に思える方法でも問題が発生することがある。ここでは、どうやって「単語『broken』を調べる」かではなく、単語「broken」を調べる対象となるテキストをどうやって見つけるかを問題にしている。
例えば次の方法を試すことができる。
if git ls-files | xargs grep -q 'broken' ; then
これは、リポジトリ内の全てのファイルで単語「broken」を検索することになる。しかし、このやり方では2つの問題がある。もし他の誰かが既に単語「broken」を含むファイルをコミットしていた場合、このスクリプトは将来のコミットを全て妨げてしまう。また、Unixのgrepコマンドには、どのファイルが実際にコミットされようとしているのかを知る方法がない。もし、単語「broken」をファイルbに追加し、それとは無関係な変更をaに行った後でgit commit a
を実行した場合、bをコミットしようとしているわけではないのに、このコミットも拒否してしまう。
チェックインの許可対象を制限するpre-commitスクリプトを書く場合、ほぼ確実に、いずれはそれを迂回する必要が出てくる。その時は、git commit --no-verify
オプションを使用するという手がある。
chmod a+x .git/hooks/pre-commit
echo "prefectly fine" > a
echo "broken" > b
git commit -m "test commit -a" -a
Hello, I'm a pre-commit script!
ERROR: Can't commit the word 'broken'
git commit -m "test only file a" a
Hello, I'm a pre-commit script!
[master 7b6769f] test only file a
1 file changed, 1 insertion(+)
git commit -m "test only file b" b
Hello, I'm a pre-commit script!
ERROR: Can't commit the word 'broken'
コミットが正しく動作する場合でも、pre-commitスクリプトは依然としてHello, I'm a pre-commit script!を表示する。これは現実のスクリプトでは煩わしいので、このようなメッセージは、スクリプトをデバッグしている時だけ使用するべきである。また、コミットが拒否された時、git commit
がエラーメッセージを表示しないことにも注意すべき。例のように、事前スクリプトが0以外の終了コードを返す時には、必ずエラーメッセージを出力するようにすべき。
利用可能なフック
Gitが進化するに伴い、新しい種類のフックが利用可能になってきている。もし自分のバージョンのGitで、どんなフックが利用できるかを調べたい場合には、git help hooks
を実行すれば良い。
・コミットに関連したフック
- pre-commitフックは、コミットされようとしている内容に何か問題がある場合、コミットを直ちに中断することができる。pre-commitフックは、ユーザーがコミットメッセージの編集を求められるよりも前に実行されるので、ユーザーがせっかくコミットメッセージを入力したのに変更が拒否されるといったことが起きない。このフックを使って、コミットの内容を自動的に修正することもできる。これは--no-verifyの指定時は実行されない。
- prepare-commit-msgを使うと、Gitのデフォルトメッセージをユーザーに表示する前に、修正することができる。例えばこれを利用して、デフォルトのコミットメッセージのテンプレートを変更できる。
- commit-msgフックを使うと、コミットメッセージをユーザーが編集した後に検証したり、修正したりすることができる。例えば、このフックを利用して、スペルミスをチェックしたり、一定の最大行数を超えたメッセージを拒否したりできる。
- post-commitは、コミット操作が完了した後に実行される。この時点で、ログファイルを更新したり、電子メールを送信したり、自動的なビルド処理を起動したりすることができる。
・パッチに関連したフック
- applypatch-msgは、パッチに添付されたコミットメッセージを調べ、それが受理可能かどうかを決定する。例えば、メッセージにSigned-off-by:ヘッダがなかった時にパッチを拒否するような選択をするなどができる。また、この時点で必要であればコミットメッセージを修正することもできる。
- pre-applypatchフックは、パッチを適用した後、かつ結果をコミットする前に実行されるもの。
- post-applypatchは、post-commitスクリプトと同じように、パッチを適用しコミットされた後に実行されるもの。
・プッシュに関連したフック
- pre-recieveフックは、更新される予定の全ての参照の一覧と、それらの新旧のオブジェクトへのポインタを受け取る。pre-recieveフックは、変更を全て一括して受け入れるか、拒否するか、しかできないので、その有効性は限定的である。
- updateフックは、各参照が更新されるたびに、一回ずつ呼ばれる。updateフックでは、他のブランチが更新されるかどうかに影響を与えることなく、個別のブランチへの更新を受け入れるか拒否するかを選択することができる。
- post-receiveフックは、pre-receiveフックと同様に、更新されたばかりの全ての参照の一覧を受け取る。post-receiveでできることは全てupdateフックでも可能だが、場合によってはpost-receiveの方が便利になることもある。例えば、更新通知の電子メールメッセージを送信したい場合に、post-receiveを使えば、更新ごとに別々の電子メールを送る代わりに全ての更新について記した1件の通知だけを送信することができる。
・その他のローカルリポジトリのフック
- pre-rebaseフックは、ブランチをリベースしようとした時に実行される。これは、既に公開されているためにリベースすべきでないブランチ上で誤ってgit rebaseを実行することを防ぐ時に便利である。
- post-checkoutは、ブランチや個別のファイルをチェックアウトした後に実行される。例えばこのフックを使って、空のディレクトリを自動的に作成したり、チェックアウトされたファイルのパーミッションやアクセス制御リストを設定したりすることができる。
- post-mergeは、マージ操作を行った後に実行される。
最後に
まとめはこれで終了です。最新のgitの挙動についてはRelease Noteを見てください。時間があれば追記します。