LoginSignup
8
10

More than 1 year has passed since last update.

Gitを仕組みから再学習

Posted at

Git

gitの実体

gitはコミットした時点のファイルのスナップショットをそのファイルから計算したハッシュ値で紐づけたKVSのようなもので過去のversionを管理している。
$git initした時に作成される.gitがgitの実体であり、その中の.git/objectにハッシュ値で紐づけられた大量のスナップショット(blobオブジェクト)が保管されている。

オブジェクト

.git/objectの中にはファイルのスナップショット(blobオブジェクト)だけが保存されているわけではない。blob、tree、commitの3種類のオブジェクトが存在する。

なお、オブジェクトを確認するには$git cat-fileコマンドを利用する。
.git/object以下はさらにハッシュ値の先頭2桁でフォルダ分けされており、先頭2桁のハッシュはオブジェクトのファイル名からは除かれているのでその値を追加してcat-fileする必要がある。見方は以下。

# ファイルのタイプを見たい。
$ git cat-file -t [中身を見たいハッシュ]
# ファイルのサイズを見たい。
$ git cat-file -s [中身を見たいハッシュ]
# ファイルの中身を見たい。
$ git cat-file -p [中身を見たいハッシュ]

blobオブジェクト

スナップショットにあたる。
ファイルを$git addすると、そのファイルがワークツリーからコピー・zlib圧縮され、blobオブジェクトとなる。また、そのファイルの中身からハッシュ値を計算し、blobオブジェクトはそのハッシュ値で区別されるようになる。
addしたファイル1つ1つに対応するようblobオブジェクトが作成されるため、.git/objectの中の任意のblobオブジェクトを覗くとどこかの時点での特定のファイルの中身を確認することができる。

treeオブジェクト

ディレクトリのスナップショットに当たる。コミット時に生成。
ディレクトリはその直下にどのファイルが存在するかを管理するようにtreeオブジェクトはそのファイルの直下にどのblobやtreeオブジェクトが存在するかの情報(ハッシュ)を持っている。

$ git cat-file -p 90b4c465b199a20546f6fa0fee7cb2d213de1b4f
040000 tree 35f02fd1aa4e582cab3dbbf4591dfd0d7007f97f    hoge
100644 blob c945dbe34b2b9b68b94a06ca1c0b31cb7d9f1bdd    aaa.txt
100644 blob 83c831f0b085c70509b1fbb0a0131a9a32e691ac    bbb.md

左から順に、アクセス権限、オブジェクトの種類、ハッシュ、ディレクトリ名orファイル名となっている。

commitオブジェクト

コミットする毎に1つ作られるオブジェクト。
ルートのtreeオブジェクト(.gitを含む最上位のディレクトリ)のハッシュが記録されている。
また、親となるコミット(ひとつ前のコミット)のハッシュを持つ。
そのほか、authorやcommitter、コミットメッセージが記録される。

$ git cat-file -p a2e77fbbef72a23e6c908a0eb36c219d77e1a93b
tree 123fdb23bd445760cfd90eaf90946648fb012b99
parent 2abb34478c89d08d2c83a46bc715db939b6ea1ce
author *** <***@gmail.com> 1620306851 +0900
committer *** <***@gmail.com> 1620306851 +0900

test commit

コミットまでの一連の流れを見てみる。

$git addすることでblobオブジェクトが作成され、$git commitによりローカルリポジトリにtreeオブジェクトとcommitオブジェクトが作成される。

1度目のコミット。
image.png

片方のファイルに修正を加えての2度目のコミット
image.png

注目すべきは、treeオブジェクトにおいて変更されていない方のblobオブジェクトのハッシュは変わっていない点である。

image.png

gitはcommitする度に全てのスナップショットを保存していくのではなく変更があったファイルのみをスナップショットとして保存し、変更されていないファイルは同じハッシュを使い回すことで容量を節約している。

なお、インデックスとローカルリポジトリの状態を確認するには以下のコマンドを利用することで確認可能。

