鯰の住処

Namazuが真面目な話を書きます

git再入門

git再入門

この記事は,

  • gitを用いた共同開発をしたことがある
  • 操作ミスの直し方が分からなくて詰む
  • コマンドが色々あるらしいけど覚えきれない

くらいの方に向けたgitの解説記事です.私自身のためのメモでもあります. gitの基本的な構造に触れつつ,各コマンドが実際何をしているかを解説するのが主な目的です. また,コマンドを知らなくてもggる時の適切なキーワード選びが出来るようになれば幸いです. 第一章・第二章でgitの基本的構造,第三章でコマンド解説をします.

第一章 git repositoryの構造

git repositoryとは,所謂「ファイルやディレクトリの歴史」のことです. SourceTreeなどで目にするコミットグラフはgit repositoryを可視化したものです. 第一章では,git repositoryの構造とともに,関連する用語として

  • commit
  • branch
  • tag

を解説します.

commitはsnapshot

端的に言うとcommitは管理下にある1 ファイルやディレクトリのsnapshotです. よくある勘違いとして「commitは前のcommitからの差分」という説がありますが,違います. snapshotとは「その時の状態の複製」です.すなわち,ファイルが変更されてcommitされる度にそのコピーが作成されアーカイブされています.2

さて,このsnapshotは撮るだけでは意味がなく,参照できなくてはいけません. そのための情報をsnapshotと一緒にまとめたものがcommitです. 各commitは以下の情報を持ちます.

  • snapshot
  • commit hash (id)
  • parent commit
  • commit message

commit hash (id)は各commit固有の文字列です.40文字程度の英数字から成りますが,ほとんどの場合先頭7文字で識別可能です. parent commitはそのcommitの派生元となるcommitのことです.大抵parent commitは一つですが,時々0個や2個以上の場合もあります.3 commit messageはcommit時に付与された,そのcommitの説明です.commit messageは後で見る人(∋自分)のためにきちんと書きましょう.

一旦commitが作られた後は,これらの情報が変更されることは絶対にありません. commit message一つ変更するにしても,必ず新しいcommitが作成されます.

branch,tagはcommitを指すポインタ

branchやtagはどちらも「あるcommitへの参照」に他なりません. より正確に言えばcommit hashのエイリアスです. 情報の勉強をした人ならばポインタと言えばピンと来るかもしれません. 特にブランチについては「元となるブランチから分岐した時点からの歴史」と勘違いされることも多いですが,これも違います.

コミットグラフを作成するときを考えてみましょう.各commitはparent commitを知っていますので,どのcommitもやがてはroot commitに辿り着くことが出来ます.しかし,自身をparent commitとするcommitは知りませんので,各派生先の最先端のcommitを別途参照出来る必要があります.その役割を果たすのがbranchです. branchの参照先は,現在の参照先のcommitから派生するcommitが作成される度に更新されてゆきます. しかし,特定のcommitへの参照を残しておきたい場合もあります.その場合はtagを用いることで,そのcommitへの参照を普遍的に持ち続けることが出来ます. branch,tagはまとめてreferenceと呼ばれます.

第二章 commitが出来るまで

第一章では「commitが出来てから」の話をしましたが,第二章では「commitが出来るまで」の話をします. ここで解説する用語は以下の三つです.

  • worktree
  • index
  • repository

ファイルに変更を行ってからそれをcommitに含めるまでの流れを解説します.

worktreeとは,作業中のディレクトリの状態

第一章でcommitはsnapshotを持つと解説しましたが,考え方によっては現在作業中のディレクトリ自体も一つのsnapshotであると言えます.これがworktreeです. すなわち,worktreeとは作業中のディレクトリの状態そのもののことです.難しく考える必要はありません. ファイルを編集して保存すればその内容は即座にworktreeに反映されます.

indexとは,commitとしてアーカイブされる前のsnapshot

現在編集中のworktreeの状態と,アーカイブとして残したいsnapshotの状態は必ずしも一致しません.そこで,「commitとしてアーカイブされる前の,編集可能なsnapshot」としての役割をindexが担います. 例えば,機能を開発している途中にcommit間の差分が大きくなりすぎると感じた場合は現在のworktreeで行った変更を分割したいと考えます.この時,その状態まで手動でworktreeの状態を戻してしまうと二度手間になります.indexの存在はこういった状況を解決してくれます. 結果として,worktreeの一部または全部の状態をindexに反映させてゆき,indexがアーカイブしたいsnapshotになったらcommitとしてアーカイブする,という流れでcommitが作成されます.

