はじめに
Gitの挙動でわからないところがいくつかあったので、備忘録として調べたことをまとめる。
Git の状態管理
Gitの状態管理は、作業ツリーだけでなく.git配下のデータ構造を見ると理解しやすい。
.gitで重要なファイル、ディレクトリ
-
.git/objects: コミット、ツリー(ディレクトリ構造)、blob(ファイルの内容そのもの、ファイル名は持たない)、注釈つきタグ(メッセージが含まれたタグ)の実体(内容がアドレス化されたオブジェクト) -
.git/index: staging areaの実体 -
.git/HEAD: 現在のHEADの参照1先(通常はrefs/heads/<branch>へのシンボリック参照、ref: refs/heads/mainとかになっている) -
.git/refs/heads/: ローカルブランチ参照 -
.git/refs/remotes/: 追跡参照2(origin/mainなど) -
.git/logs/: reflog(HEADや各ref1の移動履歴) -
.git/config: リモート設定やupstream設定
.git/config
.git/configは「このリポジトリにおけるGit設定」を持つファイル。
例えば次のようなことが書いてある。
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = git@github.com:example/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[pull]
rebase = false
[branch "main"]
remote = origin
merge = refs/heads/main
これらの内容についてみていく。
[core]
リポジトリそのものの基本動作を決める。
-
repositoryformatversion: リポジトリ内部フォーマット3のバージョン -
filemode: 実行権限ビット4の変化を差分として扱うか(OS差分で影響しやすい) -
bare: 作業ツリーなしのbareリポジトリ5かどうか -
logallrefupdates: ref更新時にreflogを記録するか
[remote "origin"]
リモートoriginへの接続先と、fetch時の取り込み先マッピングを定義する。
-
url: push/fetch先URL -
fetch: どのリモートrefをどのローカル追跡参照へ写すか- 例:
+refs/heads/*:refs/remotes/origin/*- これは
originの全てのブランチ参照(refs/heads/*)を同名のローカルのリモート追跡参照(refs/remotes/origin/*)に写す - つまり、
originにある全てのブランチ参照をローカルに(追跡参照として)持ってくる
- これは
-
+は非fast-forward更新も許可して追跡参照を更新する意味
- 例:
[pull]
git pull実行時の統合方針を決める。
-
rebase = false: fetch後にmerge -
rebase = true: fetch後にrebase - ほかに
pull.ff=onlyを設定するとfast-forward不可のpullを拒否できる
[branch "main"]
ローカルmainブランチのupstream(追跡先)を定義する。
-
remote = origin: 追跡先リモート名 -
merge = refs/heads/main: 追跡先ブランチ
この2つがあると、git pullやgit pushで引数を省略したときの既定先が決まる。
この例の場合では、ローカルmainブランチでgit pullした時、リモートのmainをfetchしてそれをmergeする。
つまりgit pullやgit pushの「省略時の振る舞い」は、この[remote]と[branch]の組み合わせで決まる(後述)。
コマンドと.gitの更新対応
-
git add- 主に
.git/indexを更新
- 主に
-
git commit-
.git/objectsにcommit/tree/blobを追加 - 現在ブランチ参照(
.git/refs/heads/...)と.git/HEADの指す先が進む -
.git/logs/HEADと.git/logs/refs/heads/...に履歴が記録される
-
-
git switch/git checkout(ブランチ切替)-
.git/HEADの参照先が切り替わる - indexと作業ツリーが対象コミットに合わせて更新される
-
-
git fetch-
.git/refs/remotes/...(追跡参照)が更新される - 必要なオブジェクトが
.git/objectsに追加される
-
-
git pull-
fetchに加えて、merge/rebaseにより.git/refs/heads/...と作業ツリーが更新される
-
reflog
reflogは、HEADや各refが「いつ・どこから・どこへ動いたか」をローカルに記録した履歴。
通常のgit logがコミット同士の親子関係を見るのに対して、reflogは「自分がこのローカルリポジトリでどんな操作をしたか」を追うためのもの。
実体は.git/logs/配下にあり、例えば以下がある。
-
.git/logs/HEAD: HEADの移動履歴 -
.git/logs/refs/heads/main:mainブランチ先端の移動履歴
以下のような時に増える。
-
commitでブランチ先端が進む -
checkout/switchでHEADが移動する -
reset/rebase/mergeで参照先が変わる
確認するには、以下のようなコマンドを用いる。
-
git reflog: HEADの移動履歴を見る -
git reflog show main:mainのreflogを見る
出力例としては HEAD@{1} のような形で参照でき、
「1回前のHEAD位置」「昨日時点のmain」などを後から辿れる。
例えば以下のようなときに復旧できる可能性がある。
-
reset --hardしすぎた -
rebaseで履歴を壊した -
checkoutしていた先を見失った
といったときでも、reflogを見れば直前にいたコミットIDを見つけて戻れることが多い。
例えば
-
git reset --hard HEAD@{1}: 1つ前のHEAD位置に状態を戻す(作業ツリーやindexもその位置に合わせる) -
git switch -c rescue HEAD@{3}: 3つ前のHEAD位置にrescueブランチを作成して切り替える
のように使う。
注意点として、reflogはローカル情報であり、他人の環境やリモートには無い。
また、一定期間(デフォルトでは通常90日、到達できなくなったものに関しては30日)でexpireされるため、永久には残らない。
.git関連コマンド
-
git rev-parse --git-dir:.gitの場所確認 -
git show-ref: refs一覧(heads/remotes含む) -
git symbolic-ref HEAD: HEADがどのブランチを指しているか確認 -
git reflog:.git/logsベースの移動履歴確認 -
git config: 設定の参照/更新(system, global, local)- 設定一覧:
git config --list - どの設定ファイル由来か確認:
git config --show-origin --list - ローカル設定確認(
.git/config):git config --local --list - 例(pullをrebaseに):
git config --local pull.rebase true - 例(upstream確認):
git config --get branch.main.remote/git config --get branch.main.merge - 設定削除:
git config --unset <key>
- 設定一覧:
git fetch / git pullの挙動
一般には、git pullはgit fetchした後にmergeまたはrebaseを行うコマンドと考えるとよい。
mergeまたはrebaseのどちらになるかはコマンド引数、branch.<name>.rebase、またはpull.rebaseの設定による(優先順位はこの順)。
git fetchは「リモートの最新情報をローカルの追跡ブランチに取り込むだけ」で、現在作業中のブランチの内容は変えない。
git pullはfetchした後に、現在ブランチへ統合するので、作業ツリーや履歴が変わる。
-
git fetch: 既定の取得先リモートから、設定されたfetch refspec6に従って追跡参照を更新する(通常の clone 直後のoriginでは、各ブランチに対応するorigin/*の追跡参照が更新される) -
git fetch origin:originに対して設定されたfetch refspecの対象を更新する(通常はorigin/*の追跡参照) -
git pull: 現在ブランチに設定されたupstreamをfetchし、その内容を現在ブランチへ統合する -
git pull origin main: 現在ブランチにorigin/mainを取り込む
git push origin @ と git push の違い
git push origin @の@はHEADの短縮で、実質的にはgit push origin HEADと同様に扱える。
つまり「push先リモートを明示して、現在のHEADをpushする」書き方である。
一方git pushの省略時の挙動はpush.defaultやupstream設定に依存する。
よくある設定では「現在ブランチをそのupstreamへpush」する動きになるが、設定によっては同名ブランチへのpushなど別の挙動になる。
- 初回push時:
git push -u origin @(-uはupstreamを設定するオプション、--set-upstreamのエイリアス)- または
git push -u origin <branch>
- または
- upstreamが設定されているブランチへのpush:
git push - upstreamが設定されていないブランチへのpush:
git push origin @ - upstreamの設定:
git branch -u origin/<branch>(-uはupstreamを設定するオプション、--set-upstream-toのエイリアス)
git push --force とか
--forceはリモート履歴を書き換えるため危険。
共同ブランチで使うと他人のコミットを消したように見える事故が起こる。
基本は--force-with-leaseを使うのが安全。
-
git push --force: 無条件で上書き -
git push --force-with-lease: 自分が見ていた先端と一致するときだけ上書き- ただし、
git fetchなどで手元の追跡参照が更新されると、その時点の値を期待値として比較する- 自分が内容を十分確認しないまま追跡参照だけ更新していると、保護の効き方を誤解しやすいので注意が必要
- ただし、
どういう時に必要か
- すでに
pushしたコミット履歴をrebaseやcommit --amendで作り直した後
Squash Merge / Merge
PRを取り込む方法として通常のMergeとSquash Mergeがある。
Merge(merge commit)
- 元ブランチのコミット粒度をそのまま残せる
- 分岐と合流の履歴が明示される
- 後で「どのコミットで壊れたか」を追いやすい
Squash Merge
- PR内コミットを新しいコミットとして1つに潰して取り込む
-
mainの履歴が見やすくなりやすい - ただし元ブランチの個別コミット情報は
main側で失われる
履歴解析重視ならMerge、履歴の見た目重視ならSquashが向いている。
別のブランチにマージしたブランチをSquash Mergeした後にマージしようとしたらどうなるか
例えば、今こうなっている。
* 0ac89ae (HEAD -> br-b, origin/br-b) Merge branch 'br-a' into br-b
|\
| * 3401004 (origin/br-a, br-a) second commit from br-a
| * 17c473b first commit from br-a
* | ad4f75f first commit from br-b
|/
* 86de574 (main) my first commit
この時点の状況は次の通り。
-
mainは初期コミット86de574で止まっている -
br-aは17c473b->3401004と2つ進んでいる -
br-bはad4f75fを積んだ後、br-aを通常マージして0ac89aeになっている
つまり、まだmainにはbr-aの変更もbr-bの変更も入っておらず、変更はbr-a/br-b側だけで進んでいる状態になっている。
br-aをmainにSquash Mergeした。
* c57929e (origin/main, origin/HEAD) Br a (#2)
| * 0ac89ae (HEAD -> br-b, origin/br-b) Merge branch 'br-a' into br-b
| |\
| | * 3401004 (origin/br-a, br-a) second commit from br-a
| | * 17c473b first commit from br-a
| |/
|/|
| * ad4f75f first commit from br-b
|/
* 86de574 (main) my first commit
この状態では、mainには c57929e(squashで作られた新コミット)だけが追加され、br-aの元コミット 17c473b と 3401004 は「取り込まれた履歴」にはならない。
そのため、この後にbr-bをmainへ通常Mergeしようとすると、br-bが持つbr-a由来の履歴をGitが未取り込みと判定し、差分が大きく見えたり、再コンフリクトすることがある。
要するに、Squash Mergeは内容については取り込むが、元コミットの系譜はつながらない。
この「内容は同じだが履歴上は別物」という状態が、後続マージをややこしくする。
回避策として、Squash Merge を使うなら、ブランチ間の事前マージを避けることが重要になる。
最終的に main へ Squash Merge する運用ルールなのであれば、作業ブランチ同士を直接マージさせないようにして、それぞれの機能を独立して main に統合していく方が安全。
git checkout/ git switch
checkoutは長年使われてきたが、1つのコマンドに役割を詰め込みすぎていた。
- ブランチ切り替え(
git checkout <branch>) - ファイル復元(
git checkout -- <path>) - 特定コミットへ移動(detached HEAD)
この「同じコマンド名で全く違う操作が走る」設計は、誤操作(ブランチを切り替えたつもりでファイルを上書きする等)を招きやすかった。
そこでGit 2.23(2019)で、用途分離のために git switch(ブランチ操作)とgit restore(ファイル復元)が追加された。
つまり歴史的には、checkoutは互換性維持のため残っている旧来の万能コマンド、 switch / restoreは意図を明確にするための新しいUIという位置づけである。
内部実装の多くは従来のcheckout系と多くを共有しているが、git switchはブランチ切替専用のインターフェースとして導入され、パス指定との曖昧さを減らしている。
現在では次のコマンド群が推奨されている。
- ブランチ切替:
git switch <branch> - 新規ブランチ作成して切替:
git switch -c <new-branch> - ファイル復元系は
git restoreを使う - detached HEADで確認したいとき:
git switch --detach <commit>
git rebase の仕様
rebaseは「ベースを付け替えて、コミットを積み直す」操作。
例: featureをmain先端に載せ替える場合
-
feature固有のコミットを取り出す -
main先端の上に順に再適用する - 結果としてコミットIDは変わる
その他の挙動
- 現在のブランチにある各コミットの再適用時にconflictが出たら、その都度解消して
git rebase --continueを行う必要がある - マージコミットは無視され、一直線に並ぶ(
--rebase-mergesオプションでは、マージコミットの情報を残す)
メリット
- 履歴が一直線になって読みやすい
- 不要なマージコミットを減らせる
注意点
- 共有済みブランチでrebaseすると他メンバーと履歴がズレる
- すでにpushしているブランチに関しては、rebase後のpushは通常
--force-with-leaseが必要
git branch -d/ git branch -D
どちらもローカルブランチ削除だが、安全性が違う。
-
git branch -d <branch>: マージ済みなら削除 (--delete) -
git branch -D <branch>: 未マージでも強制削除 (--delete --force)
ここで「マージ済み」とは、削除対象ブランチがそのupstreamブランチに取り込まれていることを指す。upstreamがない場合は、現在のHEADから到達できるかどうかで判定される。
Squash Mergeだと、マージ済みにはならないので注意が必要。
基本は-dを使い、不要だと確信できる場合のみ-Dを使うと良い。
git reset
resetは「HEADの位置」と「index(staging)」と「作業ツリー」をどう戻すかを選ぶ必要がある。
-
--soft: HEADだけ戻す(変更はstagedのまま) -
--mixed(デフォルト): HEADとindexを戻す(変更は作業ツリーへ) -
--hard: HEAD/index/作業ツリー全部戻す(変更が消える)
使い分け例
- コミットをやり直したい:
git reset --soft HEAD~1 - addを取り消したい:
git reset HEAD <file> - ローカル変更を完全に捨てる:
git reset --hard <commit>(要注意)
特に--hardは復旧困難になりやすいので、実行前にgit statusとgit logで必ず確認しておく。
git submodule
submoduleは「別Gitリポジトリを、このリポジトリ配下に参照として持つ」仕組み。
親リポジトリは、サブモジュールの中身そのものではなく「どのコミットを参照するか」を記録する。
よく使う操作
- 取得時:
git clone --recurse-submodules <repo> - 既存cloneで初期化:
git submodule update --init --recursive - サブモジュール先端へ更新:
git submodule update --remote --recursive
注意:submoduleへのpushになってしまわないようにする
<submodule>ディレクトリ内でgit pushを行うと、submoduleリポジトリにpushが行われてしまう。
この状態で親側だけ見ると、「あれ、pushしたのに反映されてないな」ということになる。
回避するには、push前に必ず現在位置を確認する。
-
pwdで親リポジトリ直下にいるか確認 -
git rev-parse --show-toplevelで対象リポジトリのルートを確認 -
git statusの表示対象パスで、親かsubmoduleかを判別する
など。
依存が多いと運用コストが上がるので、使う前に管理方法を決めておくのが大切である。
記号類(@とか^とか-とか)
Gitのコマンドでよく出る記号は、意味がわかると履歴操作がかなり楽になる。
@
-
@はHEADの短縮(エイリアス) -
git push origin @は、実質的にはgit push origin HEADと同様に扱える
^(親コミット)
-
HEAD^:HEADの第一親(直線コミットでは唯一の親、マージコミットではもともといたブランチの親) -
HEAD^2: マージコミットの2番目の親(取り込んだ側のブランチのコミット)
マージコミット以外の一直線のコミットではHEAD^2は存在せず、エラーになる。
~(第一親をn個たどる)
-
HEAD~1はHEAD^とほぼ同じ -
HEAD~nは第一親をn回たどったコミット
~は「直線的に何個前か」を表すイメージ。
@{}(reflog参照)
-
HEAD@{1}: 1回前のHEAD位置 -
main@{yesterday}: 昨日(24時間前)時点のmainの参照
他に{}内に指定できるもの
n.week.agon.hours.agon.minutes.agoYYYY-MM-DDHH:MM:SS-
-n(以下を参照のこと) - ...
履歴を戻しすぎたときの復旧などで役立つ。
ただし、reflogの期間内までしか遡れないことに注意。
-(直前のブランチ)
-
git switch -で1つ前にチェックアウトしていたブランチに戻る
内部的には-は@{-1}のエイリアスである(使える場面は限られていることに注意)。
@{-n}は「n回前にチェックアウトしていた位置」を指す。
つまり、git switchやgit checkoutで移動した履歴だけをたどって、n回前にチェックアウトしていた先を指す。
よってブランチだけでなく、detached HEADで移動していた位置を指すこともある。
.. と ...(差分/到達範囲)
-
A..B: Aには無くBにはあるコミット -
A...B: (git logなど履歴をたどるコマンドでは、)AとBの対称差(どちらか片方にだけあるコミット)
ただし、git diff A...B は意味が少し違い、merge-base(A, B) と B の差分を見る。
Pull Requestの差分表示に近いのは、こちらの使い方である。
とりあえず、以下の2つのコマンドを覚えておけば良い。
-
git log <branch1>..<branch2>:<branch1>にはなく<branch2>にあるコミット一覧の確認 -
git diff <branch1>...<branch2>:merge-base(<branch1>, <branch2>)から<branch2>までの差分の確認
終わりに
結構大作になった気がする。
勉強になったので良かったと思っている。
-
参照 / ref: Gitにおける参照(もしくはref)とは、オブジェクト(主にコミット、他にタグがBlob/Treeを指すこともある)を指す名前。感覚的には、オブジェクトID(主にコミットID)につけたふせんのようなもの。例として、ローカルブランチ (
refs/heads/mainなど)、リモート追跡参照 (refs/remotes/origin/mainなど)、タグ (refs/tags/v1.0など)、現在位置を示すHEADなどがある。 ↩ ↩2 -
追跡参照: リモートブランチの状態をローカルに記録した参照。
origin/mainなど<remote name>/<branch name>の形のブランチ。作業ブランチ本体ではないため、fetchで更新されてもローカルmainは自動で進まず、pull(またはmerge/rebase)で反映される。 ↩ -
リポジトリ内部フォーマット:
.gitディレクトリの保存形式そのもの(objects/refs/index などの扱い方)。通常はバージョン0で、Gitが互換性を判断するために使う値。手動変更は基本不要。 ↩ -
実行権限ビット: Unix系の実行可能フラグ(
chmod +xなど)。filemode=trueだとこの差分を追跡する。 ↩ -
bareリポジトリ: 作業ツリーを持たず、履歴データのみを保持するリポジトリ。共有用サーバーでよく使う。 ↩
-
fetch refspec:
git fetchで「どのリモートrefを、どのローカル追跡参照へ対応付けて取り込むか」を表す指定。例えば+refs/heads/*:refs/remotes/origin/*は、リモートの各ブランチをローカルのorigin/*に対応付けて更新する意味。詳細はgit-fetchやgit-configのremote.<name>.fetchを参照。 ↩