Astroの光線のサムネイル。

pubDate: 2024-03-04

author: sakakibara

git

初心者

おすすめエイリアス

gitを使用する際にエイリアスを使うとgit commitの粒度が細かくなる。 これはコマンド長が短くなると心理的ハードルが下がるためであると思われる。そこで、いくつか自分が設定しているエイリアスを紹介する。

Terminal window
[alias]
st = status
sw = switch
lo = log --graph --pretty=format:'[%ad %Cgreen%ar%Creset] %C(yellow)%h%Creset %s %Cblue%d' --date=format:'%Y-%m-%d %H:%M'
br = branch
ci = commit

基本2文字で統一している。 logが少々長いが、このようにするとキラキラlogが表示される。

差分はhistogramを使おう

gitではconflictなどが生じた際に差分を表示することができる。 git mergetoolなどを使うと差分をきれいに表示してくれるが、 この差分アルゴリズムはいくつか指定することができる。

いろいろ紹介するのもいいが、git-diffにある通り--histogramを使うといいらしい。 とりあえず設定しておくといいかもしれない。

Terminal window
git config --global diff.algorithm histogram

機密情報をどうするか

.envに環境変数としてアクセストークンなどを保存することが多い。 これをリポジトリで管理しないように.gitignoreで無視することが通常の運用である。 しかし、.envファイルのアクセス権限を変更しなかったり、そもそも平文でアクセストークンを保存するのはセキュリティ上問題がある。 また、ELFバイナリなんかはリバースエンジニアリングされるとアクセストークンが漏洩する可能性がある。

awshasicorpはアクセストークンを保存するためのツールを提供している。 これで全てが防げるかどうかはわからないが、無いよりマシだろう。

.gitディレクトリ

.gitディレクトリには何がはいっているのか。 適当にgit initして中身を確認してみる。

Terminal window
.
├── config
├── description
├── HEAD
├── branches *
├── hooks *
│   ├── pre-commit.sample
│   ├── ...
│   └── update.sample
├── info *
│   └── exclude
├── objects *
│   ├── info *
│   └── pack *
└── refs *
├── heads *
└── tags *

このようになっている。 なお、*がついているものはディレクトリであることを示す。 hookはgitの操作の前後に実行されるshellスクリプトであるが、ここでは省略する。

excludeを見てみる。

Terminal window
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

これはgitで管理されていないファイルを指定するためのファイルであることがわかる。 ここには他のリポジトリにとっては不要だが、自分にとっては必要な設定を記述する。 こうすることによって自分の環境のみ必要なファイルを他人に迷惑を書けることなく管理することができる。 .gitignoreのような形で書くことができる。

configを見てみる

Terminal window
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true

ここにはgit configのこのリポジトリ限定の設定が書かれている。 例えば、適当に

Terminal window
git config --local alias.stts status

のような設定をしてみると、configに以下のような設定が追加される。

[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[alias]
stts = status

descriptionはどうやらリポジトリの説明文を書くファイルのようだ。 どうやらここはgithubのリポジトリの説明文(description)に相当する部分のようだ。

Unnamed repository; edit this file 'description' to name the repository.

そしてHEADを見てみる。

Terminal window
ref: refs/heads/main

HEADは現在のブランチを指している。これがどのように変化するかに着目する。

configdescriptionはリポジトリの設定や説明文を書くファイルであることがわかった。

gitがどのようにファイルを管理しているかについて知るためには残りの

の4つのディレクトリに注目する。

git add してみる

さて、適当なファイルを作成し、git addしてみよう。

Terminal window
$ cat A
> 0123
$ git add A

すると以下のようにindexファイルとobjects/40/38...4fというファイルが作成される。

objects/40以下のファイルを見てみよう。 まず、

Terminal window
$ file objects/40/38*
> objects/40/381e26feb9944a22c5c11b6f5516f2abc77f4f: zlib compressed data

どうもzlibで圧縮されているようだ。 これを適当に解凍してみる。 なお、hoge=objects/40/381e26feb9944a22c5c11b6f5516f2abc77f4fとすること。(醜いので)

Terminal window
$ python -c "import zlib; print(zlib.decompress(open('hoge', 'rb').read()).decode())"
> blob 50123

おぉ、どうやらblob 5という文字列にファイルAの内容が格納されているようだ。

indexファイルを見てみる。

Terminal window
DIRCfiW`
ir�iW`
6bt]��@8&J"�U�OA
C