補足,HEADについて

コミットグラフで,HEADという名前を目にしたことがあると思います. HEADはreferenceの一つで,「現在のworktreeの元となるcommit」への参照を持ちます. しかし通常HEADはいずれかのbranchと同じcommitを指すこととなっており,その場合HEADが指すものはcommit hashではなくbranchです. そうでない場合はHEADは他のreferenceと同じくcommit hashを指しますが「detached HEAD」と警告されます.

第三章 コマンドの実態

第一章,第二章ではgitの基本的な構造を解説してきました. そこでの内容を踏まえて,改めていくつかのコマンドが何をしているのかを解説したいと思います.

add

worktreeの状態をindexに反映させます. オプション等の指定により,

  • 管理下の全てのファイルをindexに反映
  • 一部のファイルをindexに反映
  • あるファイルに施した変更の一部をindexに反映

などが出来ます.

commit

現在のindexのsnapshotを持つcommitを作成します. なお,gitの文脈において「commit」という用語は

  1. 上記で説明したcommit(snapshotを持つオブジェクト)のこと
  2. commitを作成する操作のこと

と二通りで使われます. 第一章,第二章においては一回だけ2の意味で用いましたが,その他は全て1の意味で用いています.

log

その名の通り,commitの履歴を表示します. アルゴリズム的には,現在のcommitからparent commitを辿っているに過ぎません. なお,SourceTreeのような表示にしたい場合以下のオプションとともに用いるのがおススメです. git log --oneline --graph --decorate --branches --remotes 私はこれをglとしてエイリアスを貼っています.表示結果は例えば以下のようになります.

$ git log --oneline --graph --decorate --branches --remotes
*   83c7494 (HEAD -> master) Merge branch 'develop'
|\  
| * bf290e8 (develop) poyo
* | 00c5833 just copied
|/  
* 670f4a6 yoyoyo
* 784fd6a increment
* 597fcf9 create po
* f020336 initial commit

Windowsだと「\」が「¥」になるので辛いです.

checkout

checkoutは,「worktreeの一部または全部をあるcommitの状態に変更する」コマンドです. git checkout ${brahch}という用法で最もよく目にすると思います. よく「branchを切り替える」と説明されますが,今までのことを踏まえると,この場合checkoutは「worktree全体をあるbranchの指すcommitのsnapshotに変更し,HEADをそのcommitまで変更する」という操作を行っています. また,git checkout ${commit hash} ${file}という用法もあり,これは指定したファイルを指定したcommitの状態に戻します.こちらは若干マイナーですが,むしろ定義に近い操作です.

その他,git checkout -b ${new_branch_name}という用法もあります. これは同じcommitを参照するbranchを新しく作成します.

merge

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git merge ${commit hash}とすることで現在のcommitと指定したcommitの両方の状態を統合したcommitを作成します. 正確に言えば,まず現在のcommitと指定したcommitの分岐元(最も新しい共通の祖先)となるcommitを探します.分岐元のcommitと現在のcommitのdiff,分岐元のcommitと指定したcommitのdiffをそれぞれ取り,共存可能であればそれらを取り込みます. 一方,現在のcommitと指定したcommitで,あるファイルについて異なるdiffが出てしまうと競合が起こり得ます.これは手動で解決するかgit checkout --oursなどのコマンドを用いて解決するほかありません.

なお,余談ではありますがmergeによって作成されたcommit (merge commit)は親commitを二つ持ちます.

cherry-pick

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git cherry-pick ${commit hash}とすることで特定のcommitの内容を現在のbranch上に適用できます.mergeとの違いを少々強引に説明すると,歴史を考慮するのがmerge,考慮しないのがcherry-pickです. cherry-pickは特定のcommitと同じsnapshotを持つcommitを現在のbranchの先頭に続けて作成しようとしますが,mergeでは上記の通り共通の祖先からのdiffを考慮した統合をしようとします.ですから,例えばあるbranchにおいてgit merge HEAD^とした場合はalready up to date.と言われ何も変更されませんが,git cherry-pick HEAD^とした場合はconflictします.また,mergeの場合は現在のcommitと指定したcommitの両方を親に持つのに対しcherry-pickの場合は現在のcommitしか親に持ちません.