# インデックス
$ git ls-files --stage
100644 38e5cdb340ae9979fe513f62388dff69a9885ad1 0   test/aaa.txt
100644 83c831f0b085c70509b1fbb0a0131a9a32e691ac 0   test/bbb.md

# リポジトリ
$ git ls-files        
test/aaa.txt
test/bbb.md

gitの実体はハッシュ値をキーとし、圧縮したファイルを値としたKVSのようなものであり、各コミットは有向グラフとして過去の全ディレクトリ構造を辿れるように管理されているため、任意のコミットの状態に行き来できることが確認できた。

ブランチ

ではどのようにして任意のコミットに移動したり、現在作業中のコミットを区別しているのか。

作成されたブランチは.git/refs/heads/以下に保存される。この中に作成したブランチ名のファイルが並んでおり、開くと単にテキストとして最新コミットのハッシュが書かれている。(テキストなのでcatコマンドなどで開ける。)
実は、ブランチとは枝分かれしたグラフの部分グラフ自体を指しているのではなく、それぞれの枝の最新のcommitオブジェクトのハッシュを表す単なるテキストである。
このイメージを持つことで、ブランチをマージする時に取り込みたい方のブランチに移動して$git mergeするのも納得できるし、マージ後のブランチが消えるのではなく、マージコミットを作る前の最後の最新コミットであることがわかる。

image.png

HEAD

複数の進行中のブランチがある中で、どのブランチにコミットするのか、この状態を保持しているのがHEADである。
HEADは.git/HEAD(テキストファイル)で確認でき、ブランチへの参照が載っている。

HEAD
ref: refs/heads/master

これにより現在のブランチを区別できる。
そして、ブランチへの参照ということは、その実体はcommitオブジェクトのハッシュであり、実はHEADもコミットへのハッシュであるということがわかる。実際、$git checkout [任意のコミットのハッシュ]とすると、detached HEADという状態に変わり、.git/HEADの中身が移動したコミットのハッシュに書き換わっていることが確認できる。

$ git checkout a2e77fbbef72a23e6c908a0eb36c219d77e1a93b
Note: switching to 'a2e77fbbef72a23e6c908a0eb36c219d77e1a93b'.

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 switching back to a branch.

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

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at a2e77fb testcommit

ブランチにアタッチされていないHEADの状態になっているので、$git checkout masterなどでブランチに戻してあげることができる。

なお、detached HEADの状態でも変更を記述し、コミットすることが可能であるが、コミット後にHEADを別のコミットに移動すると、ブランチによって参照されないこのコミットは$git log --graphからは表示されなくなる(objectとしては残るので直接ハッシュを指定して移動したらそのコミットに移動することは可能)。


ここまでの操作から、普段単にブランチを変えるコマンドだと思っていた$git checkoutコマンドというのは、実はHEADを指定したコミットに移動させるコマンドだとわかる。引数にはハッシュ自体も取れるし、実質その参照であるブランチやタグを指定することも可能である。
さらに、コマンド実行後、ワークツリーに表示されているファイルが移動先のファイルに変わっていることから、HEADを移動させるとともに、ワークツリーやインデックスを移動先のコミットの状態に書き換えるというのがcheckoutでしていたことである。
そうだとすると、例えば、feat/Aブランチで機能Aを実装中に、ちょっとmasterブランチのファイルを確認したいというような状況になった時、まだ書きかけでコミットしていないファイルを全て破棄するか、途中でも移動のためにコミットしないといけない事になる。それは不便だ。そこで利用されるのが、$git stashである。

スタッシュ

一時退避場所といった意味。
$git checkoutし、HEADを変える前に、書き換けのファイルをどっかに置いておいて、後で帰ってきたらまた展開したらいいじゃんというのが考え方。確かにそうだろう。

まずは適当にファイルを編集してaddせずに、別のブランチにcheckoutしようとしてみる。
するとまだコミットしてないファイルが上書きされるから移動できないと怒られる。

$ git checkout master
error: Your local changes to the following files would be overwritten by checkout:
        test/aaa.txt
