読者です 読者をやめる 読者になる 読者になる

git pullの詳細な挙動を追ってみる

git push/pullは何気なく使ってるけど実はよくわかってなかった。ことのきっかけはこういう質問。

  • hogeというリモートブランチをローカルのhogeブランチにもってきたい
  • hogeをローカルのmasterにはマージしたくない
  • pullでなんかこんな感じでいけそう?
$ git pull origin hoge:hoge

でもこれは間違えで、なぜか今いるブランチ(master)にhogeがmergeされるし、期待してる動作じゃない。正解はこう。

$ git branch hoge origin/hoge

もしくはチェックアウトも同時にするなら

$ git checkout -b hoge origin/hoge

こう。自分は普段後者のやり方でやってたけど、なんで上のはダメで下のが正解なのか説明できなかったのでちゃんと調べてみた。

入門Git実用Git、あとhelpを参考にした。

ブランチ名の解決

まず、git pullを知る前に、いくつか知っとかないといけないことがある。まずはブランチ名の解決から。

通常ローカルブランチはこんな感じでつくる。

$ git branch hoge

そうするとhogeというローカルブランチができて

$ git show hoge

とかするとhogeのブランチを参照できる。でもこのshowで使ってるhogeという名前は実は省略形式で、正式な名称は refs/heads/hoge になる。
なのでこの二つのコマンドを実行してみると同じ結果になるはず。

$ git show master
$ git show refs/heads/master

基本的にブランチとタグの正式な形式は次のようになる。

  • refs/heads/ # ローカルブランチ
  • refs/remotes// # 追跡ブランチ
  • refs/tags/ # タグ

にはブランチつくるときにつけたブランチ名(hogeとか)が入る。追跡ブランチは次説明するけど、ってのにはリモートリポジトリ名が入る。

  1. refs/
  2. refs/tags/
  3. refs/heads/
  4. refs/remotes/
  5. refs/remotes//HEAD

の順番でそのブランチ(もしくはタグ)があるか探す。これは

$ git help rev-parse

を見ると確認できる。なので単にmasterと指定したら

  1. refs/master
  2. refs/tags/master
  3. refs/heads/master

の順番で探して、refs/heads/masterがマッチするので無事期待通りの挙動になる。ので仮にtagにmasterという名前をつけると大変なことになる。(一応警告はでるみたい)