rebase

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git rebase ${commit hash}とすると,現在のcommitと指定したcommitの分岐元を指定したcommitに変更します. しかし,実はrebaseはcherry-pickの連続適用に過ぎません.

reset

git reset ${commit hash}とすることで,現在のbranchの参照先を指定commitに変更します. よく「git reset --hardをするとcommitが消える」といった誤解がありますが,commitは消えません.実は,ORIG_HEADというところに前にHEADが指していたcommitのhashが保存されています.ですから,git reset --hard ${any_commit_hash}をした後にgit reset --hard ORIG_HEADをすると元通りになります.

reflog

git reflogは,HEADの移動ログです.

$ git reflog
00c5833 HEAD@{0}: reset: moving to ORIG_HEAD
597fcf9 HEAD@{1}: reset: moving to 597fcf9
00c5833 HEAD@{2}: rebase finished: returning to refs/heads/master
00c5833 HEAD@{3}: rebase: poyopoyowa-i
670f4a6 HEAD@{4}: rebase: checkout 670f4a6

${commit hash} HEAD@${number} ${action detail} というフォーマットで表示されていきます. ですから,例えば「git reset --hardした後に一つcommitをしてしまったけどやはりresetを取り消したい」という場合,ORIG_HEADはもはや使えませんがreflogから希望のcommit hashをたどることが出来ます.

発展編

この章の内容はgitを利用する上でほとんど必要ありませんので,こたつでみかんでも剥きながら流し読みしていただければと思います.

git object

実は,gitのcommitやsnapshotは全てgit objectと呼ばれるものから成っています.例えば,あるcommitのhashを用いてgit catfile -p ${commit hash}と打ってみてください. すると,以下のようにtreeやparentという表示とともにhash値が表示されると思います.

$ git cat-file -p 83c7494dcbf4c01cae5977ef47b08921fc7077bd
tree e8e20230a07642ef8e1d1905c4b4133f6bfce750
parent 670f4a6f2101192e5f39fe7c94a8504e374d1a55
author Namazu <${email address}> 1577514290 +0900
committer Namazu <${email address}> 1577516327 +0900

poyopoyowa-i

treeはそのgit repositoryのrootディレクトリを示すgit objectで,parentはそのcommitの親commitのhashです. treeのhash値についてgit cat-file -p ${tree hash}とするとそのディレクトリに含まれているディレクトリ(tree)やファイル(blob)が表示されます.これらにも同じようにhash値が振られています.

$ git cat-file -p e8e20230a07642ef8e1d1905c4b4133f6bfce750
100644 blob 31a6b51d9cc6fda577d58b91ba50dc8626c865e4    po.txt
100644 blob 2df961d8030ca4360795e9608c756bdbcbc190b5    yo.txt

.gitディレクト

.gitディレクトリの中にはそのrepositoryを管理するために必要な情報が全て含まれています. ls .gitとやってみると,

COMMIT_EDITMSG
HEAD
ORIG_HEAD
config
description
hooks
index
info
logs
objects
refs

といった内容が表示されます. HEADの中身やrefs/headsの中身,objectsなどを通常のcatコマンドなどで覗いてみると面白いかもしれません.

鉞の飛ばし先

鉞歓迎いたします.正確な情報を発信したいので,誤りを見つけた場合ご指摘いただけますと幸いです. こちらの記事へのコメントまたはTwitterにて@blonde_namazu宛にお願いいたします.

参考文献

基本的にGit Documentationに従いつつ,他のサイトも理解の助けとして参考にさせていただきました.


  1. 実は,「管理下にある」の定義は少々複雑です.「.gitと同じ階層かその下の階層にある」かつ「.gitignore」されていないかつ「一度でもcommitされたことがある」と言えるかと思います.

  2. しかしcommitする度に管理下の全てのファイルをコピーしていては容量が大きくなりすぎてしまいます.

  3. root commit,すなわち最初のcommitには親が存在しません.また,merge commitには2個以上のparent commitが存在します.merge commitの親が3個以上になる場合に興味を持ったあなたは「merge strategy」で検索検索ぅ↑↑🐙