Please commit your changes or stash them before you switch branches.
Aborting

スタッシュを利用するには以下。名前はつけてもつけなくてもいいし、saveも省略可。ここでやっていることは、現在のインデックスの状態でコミットを作成し、HEADとのマージコミットを作成している。これがstashの実体。つまり、stashもコミットと同じである。

# スタッシュへ移動。移動後はワークツリーとインデックスはHEADの状態に戻る。
$ git stash save [スタッシュ名(任意)]

# スタッシュに送ったオブジェクトはここで一覧になって確認できる。
# on ** はどのブランチから退避させたstashかを示している。
$ git stash list
stash@{0}: WIP on master: 6844080 teststash

# スタッシュの中身は以下で見れる。stash@{0}も結局はコミットオブジェクト。
$ git cat-file -p stash@{0}

# ピンポイントにそのファイルだけ見れてもなあ、、なので差分としてみる場合
$ git stash show stash@{0} -p

image.png

スタッシュに送ることでワークツリー、インデックスはHEADの状態に戻り、checkout可能となる。さらに、移動した先でstashから取り出すことさえ可能だ。

$ git stash apply stash@{0} 

なお、stashから取り出してもstashには残ってしまうので、$git stash drop stash@{0}で削除できる。
(はじめから$git stash pop stash@{0}としたら削除までできる。)
ただし、コミットオブジェクトとしては残っているので、ハッシュを指定してcheckoutするとそのstashを閲覧することもできる。(gitって何かミスってもそうそうものが無くなったりしなそう。)

ここまでで、ブランチやタグ、HEAD、stash、どれもこれもがコミットへの参照であり、checkoutはどのコミットを手元の作業場(ワークツリー、インデックス)に持ってくるかを指定するものだとわかった。

なお、後述するがリモートリポジトリもローカルリポジトリからしたら1つのブランチとして表現される。

image.png

$git reset

checkoutするとインデックスやワークツリーは移動先のコミットで上書かれてしまう。
インデックスの内容は今のままで移動させたい(例えばインデックスにあげたものを削除して、HEADの状態に戻したいなど)や、特定のファイルを移動させたいといった細かい動きにはresetを使う。

コミットを移動する。

$ git reset --[soft/mixed/head] [コミットのハッシュ]

移動先のハッシュにはよきHEADから相対的にn個前という表記としてHEAD~nとされる。1個前なら^もok。
オプションは3つ。デフォルトはmixedで、ほかにsofthardがある。
コミットの移動に関して、HEAD以外に何を移動しますか?というのがオプションで決めるところ。
softはHEADのみ。mixedはインデックスのみ、hardは全て移動させる。
重要なのは、移動させると当然ながらコミット前のファイルには二度と戻れないということ。(インデックスに関してはがんばればあるらしい?)

image.png

ファイルを移動させる。

インデックスにあげてしまったファイルを戻したいとなった時、上の原理を使えば$git reset --mixed HEADとして、インデックスを丸ごと消してしまえばいいとわかる。さらに、--mixedオプションや引数HEADはデフォルトでこの値が選択されるので、$git resetだけでもよい。
もし、特定のファイルだけを戻したいなどであれば、ここにファイル名を続けることで実現できる。
すなわち、$git reset aaa.txtのようにするとaaa.txtのみをワークツリーに呼び戻せる。

※ ただし、現在はrestoreコマンドがあり、それを使うことが推奨される。

# インデックスから特定のファイルをワークツリーに呼び戻す。
$ git restore --staged [ファイル名]

# ワークツリーの特定のファイルを初期の状態に戻す。(未着手の状態に戻る。)
# git restore [ファイル名]

旧来の方法で未着手の状態に戻すには$git checkoutコマンドを使う。
(この辺のcheckoutとresetの使い分けがいまいちわからない、、 → git reset & git checkout 使い分けまとめGit の Reset, Checkout, Revert の違い)

# 特定のファイルを直前のコミットまで戻す
$ git checkout -- [ファイル名]
# ワークツリーを直前のコミットまで戻す
$ git checkout .