読めるような、、読めないような git-indexに詳しく書いてある。 また、git ls-files --stageindexファイルの内容を見ることができる。

Terminal window
$ git ls-files --stage
> 100644 40381e26feb9944a22c5c11b6f5516f2abc77f4f 0 A

1列名はファイルのモードを示している。ファイルのモードには以下のようなものがある。

次に、ファイルを編集してみる。3を消して456を追加してみる。

Terminal window
$ cat A
>012456

すると.gitファイルは以下のように新たなファイルがobjectsに作成される。

.
├── config
├── description
├── HEAD
├── index
├── branches
├── hooks
│   ├── pre-commit.sample
│   ├── ...
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── 36
│   │   └── ed83ab6ec0e255078f7b0eede5ad9bfbee0071
│   ├── 40
│   │   └── 381e26feb9944a22c5c11b6f5516f2abc77f4f
│   ├── info
│   └── pack
└── refs
├── heads
└── tags

indexファイルの中身が変更されており、

Terminal window
DIRC^@^@^@^B^@^@^@^Afil±7^]^K<99>fil±6ê.Ä^@^@þ^A^@bt.
^@^@<81>¤^@^@^Cè^@^@^Cè^@^@^@^G6í<83>«nÀâU^G<8f>{^Níå­<9b>ûî^@q^@^AA^@ ùt<8e>ÐpÝá5Ïþë~sg|<82>Coð

それをgit ls-files --stageで確認すると以下のようになる。

Terminal window
100644 36ed83ab6ec0e255078f7b0eede5ad9bfbee0071 0 A

先程と変わっていることがわかる(ファイルのハッシュを再計算したので当たり前だが)。 また、objectsの新しいファイルを見てみると

Terminal window
python -c "import zlib; print(zlib.decompress(open('ed83ab6ec0e255078f7b0eede5ad9bfbee0071', 'rb').read()).decode())"
blob 7012456

のようになっていることがわかる。

以上をひとまずまとめると、

  1. git addするとファイルを圧縮したファイルobjects/num/compress...1が作成される。また、indexファイルが作成される。
  2. さらに変更してgit addするとobjects/num/compress...2ファイルが新たに作成される。また、indexファイルが変更される。

git rm —cached してみる

git rm --cachedしてもobjectsには圧縮したものが残っている。ただ、indexファイルの中身が変わるだけである。

Terminal window
DIRC^@^@^@^B^@^@^@^@9Ø<90>^S<9e>å5l~õr!lëÍ'ªAùß

git ls-files --stageで確認すると何も表示されない。

Terminal window
$ git ls-files -s
>

そこで、git addをしてみる。

Terminal window
├── objects
│   ├── 36
│   │   └── ed83ab6ec0e255078f7b0eede5ad9bfbee0071
│   ├── 40
│   │   └── 381e26feb9944a22c5c11b6f5516f2abc77f4f
│   ├── info
│   └── pack

ここで、objectsファイルは変わらない。 どうもファイルのハッシュ値を計算して、新たにファイルを作成しているが、ファイル名も内容も被っているので更新されていないようにみえるのである。 これはls -lt -c objects/36/..で確認すると作成した時間が更新されているので新たに36ディレクトリが作られたことがわかる。

git commit してみる

git commitするとobjects, COMMIT_EDITMSG, refs logsにファイルが作成される。

COMMIT_EDITMSGはコミットメッセージが格納されているファイルである。

Terminal window
$ cat COMMIT_EDITMSG
> Initial commit

logsは後回しにする。 注目したいのはobjectsindexrefsである。

indexファイルから見ていく。 相変わらず読めないが、

Terminal window
$ git ls-files -s
> 100644 36ed83ab6ec0e255078f7b0eede5ad9bfbee0071 0 A

とあり、先程と変わらないことがわかる。

objectsファイルを見てみる。 objects/32というファイルが新しく作成されている。 これを解凍して見てみると

Terminal window
commit 201tree df14f7c51d079648cb4db3941b7872ec20776d3c
author sakakibara yuuki <sakakilabo0000@gmail.com> 1718186488 +0900
committer sakakibara yuuki <sakakilabo0000@gmail.com> 1718186488 +0900
initial commit

最初の行にcommit 201tree df14..とあり、 objectsディレクトリにもdfというディレクトリがあり、14f7...と続くファイルがある。 おそらくこれがcommitを表したファイルなのだろう。 そこでobjects/df/14f..を解凍してみると

Terminal window
$ python -c "import zlib; print(zlib.decompress(open('14f7c51d079648cb4db3941b7872ec20776d3c', 'rb').read()).decode(errors='replace'))"
> tree 29100644 A6탫n��U�{�孛��q

もうちょっといい感じに解凍できるのかもしれないけどここで力尽きた。 とりあえず、tree, 100644, A6..という文字列が格納されていることがわかる。 おそらくtreeとはディレクトリ構造(木構造)を表すもので、100644はファイルのモードを示している。
ここからcommitの情報はtree(ディレクトリ)への参照であることがわかる。
自力で解凍するのは諦めてgit cat-file -p df14...で中身を見てみる。

Terminal window
$ git cat-file -p df14...
> 100644 blob 36ed83ab6ec0e255078f7b0eede5ad9bfbee0071 A

ファイルAの場所を指すハッシュ値が記述されていることがわかる。

以上でだいたいgitがどのようにファイルを管理しているかがわかったかもしれない。

  1. git addするとファイルが圧縮されてobjectsに保存される。
  2. indexファイルはgit addするたびに変更される。
  3. ファイルのハッシュ値の前2文字をディレクトリ名とし、後ろの文字列をファイル名として保存される。
  4. git commitするとobjectsにtreeを記述するファイルとcommitに関するファイルが作成される。
  5. refs/headsにはブランチ名のファイルが作成され、 中にはcommitのハッシュ値が記述されている。

staging areaというのはindexファイルのことで, git addすることでファイルが圧縮されてobjectsにその時のワーキングディレクトリの状態が保存される。

git commit すると、indexファイルに記述されたファイルの名前を元に、objectsにcommitを記述するファイルが作成され、その中でtreeファイルを指定する。 treeにはobjectsに保存されたファイルの名前が記述されている。

おそらく、バージョン管理システムで最も重い処理というのはファイルの圧縮と解凍であろう。 git addgit commitをするたびに圧縮されたファイルを作成するのは処理に時間がかかる。 そこで、git addをするたびに、objectsファイルを作成して(git addで登録したものしかgit commitしないので、どうせそのうちgit commitはするだろうから)先に圧縮する処理を細かく入れておく。

そして、git commitすると、objectsにその時のワーキングディレクトリの状態とobjectsに保存されたファイルの対応づけを行うような処理を行うことで(これもobjectsに保存しておく)効率よくファイルを管理することができる。 git addすると、

Diagram

git commitすると、

Diagram

gitオブジェクト

長々と.gitの中身を見てきたが、ここでgitオブジェクトについて説明しよう。 とは言え、Gitの内部に詳しくかいてあるので、正直これを見てもらえれば理解できると思う。

ファイルをzipで圧縮したものをblobオブジェクトと呼ぶ。 これはそのファイルのSHA-1の前2文字がディレクトリ名、残りをファイル名として保存される。 blogオブジェクトをディレクトリ構造として保存するためのオブジェクトをtreeオブジェクトと呼ぶ。 treeオブジェクトも名前にハッシュ値がつけられる。 treeオブジェクトはblobオブジェクトとtreeオブジェクトの名前が保存される。

これにより、履歴をとるためにはtreeオブジェクト指定すれば良いことになる。 履歴とtreeオブジェクトを対応付けるオブジェクトをcommitオブジェクトと呼ぶ。 commitオブジェクトはtreeオブジェクトのハッシュ値、著者やcommitが実行された時間を保存している。

HEADにはref: refs/heads/mainとあり、 refs/heads/mainには直前に行ったcommitのcommitオブジェクトのハッシュ値が保存されている。

git sw -c devのようにブランチを切り、

Terminal window
mkdir B && echo 987 > B/C

のようにファイルを作成する。 HEADにはref: refs/heads/devとなり、 refs/heads/devが新たに作成されている。 refs/heads/devには直前に行ったcommitのcommitオブジェクトのハッシュ値が保存されている。

git sw -d HEAD^のようにHEADを1つ前に戻すと、 HEADには32d38...となり、直前に行ったcommitのcommitオブジェクトのハッシュ値が保存されている。 refs/heads/{dev, main}はそのままである。 このようにするとHEADが指しているcommitオブジェクトの中身を見ることができる。(ワーキングディレクトリに展開される。)

gitのブランチ戦略

gitではどのようなブランチを切って開発していくべきだろうか。 gitのブランチの切り方、運用の仕方をまとめたものをブランチ戦略と呼ぶ。 ブランチ戦略にはいくつかの種類がある。 この記事がよくまとまっているので主に参考にするが、

の主に3つを使うことがおおいだろう。 git-flowは大規模開発用、github-flowはシンプルだがgithubの使用を前提としたもの、gitlab -flowはgithub-flowに改良を加えたものである。 個人開発ではgithub-flowをよく使うことが多いだろう。

git-flow

git-flowは大規模開発用のブランチ戦略であり、5つのブランチを使う。

開発の軸足となるdevelopブランチから個別の機能を開発するfeatureブランチを切り、 featureブランチからdevelopブランチへマージする。 release-branchではbugの修正のみを行い、適当なタイミングでmainブランチにマージする。 mainブランチでリリースされたものに対してbugが発生した場合はhotfixブランチを切り、mainブランチにマージする。

言葉にすれば簡単だが、ブランチの数が大きく運用が複雑になる。 少なくとも個人開発では使わない。

github-flow

github-flowは3つのブランチを使う。

git-flowと比較してみるとだいぶシンプルになった。develop(es)ブランチはfeatureブランチに該当し、integrationブランチはdevelopブランチに該当する。 integrationブランチからmainブランチにマージする前にpull requestを作成し、レビューを受ける。また、mainへマージした直後にデプロイを継続的に行う。 CI/CDの整備が必要となる。

gitlab-flow

gitlab-flowは4つのブランチを使う。

gitlab-flowはmainがリリースするためのブランチではない。 mainからfeatureブランチを切り、featureからmainへマージする。 mainからpre-productionブランチを切り、pre-productionからproductionブランチへマージする。

pre-production以降の流れというのは複数環境でデプロイが行われたかを確認するためのものである。これはgithub-flowの欠点であった複数環境でのデプロイにおいてデプロイのタイミングとブランチの結びつきが不明瞭という欠点を補うものである。 これもまた、CI/CDの整備が必要となる。

gitのコミットメッセージ

gitをを使う際にコミットメッセージをどうするかは悩ましい問題である。 その際に参考になりそうなのがどのようにGit commit messageを書くかである。 重要なことは振り返って見やすいコミットメッセージを書くことである。 そのため、コミットメッセージにいくつか規格や制約を設けることが重要である。 コミットメッセージの長さや、コミットメッセージの形式を統一することで、コミットメッセージを見返す際に見やすくなる。

実際、LinuxカーネルやGitの開発でも規格的なコミットメッセージが使われている。

逆にこのように規格を設けないと、blame, revert, rebase, log, shortlogなどが効果を発揮しづらくなる。 参考記事ではいいコミットメッセージの要件を3つ挙げている。

  1. Style: マークアップ構文、マージン、文法、大文字小文字の統一、句読点の規格化
  2. Content: コミットメッセージに含めるもの、含めないものは何かの規格化
  3. Metadata: issue tracking ID, pull request IDの規格化

また、たった7つのルールを守るだけで、コミットメッセージが見やすくなるという。

  1. 件名と本文を空行で分ける
  2. 件名は50文字以内にする
  3. 件名の頭文字を大文字にする
  4. 件名の最後にピリオドをつけない
  5. 件名は命令形にする
  6. 本文は72文字以内程度にまとめる
  7. 本文は何を変えたかではなく、なぜ変えたかを書く

例1

Summarize changes in around 50 characters or less
More detailed explanatory text, if necessary. Wrap it to about 72
characters or so. In some contexts, the first line is treated as the
subject of the commit and the rest of the text as the body. The
blank line separating the summary from the body is critical (unless
you omit the body entirely); various tools like `log`, `shortlog`
and `rebase` can get confused if you run the two together.
Explain the problem that this commit is solving. Focus on why you
are making this change as opposed to how (the code explains that).
Are there side effects or other unintuitive consequences of this
change? Here's the place to explain them.
Further paragraphs come after blank lines.
- Bullet points are okay, too
- Typically a hyphen or asterisk is used for the bullet, preceded
by a single space, with blank lines in between, but conventions
vary here
If you use an issue tracker, put references to them at the bottom,
like this:
Resolves: #123
See also: #456, #789

ただ1行で書くこともできる。

Fix typo in introduction to user guide

自分が現時点で守れていないと思うルールは以下の2つである。

  1. 件名は命令形にする
  2. 本文は何を変えたかではなく、なぜ変えたかを書く

5. 件名は命令形にする

なぜGitは命令形で書くべきなのかというと、Git自身がデフォルトで命令形を使っているからである。

Terminal window
Merge branch `dev`

このようなメッセージがデフォルトで表示される。
Gitはデフォルトで命令形を使っているので、それに合わせるべきである。
だからといって、そもそも命令形で書くことは難しい。
通常、我々は報告をする際に命令形を使うことは無い。
注意すべき点として、まず、 関係代名詞は命令形ではない。

また、説明文は命令形ではない。

ではどうすればいいだろうか。 簡単な対処だが、以下の太字の部分を抜き出せば良い。

なぜこれがいいかというと、Fixed bug with Yのようなコミットメッセージを防げるからだ。

また、commit messageを参考にしてコミットメッセージの先頭にprefixをつけることもできる。

feat: remove deprecated methods

のようにすることで、全体の統一が取れる。

7. 本文は何を変えたかではなく、なぜ変えたかを書く

なぜ変えたかの良い例としては

commit eb0b56b19017ab5c16c745e6da39c53126924ed6
Author: Pieter Wuille <pieter.wuille@gmail.com>
Date: Fri Aug 1 22:57:55 2014 +0200
Simplify serialize.h's exception handling
Remove the 'state' and 'exceptmask' from serialize.h's stream
implementations, as well as related methods.
As exceptmask always included 'failbit', and setstate was always
called with bits = failbit, all it did was immediately raise an
exception. Get rid of those variables, and replace the setstate
with direct exception throwing (which also removes some dead
code).
As a result, good() is never reached after a failure (there are
only 2 calls, one of which is in tests), and can just be replaced
by !eof().
fail(), clear(n) and exceptions() are just never called. Delete
them.

基本的には変更がどのように行われたかについては除くことができる。

この3つについて書くことができればいいコミットメッセージになる。