ちなみにこの情報は .git/refs/* においてある。

traking branch

追跡ブランチともいう。これは何かというと、リモートリポジトリのブランチを追跡するためだけのブランチ。

基本的にこのブランチにコミットとかマージはしちゃダメ。pushとかpull(正確にはpullじゃなくてfetchのとき)したときに勝手に最新の情報に更新されるようになってるという特殊なブランチ。

特に何も設定してなくても、どっかからcloneしたリポジトリのブランチを見ると

$ git branch -a
* master
  remotes/origin/master

とかって出るんじゃなかろうかと思う。masterってのが普通のローカルブランチ、remotes/origin/masterってのが追跡ブランチ。わかりにくいけど、追跡ブランチもローカルのブランチになる。remotes/origin/master ってのはつまり、「originっていうリモートリポジトリのmasterブランチを追跡しているローカルブランチ」ってこと。正式名称は refs/remotes/origin/master。git branchコマンドでは refs/ が省略された形式で出力されてるだけ。

単にpush、pullするだけの簡単なお仕事ならこの追跡ブランチを意識することはほとんどないけど、今回明らかにしたい挙動ではこれが重要になる。言葉を整理するために、今後は次のように使う。

  • トピックブランチ (ローカルにある通常のブランチ)
  • 追跡ブランチ(ローカルにある追跡ブランチ)
  • リモートブランチ(リモートリポジトリにあるブランチ)

追跡ブランチというのはリモートブランチの状態を同期するだけのブランチなので、コミットもマージもすべきではない。その証拠?に、追跡ブランチをチェックアウトしようとすると、一応チェックアウトはできるものの、detached HEADという「切り離されたHEAD」というものになってしまう。

$ git checkout origin/master 
Note: checking out 'origin/master'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at 403264d... Add

detached HEADについてはこのあたりを参照のこと。
gitのHEADがブランチから外れてしまう現象とその直し方 - 西尾泰和のはてなダイアリー

git fetch

git fetchで追跡ブランチにリモートブランチのデータを反映させて、追跡ブランチをトピックブランチにmergeするってのがgit pullの流れになる。ので次はgit fetchについて説明する。

git fetchのhelpを見るとgit fetchは次のようなコマンドライン引数を受け取ることができることがわかる。

git fetch [<options>] [<repository> [<refspec>...]]

はリモートリポジトリの名前なんで通常はoriginでOK。で、このrefspecってのが重要な上にけっこうむずい。refspecってのは簡単に言うと、リモートリポジトリとローカルリポジトリのブランチを紐付けるためのもの。こういう形式になる。

[+]<source>:<destinations>

実際に書くとこんな感じ。

+refs/heads/hoge:refs/remotes/origin/hoge

先頭の+は省略可能であってもなくてもいい(+をつけた場合はfast-forwardのチェックをしない。詳しくは入門Gitに書いてある)。は転送元リポジトリのブランチ、が転送先リポジトリのブランチの参照を指定する。転送先とか転送元とかややめんどくさいいい回しなのは、pushとfetchのときで指すものが変わるから。fetchのときは転送元()がリモート、転送先()がローカルになる。pushのときは逆。

この参照の指定はワイルドカード(*)が指定できる。なのでgit fetchはこのように書ける。

$ git fetch origin '+refs/heads/*:/remotes/origin/*'

また、この「+refs/heads/*:/remotes/origin/*」ってのは通常、fetchのrefspecのデフォルト値になってる。設定ファイルを見ると

$ cat .git/config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
        ignorecase = true
[remote "origin"]
        fetch = +refs/heads/*:refs/remotes/origin/*
        url = /Users/hokamura/tmp/git/repo
[branch "master"]
        remote = origin
        merge = refs/heads/master

この「fetch = +refs/heads/*:refs/remotes/origin/*」っていうのが、originをfetchするときのrefspecのデフォルト値になる。なので

$ git fetch

ってやると

$ git fetch origin '+refs/heads/*:refs/remotes/origin/*'

ってやったのと同じことになる(は省略したらoriginになる)。これはリモートブランチの全部を追跡ブランチにもってこいって指定になる。ここで注意点が2つ。まずの refs/heads/* はローカルリポジトリの refs/heads/* じゃなくてリモートリポジトリのブランチの指定になってるということ(リモートもローカルと同じようなrefsの構成になっているので表記が同じになる)。もう一つはは追跡ブランチを指すということ。fetchしたときにリモートのブランチのデータがローカルの追跡ブランチに反映されるのはこういう仕組み。

例えば、リモートに新しくhogeというブランチができててfetchしたら次のようになる。

$ git fetch
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
From /Users/hokamura/tmp/git/repo
 * [new branch]      hoge       -> origin/hoge

hoge -> origin/hoge 」ってのがリモートのhogeっていうブランチをローカルのorigin/hogeっていう追跡ブランチに落としてきたよって意味。なので

$ git branch -a
* master
  remotes/origin/hoge
  remotes/origin/master

と、追跡ブランチが増えていることがわかる。

ここまでで、最初のコマンド

$ git branch hoge origin/hoge

が正しいというのがわかる。これは origin/hoge という追跡ブランチを元にして hoge というトピックブランチをつくるということ。これが最初の例で望んでいた挙動。ただし、最初の例ではこれが正しいと言ったけど、fetchやpull、cloneなどでhogeリモートブランチの追跡ブランチ(origin/hoge)がローカルに作成されているという前提条件が抜けていたのに注意。

git pull

fetchだけでは追跡ブランチを同期させるだけなので、トピックブランチには反映されない点に注意が必要。トピックブランチにも反映させるには追跡ブランチをトピックブランチにmergeなりrebaseなりをして反映させる必要がある。

git pull は git fetch の後に git merge も続けてやってくれるコマンド。git pull も git fetch と同じように、

git pull [options] [<repository> [<refspec>...]]

という引数を取る。また、fetchと同じく、refspecに何も指定しなければ次の設定を使ってくれる。

[remote "origin"]
        fetch = +refs/heads/*:refs/remotes/origin/*

つまり

$ git pull

ってやると、

$ git fetch origin '+refs/heads/*:refs/remotes/origin/*'

という処理がまず走る。つぎにマージ。

$ git pull
$ git pull origin '+refs/heads/*:refs/remotes/origin/*'

fetchのときはこれは同じ意味だったけど、pullのときはmergeの段階で上の二つは別の挙動になる。refspecを明示的に指定した場合は、refspecの左側(リモートブランチ)を全て今チェックアウトしてるブランチ(カレントブランチ)にマージする。

$ git pull origin '+refs/heads/hoge:refs/remotes/origin/hoge'

とかするとリモートのhogeブランチを追跡ブランチにfetchした後、カレントブランチにマージするということになる。

refspec を指定しない場合は何がマージされるのかは、設定ファイルに書いてある。

[branch "master"]
        remote = origin
        merge = refs/heads/master

というのが設定ファイルに書かれてるはず。これは refspec を指定しないで git pull したときに、カレントブランチにリモートブランチのmasterをマージするという意味。(refs/heads/master はリモートブランチを指している)。つまり、masterがカレントブランチのときに

$ git pull

ってやると、

$ git fetch origin '+refs/heads/*:refs/remotes/origin/*'
$ git merge origin/master

ってやったのと同じことになるということ。やっと git pull の流れが見えてきた。

refspecの省略表記

refspecの指定で補足。refspecは

[+]<source>:<destinations>

って書いたけど、:を省略できる。そのときの挙動は help に書いてある。

A parameter without a colon is equivalent to : when pulling/fetching, so it merges into the current branch without storing the remote branch anywhere locally

つまりコロンなしで指定すると : って指定したのと同じ意味になり、 をカレントブランチにマージしてリモートブランチをローカルに保存しないってことらしい(追跡ブランチもつくらない)。

これがどういうときにいいかというというのは入門Gitに書いてあって、Pull Requestをマージするときに役に立つ。たとえばPull Requestを受け取った時、その人のリモートリポジトリをfetchして追跡ブランチ追加してそこからマージしてもいいんだけど、Pull Request大量に受け取る人とかはゴミブランチがいっぱい増えて大変になる(消せばいいんだけど)。そんなとき、

$ git pull <repository> <branch>

ってやるだけでカレントブランチにPull Requestのブランチがマージされて、しかもローカルにそのブランチの情報な残らないので嬉しいということ。

ちなみにpushのときのrefspceをって書くと:ではなく
:として扱われるらしく、コマンドによってそのあたりの挙動が変わるので難しい。

つまり何が言いたいかというとこの二つは等価でないということ。

$ git pull origin hoge
$ git pull origin hoge:hoge

pushのときはこの二つは等価。

$ git push origin hoge
$ git push origin hoge:hoge
まとめ

最初のやつは何がダメだったか。

# 今のブランチどんな感じか
$ git branch -a
* master
  remotes/origin/master

# pull実行
$ git pull origin hoge:hoge
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
From /Users/hokamura/tmp/git/repo
 * [new branch]      hoge       -> hoge
Merge made by recursive.
 0 files changed, 0 insertions(+), 0 deletions(-)

これで何が起きてるか。

まず refspec が hoge:hoge になってることに注目。コロンの左がリモートブランチで右がローカルの追跡ブランチを指定するんだった。まず左は hoge なんで解決する順番にそってリモートブランチは(おそらく) refs/heads/hoge を見つける。これは特に問題ない。次に右もhogeだけど

 * [new branch]      hoge       -> hoge

を見てわかるとおり、ローカルブランチは refs/heads/hoge として解決されたっぽい(ここの挙動がよくわからんのだけどブランチ名を探してもなかったら refs/heads につくるのかな?)。ホントはここで追跡ブランチ、origin/hogeを作ってほしかったところ。実行後のブランチは次のようになってる。

$ git br -a
  hoge
* master
  remotes/origin/master

ここまでがfetchの段階。さらに続いてmergeが始まる。mergeはリモートのhogeブランチがカレントブランチ(master)にマージされるので、期待していない動作になる。のでこのコマンドは間違えだろうと思われる。というところで調査終わり。

gitが push と pull を何も指定しなくても簡単に使えるように裏で色々設定とか勝手にやってくれてるのでいざちょっと違うことをしようとしたり、細かい挙動を把握しようと思った時に深みにはまるというのがわかった。