→ 参考

$git rebase

gitの実体は各時点でのスナップショットであるのでいらないコミットが増えるほど容量を食っていく。他にもタイポ打ち直したり、ケアレスミスを修正したくらいのコミットならpushしたくないとなるだろう。
そうした時に一度コミットした履歴を改竄するコマンドが$git rebaseである。

$ git rebase -i [ハッシュ]

指定したコミットオブジェクト以降のコミットをどちらかに混ぜ込んだり、無くしたりすることができる。
現時点からn個前とかなら、HEAD~nとしてもok

image.png

rebaseの編集の仕方は色々ある。
pick → そのコミットを採用する。
squash → 特定のコミットをその直前のコミットに混ぜ込む。その際、新たなコミットとしてコミットメッセージをつける。
fixup → 特定のコミットをその直前のコミットに混ぜ込む。新たなコミットには直前のコミットメッセージと同じものをつける。

詳細はガイドの英語を読めばいい。

# 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.

ただし、歴史の改竄は未公開のローカルのコミットに対してのみやること。リモートが絡み始めると、他の人が迷惑を被るようになるためだめ。

リモートリポジトリ

既存のリモートリポジトリから全てのデータを取得する。

$ git clone https://github.com/***/test.git

既存のリモートリポジトリからgitのグラフを丸ごと持ってきてローカルに構築。リモートリポジトリのmasterブランチの先頭がどこにあるかを示すorigin/masterが生成される。

ローカルの変更を反映する。

$ git push [リモートリポジトリ名] [ブランチ名]

どのリモートリポジトリ(一般的にはoriginとすることが多い)に対して、ローカルのどのブランチの変更を送るかを指定する。ここでもブランチが単にコミットのハッシュであることを利用すると、実はここはHEADと書いてもいい。

image.png

なお、ローカルでブランチを新設しても勝手にリモート側にもそのブランチができるわけではない。ローカルリポジトリからしたら リモートリポジトリは1つのブランチにすぎず、ブランチとはコミットへの参照でしかないから。
そこで、ローカルに新設したブランチをリモートリポジトリにも反映するには新しいブランチをつけたことを教えてあげる必要がある。

$ git push --set-upstream origin [新しいブランチ名]

リモートの変更を取り込む。

$ git pull = $ git fetch + $ git merge

pullはとってきてマージする操作を自動でやってくれるコマンドである。
リモートにもローカルにも変更がある場合にまじ、リモートから変更をとってきて(fetch)、ローカルのorigin/masterブランチを伸ばす。その後、ローカルでの変更との差を吸収するためにマージする。たいてい同じ箇所をそれぞれ変更していたりしない限りは成功するが、自動で判断できない場合はコンフリクトが発生し、手動で解消する必要がある。といってもどちらの変更を正としてマージコミットを作成しますか?ということなので、マージ後のあるべき姿に書き直してコミットすることを求められているということである。

image.png

なお、mergeではなく、rebaseしたい場合には----rebaseオプションを指定してpullしたらいい。

$ git pull --rebase = $ git fetch + $ git rebase

また、ここまでの記述でoriginorigin/masterとしていたが、その名称は任意に設定可能であり、別の名称をつけた物を複数用意し、リモートリポジトリを複数用意することも可能である。
リモートの設定は.git/configにテキストで書かれている。

config(抜粋)
[remote "origin"]
    url = https://github.com/***/test.git
    fetch = +refs/heads/*:refs/remotes/origin/*

リモートの名前(origin)、リモートリポジトリのURL、fetchする対象のrefspecが指定される。
refspecは、<リモート側の参照>:<ローカルの書き込み先>を意味しており、fetchする際にリモートのどのブランチをローカルのどこに対応づけて進めるかを指定している。なお、先頭の+は、fast-forwardでない場合でも参照を更新するよう指定している。

リモートの追加・削除は以下。

$ git remote add origin https://github.com/***/test.git
$ git remote rm origin

参考

8
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
10