MarkdownとBullet Journal

いわゆるプログラマーのつぶやき

【Git】同時に複数の作業ディレクトリを扱う

複数ブランチを同時編集

Gitで複数のブランチを同時編集したい場合、git checkout ブランチ名で頻繁に移動しながら編集することになるが、都度別ブランチのfileを確認しようとすると不便なため、全く別のフォルダに暫定的にコピーしたり、stashしたり、別クローンしたりなど結構手間がかかる作業を強いられがちになる。

その様な課題に対してGitは、1つのリポジトリに対して複数ブランチの各作業ディレクトリ(worktree)を同時展開して、編集作業やcommitをそれぞれの作業ディレクトリで並行して行える機能を提供している。コマンド名もgit worktree

f:id:ProgrammingForEver:20210922204302p:plain

同時展開は、現在のブランチを展開する作業ディレクトリの下に、他のブランチがそれぞれサブディレクトリとなって存在する形で展開される(全く別のフォルダでも良い)。なのでディレクトリを移動するだけで各ブランチに入り、そのブランチに対するGit操作が行える。このサブディレクトリとして存在する各作業ディレクトリは仮想的な存在なので各ブランチ本体への操作とイコールであり、作業完了後このサブディレクトリは不要になるので抹消する。

VSCodeなどのエディターを用いれば各ブランチに該当するサブディレクトリを一覧して同時編集や差分表示が出来る。Git操作もVSCodeのターミナル画面のシェルでディレクトリを変更するだけで各ブランチに入ってそのブランチのcommitが行える。とても便利なので実例を示しながら紹介したい。

git worktreeコマンドを用いた同時編集の例

説明のために、以前の記事でまとめた内容と同じく、ブランチ数が1つ、commit数が4つ、fileもa.sh,b.sh,c.sh,d.shの4つだけのシンプルな状態を出発点とする。

programmingforever.hatenablog.com

実証のために2つブランチを作成

まずtest1ブランチを作成して、e.sh(空file)を追加してからstaging&commitを行う。

$ git checkout -b test1
Switched to a new branch 'test1'
$ touch e.sh
$ git add .
$ git commit -m "test1のブランチ作成"
[test1 6a63d27] test1のブランチ作成
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 ee.sh
$ 

次にtest2ブランチを作成して、f.sh(空file)を追加してからstaging&commitを行う。

$ git checkout -b test2
Switched to a new branch 'test2'
$ touch f.sh
$ git add .
$ git commit -m "test2のブランチ作成"
[test2 0e5d6c1] test2のブランチ作成
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 ee.sh
$ 

git worktreeコマンドを使う

ここでmainブランチに戻り、git worktreeコマンドを用いてtest1ブランチとtest2ブランチの2つを現在の作業ディレクトリの下に配置する。なお配置先は現在の作業ディレクトリの下でなくても構わないが、VSCodeなどで編集する際に各ブランチがそれぞれサブフォルダに収まっていると一覧出来て扱い易いと思う(もちろん別フォルダに置いてVSCodeのworkspace機能を使って編集しても良い)。

コマンドとしてgit worktree add サブディレクトリ名(新規) ブランチ名と打つと、指定したブランチの作業ディレクトリ一式が新規に作られるサブディレクトリ内に入る。もちろんcommit履歴などのobjectもそのまま扱える。つまり並行宇宙の様に別のブランチ一式が同時に扱える状態で存在している。初めて使った時はまるで魔法の様に感じた。

十分に発達した科学は魔法と見分けがつかない by.アーサー・クラーク

$ git checkout main
Switched to branch 'main'

$ git worktree add test1 test1
Preparing worktree (checking out 'test1')
HEAD is now at 6a63d27 test1ブランチ作成

$ git worktree add test2 test2
Preparing worktree (checking out 'test2')
HEAD is now at 0e5d6c1 test2ブランチ作成

ここでmainブランチの状態を確認すると、追加されたtest1/, test2のサブディレクトリを検出して未追跡folderだとメッセージが出るが、これらのサブディレクトリは作業完了後にGitが削除するので無視して問題ない

$ ls
a.sh    b.sh    c.sh    d.sh    test1    test2   # - 追加したサブディレクトリが見える

$ git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        test1/
        test2/

nothing added to commit but untracked files present (use "git add" to track)

ではtest1ブランチを入れたサブディレクトリのtest1/に移動してみよう。するとまるでこのサブディレクトリが全く別の作業ディレクトリの様に振る舞い、Gitの各コマンドも機能する。まずはtest1ブランチがクリーンな状態のままであることを確認してから、e.shを編集してcommitする。記載した様にこのcommitは現在のmainブランチとは全く関係なく、worktreeとして取り込んだtest1ブランチのcommitになる

$ cd test1
$ ls
a.sh    b.sh    c.sh    d.sh    e.sh
$ git status
On branch test1
nothing to commit, working tree clean
$ vi e.sh
$ git status
On branch test1
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   e.sh

no changes added to commit (use "git add" and/or "git commit -a")
$ git add .
$ git status
On branch test1
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   e.sh

$ git commit -m "worktreeでcommit"
[test1 dcadb5c] worktreeでcommit
 1 file changed, 1 insertion(+)

同様にtest2ブランチを入れたサブディレクトリのtest2/に移動してから、f.shを編集してcommitする。こちらも同様にmainとは全く関係ない、worktreeとして取り込んだtest2ブランチのcommitだ。

$ cd ../test2
$ git status
On branch test2
nothing to commit, working tree clean
$ vi f.sh
$ git add .
$ git commit -m "test2のworktreeでcommit"
[test2 ba6f4fe] test2のworktreeでcommit
 1 file changed, 1 insertion(+)

この状態でそれぞれの作業ディレクトリに移動してログを見てみる。まずは元のmainの作業ディレクトリに移動してログを確認すると最初の4commitが確認出来る。

$ cd ..
$ git log --oneline
cac41d4 (HEAD -> main) 4番目のcommit
8a20ac3 3番目のcommit
1999418 2番目のcommit
ef11425 最初のcommit

次にtest1サブディレクトリに移動するとtest1ブランチのログが全て確認出来る。test2ブランチのcommitは当然含まれない

$ cd ../test1
$ git log --oneline
dcadb5c (HEAD -> test1) worktreeでcommit
6a63d27 test1ブランチ作成
cac41d4 (main) 4番目のcommit
8a20ac3 3番目のcommit
1999418 2番目のcommit
ef11425 最初のcommit

さらにtest2サブディレクトリに移動するとtest2ブランチのログが全て確認出来る。別ブランチのtest1の"worktreeでcommit"のcommitも存在しない。

$ cd ../test2
$ git log --oneline
ba6f4fe (HEAD -> test2) test2のworktreeでcommit
0e5d6c1 test2ブランチ作成
6a63d27 test1ブランチ作成
cac41d4 (main) 4番目のcommit
8a20ac3 3番目のcommit
1999418 2番目のcommit
ef11425 最初のcommit

以上の様に各ブランチの編集とcommitを終えたら、不要となったworktreeを削除するこの削除は各ブランチの本体に全く影響しない。一時的に構成された仮想作業場所での用事が済んだので、その仮想作業ディレクトリを削除する行為に過ぎない。

$ cd ..
$ git checkout main
Switched to branch 'main'

$ git worktree remove test1
$ git worktree remove test2

$ ls
a.sh    b.sh    c.sh    d.sh       # - test1, test2サブディレクトリが消えた

【Git】複数PCを用いたresetやamend利用法

commit改変時の複数PCのリポジトリの合わせ方

個人開発では会社用と自宅用にそれぞれPCを置いて同じリポジトリを用いながら編集を継続する運用方法がある(私もそう)。その場合、どちらかのPCでリモートからpull&更新&commit&pushを行い、もう1台のPCを使う際には再びリモートからpull&更新&commit&pushを行う形で、違うPCでもよどみなく編集を継続出来る。

但しcommitを整理する目的でgit rebase,git resetgit commit --amendなどを使用するとcommit IDが変わるため、リモートリポジトリやもう1台でのローカルリポジトリgit pullを行う際にconflictする。この記事は個人で複数台のPC使用時にreset使用等でconflictした場合のcommit履歴の整理方法を記載する。

f:id:ProgrammingForEver:20210922205020p:plain

git reset使用例

個人開発で複数台のPCを利用するケースで、git resetなどcommit IDの改変が行われる作業を行うと、複数人での開発同様に各ローカルリポジトリやリモートリポジトリとのconflictが生じる。

以下の例では、ある1台のPCで一気に最初のcommitまでresetする極端なgit resetを行ったと仮定する。その段階での作業ディレクトリにはa.sh, b.sh, c.sh, d.shの4fileだけがあると仮定し、reset直後にstaging&commitを行うとする(2回目のcommitになる)。

$ git reset HEAD~10
Unstaged changes after reset:
M       a.sh
M       b.sh
M       c.sh
M       d.sh

$ git log
commit f0f8ac18d38bf64ff3cab023fb7330280fdadcf4 (HEAD -> main)
Author: ************
Date:   Mon Sep 20 15:39:36 2021 +0900

    最初のcommit

$ git add .
$ git commit -m "2回目のcommit"
[main 736ed4a] 2回目のcommit
 2 files changed, 6 insertions(+)

$ git log --oneline
736ed4a (HEAD -> main, origin/main) 2回目のcommit   # - 1台目PCのcommit履歴
f0f8ac1 最初のcommit

リモートリポジトリへ強制push

次にresetした後のcommit(新2回目)をGitHubなどのリモートリポジトリにpushする。但し、reset前の2回目のcommitからIDが変化しているので普通にpushするとマッチしないためエラーで停止する。そこでpushコマンドに強制オプションの-fを付けて実行することで、resetを行ったローカルリポジトリの内容をリモートリポジトリに強制的に更新する。

この記事では自分のPC間のリポジトリ共有を対象としているので納得した上で行えるが、複数人で開発している場合は事前許諾を得てから行わなければならない。

$ git push -f
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 8 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 788 bytes | 788.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0)
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.

conflict発生

さてリモートリポジトリにreset後のcommitが無事に登録出来たので、もう1台のPCでこのリモートリポジトリからgit pullすると、ここでもconflictが発生して本当なら2つだけのcommitのはずが4つcommitが見えており、マージ作業が求められる。これはもう1台の2番目のcommit(reset前のcommit:旧IDとする)とreset&push&pullした新しいcommitのID(新ID)が一致しないためで、このconflictも当然の結果だ。

$ git pull
remote: Enumerating objects: 9, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (1/1), done.
remote: Total 5 (delta 4), reused 5 (delta 4), pack-reused 0
Unpacking objects: 100% (5/5), done.
From https://github.com/*********
 + e0a9a2d...0fccb57 main       -> origin/main  (forced update)
Merge made by the 'recursive' strategy.

$ git log --oneline
9739d8f (HEAD -> main) Merge branch 'main' of https://github.com/********** into main
0fccb57 (origin/main) 2回目のcommit   # - git pull後のcommit(新ID)
e0a9a2d 2回目のcommit   # - git pull前のcommit(旧ID)
f0f8ac1 最初のcommit

しかし本来commitの整理のためにgit resetgit commit --amendを使ったのに、その都度余分なマージcommitが生成される様では本末転倒である。ここでやりたいのはgit push -fの様に強制的なgit pullなのでその方法を以下説明する。

git-pullの強制conflictの解決方法

作業対象をmainブランチとする。なお下記作業を行うとstaging、作業ディレクトリにある変更が全て消失する点に注意。

$ git checkout main  # - mainブランチにcheckoutする
$ git fetch origin main   # -  リモートの最新状態を取り込む
$ git reset --hard origin/main   # - hardモードでリセット

$ git log --oneline
736ed4a (HEAD -> main, origin/main) 2回目のcommit   # - 1台目PCと同じcommit履歴になる
f0f8ac1 最初のcommit

既にgit pullでconflictしてしまった場合も以下で対応出来る。

$ git merge --abort     # - mergeを取り消す
$ git fetch origin main   # - リモートの最新状態を取り込む
$ git reset --hard origin/main   # - hardモードでリセット

$ git log --oneline
736ed4a (HEAD -> main, origin/main) 2回目のcommit   # - 1台目PCと同じcommit履歴になる
f0f8ac1 最初のcommit

上記の様に2台目のPCも1台目のPCと同じく整理されたcommit状態になる

【Git】リポジトリをコンパクトにする

リポジトリのディスク容量を削減する

開発を何年も継続し続けるとリポジトリもそれなりの規模になる。エンジニアの心理としてはリポジトリを整理したくなるもので、5種類の方法を紹介する。

方法 過去へのアクセス 削減度 作業の手軽さ
git-gc 50%~
partial-clone 90%~
shallow-clone 90%~
logic-compress 90%~
reborn 100%

その1:git gcを用いる

これは最も簡単でそれなりの効果が得られる手段だ。通常Gitはfileをsnapshotして全て保存しているが、git gcを発動するとバイナリfileに差分保存することでディスク容量を圧縮する。オプションを付ける事でさらに圧縮率を上げる事が可能。

programmingforever.hatenablog.com

その2:クローンサイズを下げる:パーシャルクローン

Git のリポジトリが大きくなると、非常に多くのcommit,tree,そしてblob(file)objectを保持する様になる。これは開発者が何年も前の特定commitにいつでも戻れる機能を維持するためであり、すべての到達可能なデータがローカルリポジトリにある事を意味する。しかし、Git の全履歴にある全file(blob)を保持しなくてもよい用途には、保持するデータを限定する「パーシャルクローン」機能がある。名前の通り、必要な一部のobjectのみ保持して、過去のfile参照などの必要が生じたら該当するfileをリモートから自動ダウンロードする機能である。

過去の履歴はほとんど使用せず、追加作業が主であればローカルリポジトリが過去のfile(blob)を保持していなくてもよい実態に合わせたクローンであり、ディスク容量の軽減が可能となる。保持するレベルでプロブレスクローンとツリークローンが選択出来る。

すでにフルクローンがローカルリポジトリにある場合、一旦それを削除してからパーシャルクローンで再度ローカルリポジトリを構成する方法が取れる。

以下比較のために各クローンのローカルリポジトリのobjectの保持状態一覧を示す。

各クローン選択 commit履歴 tree blob
フルクローン 全て 全て 全て
プロブレスクローン 全て 全て 最新のみ
ツリーレスクローン 全て 最新のみ 最新のみ
シャロークローン 最新のみ 最新のみ 最新のみ

フルクローンコマンド:git clone

  • 保持するcommit:全commit
  • 保持するtree:全tree
  • 保持するblob:全blob

パーシャルクローン:ブロブレスクローンコマンド:git clone --filter=blob:none

バーシャルクローン:ブロブレスクローン は、到達可能な全commitとtreeを保持するが、blob(file)は必要に応じて取得する。このクローンは、開発環境に普通に利用出来る。

  • 保持するcommit:全commit
  • 保持するtree:全tree
  • 保持するblob:最新blobのみ

パーシャルクローン:ツリーレスクローンコマンド: git clone --filter=tree:0

バーシャルクローン:ツリーレスクローンは、到達可能な全commitを保持するが、treeとblob(file)は必要に応じて取得する。このクローンは、commit履歴にアクセスしたいビルド環境に向く。

  • 保持するcommit:全commit
  • 保持するtree:最新treeのみ
  • 保持するblob:最新blobのみ

その3:クローンサイズを下げる:シャロークローン

さらに過去のcommitにはアクセスせず、現時点の最新fileだけのビルドだけ行うなどの用途には、シャロークローンが適している。

シャロークローンコマンド: git clone --depth=1

シャロークローンはHEAD以外の全てのcommit履歴も捨てる形でクローンのサイズを小さくする。当然ながら利用可能な Git コマンドが制限される。このクローンは後からのフェッチで過度の作業量を発生するため開発者の使用は推奨できない。

  • 保持するcommit:最新commitのみ(HEAD)
  • 保持するtree:最新treeのみ
  • 保持するblob:最新blobのみ

その4:不要と判断するブランチやblobを整理してgcで高圧縮

ここから下はリポジトリの論理的に不要となった部分を削除する方法を記載する。まずは元のリポジトリに色々と手を加える方法を紹介し、次のその5では過去のリポジトリと絶縁して新生する方法を紹介する。

その4に関しては詳細記事の紹介に留める。

zenn.dev

その5:ゼロから新しいリポジトリを生成

シャロークローンはHEAD以外の過去のcommit履歴を取り込まない事を説明したが、作業ディレクトリに入った最新fileを元に、ゼロから新しいリポジトリを立ち上げる運用も可能だ。何年も経過している様なプロジェクトでも一気にリポジトリがゼロになる。もちろんフルクローンでも同じことが出来るが、作業時間の無駄を避けたいと考えるのがエンジニアであり、本用途にはシャロークローンが適していると考える。

注意:この方法で作る新生リポジトリは元のリポジトリと完全に縁が切れた別物になる点に注意。また元のリポジトリ削除のためにrm -rfコマンドを使うので作業の際はディレクトリの位置などに十分注意すること

$ git clone --depth=1 <url> # - シャロークローンで最新リポジトリから最新file一式を取り込む
$ cd NewDirectory           # - 新しく作成された作業ディレクトリに移動 
$ rm -rf .git/    # - file以外の不要なリポジトリ一式を抹消する
$ git init        # -  最新fileだけの新しいローカルリポジトリを作る
$ git add .       # - 最新fileを全てstaging
$ git commit      # - 新生ローカルリポジトリの最初のcommit

$ git remote add origin <url>  # - 新生のリモートリポジトリのurlを入力
$ git push -u origin main      # - ローカルをリモートにpush

補足:新生に合わせてブランチ名をmainに変えたい場合

昨今の流れからブランチ名をmasterからmainに変更したい場合は下記手順を実行する。

  • ①ローカルのブランチ名をmainに変更(最初にローカルを変えること)
$ git branch -m master main
  • ②リモートのブランチ名をmainに変更
$ git push -u origin main  # - ローカルで変更したmainブランチをリモートへpush
  • GitHubの目的のリモートリポジトリにアクセス
  • ④Settings>Branches>Default branchでmainに変更してUPDATEを押す
  • リポジトリのTOP画面に戻ってブランチ名の欄の中にあるView all branchesボタンを押す
  • ⑤masterを消去(ゴミ箱をクリック)

補足:間違ってrmを実行した場合の復元方法

以下参考までにrmで消去したfileの復元方法を記載するが、プロセスが有効な状態に限られるし、削除したのが.gitフォルダの場合はこの方法では無理だ(代わりに下記外部ツールの利用参照)。いずれにせよそんな事態にならないのが一番

rmコマンドはinodeへのリンクを削除するがinodeは削除しないので、inodeへのリンクがあればデータは存在する。よってfileを扱ったプロセスがまだ存在すれば/proc/【プロセスID】/fd/を辿って復元出来る。

$ rm a.sh   # - fileを削除
$ lsof | grep "a.sh"   # - 削除したfileのプロセスIDを取得
less      8324        hoge    4r      REG  202,1        20 12592058 /home/hoge/a.sh (deleted)

1列目はプロセスに関連付けられたコマンドの名前、2列目がプロセスID、4列目の数字はファイル記述子である(”4r”の”r”は”regular file”、通常fileの意味)。プロセス8324がまだファイルを開いており(望みがある)、file記述子が4で通常アクセス出来るので/procからコピーする。

$ ls -l /proc/8324/fd/4
lr-x------  1 hoge hoge 64 Sep 21 22:31 /proc/8324/fd/4 -> /home/hoge/a.sh (deleted)

$ cp /proc/8324/fd/4 a.sh  # - cpコマンドで復元

上記で無理な場合は外部ツールに頼る方法もある。参考まで。

unskilled.site

【Git】実はファイルを差分でも保存する

Gitのファイル管理の続き

前回の記事でGitは作業ディレクトリ内のGit追跡fileを、staging及びcommit履歴に"blob"と呼ばれる圧縮独自フォーマットのfileで全て保存していることを説明した。

programmingforever.hatenablog.com

またその各file(blob)はsnapshot、つまり以前のfileとの変更点の差分保存ではなく、各fileの内容のまま(blobに形を変えた上で)全て保存していると説明した。

しかしそうなると、例えば1万行あるfileのたった1行だけを修正しても、非常によく似た大きなfile(blob)が2つ保存されてしまう。Git が最初のfile(blob)を保存するのは当然として、2つ目以降のfile(blob)は最初との差分(delta)のみを格納すれば効率良く保存出来るのにと、Gitが全てのfile(blob)をsnapshotとして保存するやり方に無駄を感じるのはごく自然だと思う。

Gitは条件成立時に差分保存を行う

実はその心配は無用だ。Gitはfileの差分保存を実に上手いやり方で実装している。編集作業中に行うgit addgit commitなどのコマンドを受けたGit がfile(blob)を保存する時は、確かに一つ一つのblobをsnapshotとして個々に保存している。これを緩いオブジェクトフォーマット(loose object format)と呼ぶ。

それに対して、規定または指定の条件が成立するとGitのgcガベージコレクション)が発動し、 Git はblobやtree,commitオブジェクトをpackfileと呼ばれるバイナリfileに差分単位で詰め込む作業を自動実行する(*設定による)。あるいは手動でgit gc コマンドを打つと実行する。

つまり、直近作業のfile(blob)は差分ではなくsnapshotの形で全て保存されるが、規定または指定の条件成立時にfile(blob)は差分管理で保存される、ハイブリッド制御が行われることを示す。

発動条件は色々あり、指定やパラメータ調整も出来る(設定fileの gc.auto と gc.autopacklimitなど)。なおgcの別機能である絶縁commitや不要なblobを自動削除する発動期間の設定とは別である。

では実際にgit gcコマンドを使って実証してみる。例題として前記事の通りの作業ディレクトリを新たに用意する。

ここで差分保存の効果を確認出来る様にa.shを100kバイト超の大きなテキストfileとし、No.3のcommitで1行だけ変更する。またb.sh, c.sh, d.shは全て空fileとしたため3fileのblob IDが同じになる点に注意(file内容が同一であれば誰がどこで作成しようとも必ず同じIDになる)

f:id:ProgrammingForEver:20210915192442j:plain

最初のcommit

最初にa.sh,b,sh,c,sh,d,shの4fileを用意して、a.shのみstaging&commitを行う。

$ ls -a
.       ..      .git    a.sh    b.sh    c.sh    d.sh
$ find .git/objects -type f
$ git add a.sh
$ git commit -m "最初のcommit"
[master (root-commit) ef11425] 最初のcommit
 1 file changed, 1775 insertions(+)
 create mode 100644 a.sh

ここでGitレポジトリのobject folderを覗くコマンド、及びツリーを見るコマンドでblob objectの SHA-1ハッシュ値が確認出来る。下の例ではa.shが.git/objects/57/9da06-----である事が分かる。

$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f

$ git cat-file -p master^{tree}
100644 blob 579da06934bd26a1a2f791021a989b95dfcd8a39    a.sh

そして、a.shつまり.git/objects/57の容量を確認すると88kバイトになっている事が分かる。

$ du .git/objects
88      .git/objects/57      # - これがa.sh
8       .git/objects/fc
0       .git/objects/pack
0       .git/objects/info
8       .git/objects/ef
104     .git/objects

$ 

以上でa.shをblobに格納されており、容量は88kバイトであることが確認出来た。]

2回目のcommit

次にb.shを加える2回目のcommitを行う。

$ git add b.sh
$ git commit -m "2番目のcommit"
[master 1999418] 2番目のcommit
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.sh
$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob 579da06934bd26a1a2f791021a989b95dfcd8a39    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh

$ du .git/objects
88      .git/objects/57
8       .git/objects/fc
0       .git/objects/pack
8       .git/objects/19
0       .git/objects/info
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
128     .git/objects

同様にb.shが追加されている事がわかる。なおb.shは空fileだがblobフォーマットの関係などで8kバイトの容量になる。

3回目のcommit

3回目のcommitでは、a.shの一行目に一文字だけの僅かな変更を加えた上で、a.shとc.shをcommitする

$ vi a.sh                      # - 一文字だけ加える
$ git add a.sh c.sh
$ git commit -m "3番目のcommit"
[master 8a20ac3] 3番目のcommit
 2 files changed, 1 insertion(+), 1 deletion(-)
 create mode 100644 c.sh

$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/d7/754295c833a6a8147c6bd22122d145d2f09b47
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/8a/20ac30c95d90fc1c92aaa7b433e91ffe7f8d8f
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/6e/9ab2c475344fa2243fc0b3a7a683143fe1eeb4
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh

$ du .git/objects
88      .git/objects/57     # - 最初のa.sh
88      .git/objects/d7     # - 一文字だけ修正したa.sh
8       .git/objects/fc
0       .git/objects/pack
8       .git/objects/8a
8       .git/objects/19
8       .git/objects/6e
0       .git/objects/info
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
232     .git/objects

上のgit/objects/57が最初のa.sh、.git/objects/d7が改訂したa.shで、共に88kバイトの容量で保存されている事がわかる。

4回目のcommit

続いて最後のcommitを行う。

$ git add d.sh
$ git commit -m "4番目のcommit"
[master cac41d4] 4番目のcommit
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 d.sh
$ find .git/objects -type f
.git/objects/57/9da06934bd26a1a2f791021a989b95dfcd8a39
.git/objects/d7/754295c833a6a8147c6bd22122d145d2f09b47
.git/objects/fc/0010737a9457dd224c2f778d430b3c293c2e89
.git/objects/ca/c41d483660425387f0488f0551df45f9c9e186
.git/objects/8a/20ac30c95d90fc1c92aaa7b433e91ffe7f8d8f
.git/objects/19/99418d81ddda2f848a3e91cdd65d7c211963a0
.git/objects/6e/9ab2c475344fa2243fc0b3a7a683143fe1eeb4
.git/objects/55/250e90d114ed645ba6a8ef4d3d5b8b8c99fd10
.git/objects/ef/11425c7169155b5c167dcfc3837c250613d12f
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/4a/e759d0ac93c9f59dc682cf3c606bef2f5ee25f

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    d.sh

$ du .git/objects
88      .git/objects/57
88      .git/objects/d7
8       .git/objects/fc
8       .git/objects/ca
0       .git/objects/pack
8       .git/objects/8a
8       .git/objects/19
8       .git/objects/6e
0       .git/objects/info
8       .git/objects/55
8       .git/objects/ef
8       .git/objects/e6
8       .git/objects/4a
248     .git/objects

これまでの4つのcommit の結果、合計11オブジェクト、容量はa.shの新旧2つ分の176kバイトにb.sh〜d.shの24kバイトを足した200kバイトになった。

git gcコマンド実行

以上を確認したら、手動でgit gcを実行する。

$ git gc
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 8 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), done.
Total 11 (delta 2), reused 0 (delta 0)
Computing commit graph generation numbers: 100% (4/4), done.

$ find .git/objects -type f
.git/objects/pack/pack-a4e0422cb92e03407ff3d178f35cd04ce7c568d3.idx
.git/objects/pack/pack-a4e0422cb92e03407ff3d178f35cd04ce7c568d3.pack
.git/objects/info/commit-graph
.git/objects/info/packs

上の通りpackfileにまとめられ、11個あったオブジェクトが4個に減少している。もちろん下記の様にfileとしていつでも取り出せる状態だ。

$ git cat-file -p master^{tree}
100644 blob d7754295c833a6a8147c6bd22122d145d2f09b47    a.sh
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.sh  # - 以下3fileは
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    c.sh  # - 同一内容なので
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    d.sh # - 同じIDになる

ここで容量を確認してみよう。すると.git/objects/packにまとめられたfileの容量はa.shの新旧2つ分の176kバイトにb.sh〜d.shの24kバイトを足した200kバイトから80kバイトに減少したのが分かる。

$ du .git/objects
80      .git/objects/pack
16      .git/objects/info
96      .git/objects
  • a.shの新旧file(blob)の重複内容が差分保存になり大幅に削減された
  • b.sh, c.sh, d.shの各fileは空fileなので単純にフォーマット用の8kバイトが削除された

まとめ

以上実証した通り、Gitは追跡対象のfile(blob)をsnapshotで保存するが、ある程度期間が経過したfile(blob)を自動で差分保存することでディスクの容量削減を実現する、優れたハイブリッド制御を行っている。

【Git】内部のファイル管理

Gitのfile管理方法

Gitは内部のfile管理のイメージを掴むと理解が一気に進む。そこで図とログを同時に扱ってGitが3大ツリーでfileをどう扱っているかなどを説明しgit resetの動きも理解出来る図を掲載した。

Gitの3大ツリーでのfileの扱い

3大ツリーでのfileの扱いはそれぞれ下記の様に異なっている。

  • 作業ディレクトリ(Working directory):編集作業を行うフォルダを指し、各fileはシェルやエディターで自由に扱える。fileはGitが追跡するfileとGitが追跡しない対象外のfileが混在する
  • staging(ステージング、index、インデックス):次のcommit候補一式(Gitが追跡する全file)を、blob(object)と称する圧縮独自フォーマットに変換して保管している
  • commit(コミット履歴):staging同様に各commit毎に、GItが追跡する全fileをblobで保管している。但し以前のcommitと変化が無いfileは参照Linkを用いることで同一fileの重複保存を回避している

blobはfileにヘッダーを追加してからSHA1でハッシュ化を行い、それらの情報を付与したfileをzlibで圧縮した上で保存する。SHA1は objectのIDとして利用出来るので、何らかのミスでfileを失ってもそのSHA1を手がかりに復活させる方法が存在する。

サンプル

理解のためにシンプルな構成を想定する(下図)。

  • 全部で4commit
  • 各commit毎にa.sh、b.sh、c.sh、d.shとfileを1つずつ追加する
  • 3番目のcommit前にa.shの内容を修正してからcommitする

f:id:ProgrammingForEver:20210915192442j:plain

これを用いて1つずつcommitを進めてみる。

1回目のcommitまで

a.shを追加し、staging&commitを行う。fileは作業ディレクトリに1個、stagingにblobに変化して1個、commitにもblobが1個、以上3個のfileが存在する。

注意:fileの扱いに特化して説明する関係上、treeおよびcommit objectは除外する

$ touch a.sh
$ git add .
$ git commit -m "最初のcommit"

f:id:ProgrammingForEver:20210915192914j:plain

stagingの確認:git ls-filesコマンドでstagingされているfile(blob)一覧が出る(--stageオプションを加えると各fileのID(SHA1)も確認出来る)。以下ではa.shがstagingされているのが見える。

$ git ls-files --stage
100644 579da06934bd26a1a2f791021a989b95dfcd8a39 0       a.sh

commitの確認:git logでcommitのID(SHA1)を確認後にgit show ID(SHA1) --name-onlyと打つと、そのcommitで保存されたfile(blob)一覧が出る。以下ではcommitにa.shが入っている事が分かる。

$ git log --oneline
ef11425 (HEAD -> master) 最初のcommit

$ git show ef11 --name-only
commit ef11425c7169155b5c167dcfc3837c250613d12f
Author: 
Date:   Sun Sep 19 15:33:23 2021 +0900

    最初のcommit

a.sh

2回目のcommitまで

b.shを追加し、staging&commitを行う。fileは作業ディレクトリに2個、stagingにblobに変化して2個、commitにもblobが2個、以上6個のfileが存在する。なお各commitにはGitの追跡fileが全て登録されるが、下図のa.shの様に前回のcommitから変更されていない同一内容のfileはそのfileを指し示すLinkを作成することで同一fileの重複保存を避けている。

$ touch b.sh
$ git add .
$ git commit -m "2回目のcommit"

f:id:ProgrammingForEver:20210915193354j:plain

同様にstagingにはa.shとb.sh、commitには新規追加したb.shだけが入っているのが分かる。

$ git ls-files --stage
100644 579da06934bd26a1a2f791021a989b95dfcd8a39 0       a.sh
100644 5d308e1d060b0c387d452cf4747f89ecb9935851 0       b.sh

$ git log --oneline
1999418 (HEAD -> master) 2番目のcommit
ef11425 最初のcommit

$ git show 1999 --name-only
commit 1999418d81ddda2f848a3e91cdd65d7c211963a0
Author: 
Date:   Sun Sep 19 15:46:49 2021 +0900

    2番目のcommit

b.sh

3回目のcommitまで

c.shを追加し、さらにa.shの内容を修正した上でstaging&commitを行う。fileは作業ディレクトリに3個、stagingにblobに変化して3個、そしてcommitにはblobが4個登録される(合計10個のfile)。これはGitがfileの差分管理ではなく、変更がある毎に別のfileとして保存するためで、図の様にa.shの変更前のblobがNo.1commit、a.shの変更後のblobがNo.3commitにそれぞれ保管されている。差分管理ではなく別fileとして管理する方式を取ったことで、branchやreset、rebaseなどの処理が容易に行える様になっている。

$ touch c.sh         # - c.shを追加
$ vi a.sh            # - a.shを編集
$ git add .
$ git commit -m "3回目のcommit"

f:id:ProgrammingForEver:20210915194422j:plain

a.shを変更、c.shを追加したのでstagingには3file(blob)が入る。なおa.shは内容が変更されたためSHA1ハッシュが違うIDに変更されている

commitは変更されたa.shと新規のc.shの2つのfile(blob)が保存されている。

$ git ls-files --stage
100644 d7754295c833a6a8147c6bd22122d145d2f09b47 0       a.sh
100644 5d308e1d060b0c387d452cf4747f89ecb9935851 0       b.sh
100644 61780798228d17af2d34fce4cfbdf35556832472 0       c.sh

$ git log --oneline
8a20ac3 (HEAD -> master) 3番目のcommit
1999418 2番目のcommit
ef11425 最初のcommit

$ git show 8a20 --name-only
commit 8a20ac30c95d90fc1c92aaa7b433e91ffe7f8d8f
Author: 
Date:   Sun Sep 19 15:50:03 2021 +0900

    3番目のcommit

a.sh
c.sh

4回目のcommitまで

d.shを追加してstaging&commitを行う。fileは作業ディレクトリに4個、stagingにblobが4個、commitにはblobが5個登録される(合計13個のfile)。

$ touch d.sh         # - d.shを追加
$ git add .
$ git commit -m "4回目のcommit"

f:id:ProgrammingForEver:20210915194612j:plain

以上4commitの結果、stagingには4file(blob)が入っている。またcommitは最終追加のd.shが保存されている。

$ git ls-files --stage
100644 d7754295c833a6a8147c6bd22122d145d2f09b47 0       a.sh
100644 5d308e1d060b0c387d452cf4747f89ecb9935851 0       b.sh
100644 61780798228d17af2d34fce4cfbdf35556832472 0       c.sh
100644 f2ad6c76f0115a6ba5b00456a849810e7ec0af20 0       d.sh

$ git log --oneline
cac41d4 (HEAD -> master) 4番目のcommit
8a20ac3 3番目のcommit
1999418 2番目のcommit
ef11425 最初のcommit

$ git show cac4 --name-only
commit cac41d483660425387f0488f0551df45f9c9e186 (HEAD -> master)
Author: 
Date:   Sun Sep 19 15:51:13 2021 +0900

    4番目のcommit

d.sh

fileの総数

ここで改めてfileの総数を数えると、作業ディレクトリに4個、stagingに4個(blob)、commit履歴に5個(blob)の合計13個になった。前記した通りa.shはcommit履歴のNo.1 commitに初期版、No.3 commitに修正版と内容が異なるfileが2つ存在している。

f:id:ProgrammingForEver:20210915195724j:plain

高効率なfile保存

開発では各commit毎に1つのfileを修正&commitするパターンが多く、仮に10個のfileで30commitある場合は、作業ディレクトリに10個、stagingに10個(blob)、commitに30個(blob)の合計50fileが存在する事になる。

仮に10fileの各大きさを10kバイトとした場合、10k*10=100kバイトの実容量に対して、blobの圧縮率を1/4とするとstagingとcommit履歴を足しても2.5k*(10+30)=100kバイト程度に収まり、スマートにcommit履歴を保存出来ている計算となる。

実はGitは一定期間が過ぎるとgit gcコマンドが自動で走って、file(blob)の各差分をバイナリfileにまとめる方法でより高圧縮に保存する。詳しくは下記の記事を参照して欲しい。

programmingforever.hatenablog.com

git resetを実行する

ここで2つ前のcommitまでresetすべくgit reset HEAD~2を実行してみる

$ git reset HEAD~2

HEADが移動して、No.2のcommit履歴からblobをstagingに戻す(copy)。a.shはLink先を辿ってNo.1のcommit履歴からblobを取り出す。resetは--mixedの動作なので作業ディレクトリは最新commitの状態のままだ。

f:id:ProgrammingForEver:20210915200005j:plain

git reset --hardを実行すると

上記を元に戻してから、今度は2つ前のcommitまでをreset --hardで実行してみる

$ git reset ORIG_HEAD    # - 元に戻す
$ git reset --hard HEAD~2

HEADが移動して、No.2のcommit履歴からblobをstagingに戻す(copy)。a.shはLink先を辿ってNo.1のcommit履歴からblobを取り出す。次にresetは--hardの動作なのでstagingに戻したblobを通常のfileに解凍した上で作業ディレクトリに展開する(作業ディレクトリは更新されるので、元の状態は破壊される点に注意)。

f:id:ProgrammingForEver:20210915200726j:plain

いずれもGitが差分管理ではなく、変更file管理方式を採用していることでresetなどのcommit編集が容易に行えることが分かる。

【Git】stashで退避する範囲

git stashの有効範囲を調べる

以前の記事でも記載した様にGitは、fileを6つの状態で扱う。

programmingforever.hatenablog.com

そこで、その内ローカル側の5つの状態のfileをgit stashコマンドでどの様に退避するのかをまとめた。但しcommitはstashの対象にならないため実際は以下の2から5までの状態に置いた4つのfileの退避を確認する。

各shell scriptの状態

説明のために5つのfileをそれぞれ下記の状態にして動作を追う。

  1. a.sh:fileをcommitした状態
  2. b.sh:fileを変更してstagingした状態
  3. c.sh:fileを変更してstagingしていない状態
  4. d.sh:fileをGitがまだ追跡していない状態(新規file等)
  5. e.sh:fileをGitが無視する状態(.gitignoreで指定したfile)

コマンドで退避前の各fileの状態を確認

$ ls    
a.sh    b.sh    c.sh    d.sh    e.sh   # - 各fileが見える 
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   b.sh     # - b.shはstaging中(変更あり)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   c.sh     # - c.shは変更あり 

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh                 # - d.shはGit追跡対象外、e.shは無視なので見えない

$           

図で退避される範囲を確認

f:id:ProgrammingForEver:20210913111049j:plain

git stash

Gitの追跡対象かつ変更したfile及びfolderを作業ディレクトリとstagingから退避し、作業ディレクトリとstagingをcommt直後に戻す。

git stash popで退避を戻すとstaging状態が解除されている。またstagingしたfileを追加修正してstagingしていない場合、stagingしたfileは消失して追加修正したfileが残る。

  1. 退避する(commit直後のstaging状態に戻る)
  2. 退避する(commit直後の作業ディレクトリ状態に戻る)
  3. 退避しない
  4. 退避しない
$ git stash         # - 退避を実行 
Saved working directory and index state WIP on main: 80afbdd test1
$ ls  
a.sh    b.sh    c.sh    d.sh    e.sh     # - b.sh, c.shの内容は変更前に戻っている 
$ git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh            # - staging中のb.sh、変更&staging前のc.shが消えた

nothing added to commit but untracked files present (use "git add" to track)

$ git stash show    # - b.shとc.shが退避されているのが見える
 b.sh | 2 +-
 c.sh | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

$ git stash pop     # - 退避を元に戻すと・・ 
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   b.sh   # - stagingが解除されている点に注意 
    modified:   c.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (ec403a9850501332a6bbc52a694c5473c97ba435)
$ git add b.sh      # - 必要に応じてstagingし直す 
$

git stash -k

-kオプションを付けると、Gitの追跡対象かつ変更したfile及びfolderを作業ディレクトリとstagingから退避し、作業ディレクトリはcommit直後に戻すが、staging状態は現状を維持する。

stagingしたfileを追加修正してstagingしていない場合は、stagingした内容と追加修正した内容が、git stash popで退避を戻す際にマージされるので対象fileを編集してconflictを解決する。

  1. 退避する(staging状態を維持する)
  2. 退避する(commit直後の作業ディレクトリ状態に戻る)
  3. 退避しない
  4. 退避しない
$ git stash -k         # - -kオプションを付けて実行
Saved working directory and index state WIP on main: 80afbdd test1
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   b.sh   # - stagingのb.shが残っている 

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh  # - 変更&staging前のc.shが隠され、追跡外のd.shが見える 

$ git stash show    # - b.shとc.shが退避されているのが見える
 b.sh | 2 +-
 c.sh | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

$ git stash pop        # - 退避を元に戻すとstash前と同じ状態
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   b.sh

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   c.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh

Dropped refs/stash@{0} (c976e74a49d3960940bd367e1da312d030cf75f9)
$

git stash -u

-uオプションを付けると、Gitの追跡対象かつ変更したfile及びfolderを作業ディレクトリとstagingから退避し、作業ディレクトリとstagingをcommt直後に戻す動作に加えて、

作業ディレクトリ内のGit追跡対象外のfile及びfolderも、作業ディレクトリとstaging内から退避して、popするまで作業ディレクトリから消失させる。但し.gitignoreで指定したfileは退避しない。空のfolderは削除されて戻らない。

  1. 退避する(commit直後のstaging状態に戻る)
  2. 退避する(commit直後の作業ディレクトリ状態に戻る)
  3. 退避する
  4. 退避しない
$ git stash -u            # - -uオプション付きで退避を実行 
Saved working directory and index state WIP on main: 80afbdd test1
$ ls  
a.sh    b.sh    c.sh    e.sh     # - Git追跡対象外fileのd.shが消えた 
$ git status
On branch main
nothing to commit, working tree clean  # - 何も変更がないと表示される
$ git stash pop           # - 退避を戻すと・・・
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   b.sh      # - stagingは外される 
    modified:   c.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (11ee1ab69090b3360d2f19bf97b9a646f575fc15)

$ ls
a.sh    b.sh    c.sh    d.sh    e.sh   # - 消えたd.shが復活   
$ git add b.sh            # - 必要に応じてstagingし直す 
$

git stash -a

-aオプションを付けると、Gitの追跡対象かつ変更したfile及びfolderを作業ディレクトリとstagingから退避し、作業ディレクトリとstagingをcommt直後に戻す動作に加えて、

作業ディレクトリ内のGit追跡対象外やGitに無視する様に指定したfile及びfolderも、作業ディレクトリとstaging内から退避して、popするまで作業ディレクトリから消失させる。空のfolderは削除されて戻らない。

  1. 退避する(commit直後のstaging状態に戻る)
  2. 退避する(commit直後の作業ディレクトリ状態に戻る)
  3. 退避する
  4. 退避する
$ git stash -a           # - -aオプション付きで退避を実行 
Saved working directory and index state WIP on main: 80afbdd test1
$ ls  
a.sh    b.sh    c.sh     # - Git追跡対象外のd.shと無視指定のe.shが消えた 
$ git status
On branch main
nothing to commit, working tree clean  # - 何も変更がないと表示される
$ git stash pop          # - 退避を戻すと・・・
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   b.sh     # - stagingは外される 
    modified:   c.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    d.sh

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (11ee1ab69090b3360d2f19bf97b9a646f575fc15)

$ ls
a.sh    b.sh    c.sh    d.sh    e.sh   # - 消えたd.sh, e.shが復活   
$ git add b.sh          # - 必要に応じてstagingし直す 
$

git stash -- files

git stash -- filesで指定fileだけを退避出来る。

git stash -p

git stash -pでハンク単位で退避出来る。

【Git】ファイルの6つの状態

Gitではfileが6つの状態に遷移する

Gitではいわゆる3大ツリーである、commit(commit履歴)、staging(ステージング、index、インデックス)、作業ディレクトリ(Working directory)を用いてfile管理を行なっている。

これをもう少し細かく見るとfileには以下の6つの状態がある事がわかる。

  1. fileをpushした状態
  2. fileをcommitした状態
  3. fileをstagingした状態
  4. fileをgitが追跡する状態(git addした事がある作業ディレクトリ内のfile)
  5. fileをgitが追跡しないが認識する状態(新規file等の作業ディレクトリ内のfile)
  6. fileをgitが無視する状態(追跡も認識もしない作業ディレクトリ内のfile)

この6つの状態と遷移を表にまとめ、以下丸数字に従って説明する。

f:id:ProgrammingForEver:20210913090355j:plain

①$ touch files →fileを作業ディレクトリに追加

touch,mvなどで作業ディレクトリに新規fileが追加された段階からGitはfileを認識する。

$ touch a.sh b.sh c.sh       # - a.sh, b.sh, c.shの3fileを新規作成
$ git status                 # - 状態を確認
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    a.sh                     # - 各fileを追跡対象外fileとして認識している
    b.sh
    c.sh

nothing added to commit but untracked files present (use "git add" to track)
$

②$ git add -N files →fileをgit追跡対象にするがstagingはしない

一般的にはgit add filesでfileをGitの追跡対象にすると同時にstagingするが、 git add -N filesを使うとGitの追跡対象にはするがstagingはしない。何のためにその様な状態にするのか?というと、stagingはまだしたくないが、新規作成したfileをGitの配下に置いて差分をgit diffでいつでも見たいといった運用が出来る。例えばgit add -N .と打つと、作業ディレクトリ内のfileをすべてGitの追跡対象に出来るので、差分を確認しながら編集を行い、整ったものからstagingが出来る。

逆動作になる後述の「⑦fileをGit追跡対象から外す」と合わせた図表を示す。

f:id:ProgrammingForEver:20210913091026j:plain

$ git add -N b.sh.          # - b.shをgit追跡対象にする(stagingはしない)
$ git status     
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    new file:   b.sh        # - 追跡対象になったがstagingされていない状態 

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    a.sh                    # - 追跡対象外fileとして認識中
    c.sh

no changes added to commit (use "git add" and/or "git commit -a")
$

③$ Vim files →Git追跡fileを編集する

上記①ではfileを新規作成したが、理解を深めるべく別のパターンとしてGit追跡fileを修正した場合を加える。後述の⑤の2つの図で違いが分かる

④$ git add files →fileをstagingに上げる

対象fileが上記③の様にGitの追跡対象ならgit add filesでstagingする。また対象fileがGitの追跡対象でない場合は追跡対象fileにした上でstagingする。次の⑤のresetコマンド動作と対になるので、fileが追跡対象外の時と追跡対象時のgit add filesコマンドの動作の違いをresetの項にある図表で確認して欲しい。

$ git add a.sh             # - a.shをstagingする
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   a.sh       # - 追跡対象になり、かつstagingされた

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    new file:   b.sh        # - 追跡対象になったがstagingされていない状態

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.sh                    # - 追跡対象外fileとして認識中
$

⑤$ git reset files →fileをstagingから下ろす

stagingしたfileを破棄する(unstaging)。作業ディレクトリ内のfileは変化しないが、対象fileの過去のcommitの有無で扱いが変わる。一度でもcommitした事があるfileは引き続きGitの追跡対象だが、commitした事が無ければGitの追跡対象外になる(addの逆動作)。

以下、対動作となる「③fileをstagingに上げる」も記載した図表を、対象fileのcommit実績の有無別で示す。

fileがcommit実績ある場合:unstagingだけを実施

f:id:ProgrammingForEver:20210913092725j:plain

fileがcommit実績ない場合:unstagingして、Gitの追跡対象からも外す(⑤+⑦の動作)

f:id:ProgrammingForEver:20210913092544j:plain

$git reset a.sh              # - a.shのstagingを取り消す
Unstaged changes after reset:
A   b.sh
$ git status
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   a.sh         # - 1回はcommitしたfileは引き続きgitの追跡対象
    new file:   b.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    a.sh                     # - 未commitのfileはgitの追跡対象外になる 
    c.sh

no changes added to commit (use "git add" and/or "git commit -a")
$

⑥$ git checkout files →fileをstagingから作業ディレクトリに戻す

上記⑤のstagingのfile破棄とは異なり、stagingのfileを作業ディレクトリにCOPYする動作。以下の説明は、a.shをstaging後に追加編集した状態から、その追加編集を取り消してstagingの内容に戻す所までを示す。

% git status              # - staging後にa.shを追加編集した直後
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   a.sh

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   a.sh      # - 変更された表示になる
    new file:   b.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.sh

$ git checkout a.sh       # - stagingの内容に戻すコマンド
Updated 1 path from the index
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   a.sh

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    new file:   b.sh      # - 変更したa.shが取り下げられて消えた

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.sh

$

⑦$ git rm --cached files →fileをgit追跡対象から外す

git rmはfileを削除するコマンドだが、 git rm --cashed filesコマンドを使うとfileを残したままGitの追跡対象から外せる。外されたfileは引き続きgitに認識されるので、⑨で説明するfileをGitの認識対象から外す操作が必要になる。

これも対動作になる「②fileをGit追跡対象にする(stagingはしない)」と「⑨fileをGitが認識する/無視する」と合わせた図表で示す。

f:id:ProgrammingForEver:20210913094105j:plain

$ git rm --cached b.sh        # - 上の状態からb.shをgitの追跡対象から外す
rm 'b.sh'
$ git status          
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   a.sh

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    b.sh                      # - b.shがgitの追跡対象から外れた(認識は継続)
    c.sh

$

⑧$ git rm files →fileを削除

git rm filesでGit追跡対象fileを削除する。commitされていないfileは失うためエラーになるが強制的な削除も可能(git rm -f files)。 なおGitの追跡対象外のfileをこのコマンドで削除しようとしても(Gitの働きが及ばないfileのため)エラーになるのでその場合はシェルのrm filesで削除する。

f:id:ProgrammingForEver:20210913095112j:plain

$ git rm a.sh            # - staging中のa.shを削除しようとすると・・
error: the following file has changes staged in the index:
    a.sh                 # - 変更された内容を失わない様にエラーで中断
(use --cached to keep the file, or -f to force removal)
$
$ git rm -f a.sh         # - 今度は強制オプションを付けて削除する 
rm 'a.sh'                # - 削除された 
$
$ git rm b.sh            # - gitの追跡対象外のfileを削除しようとすると・・ 
fatal: pathspec 'b.sh' did not match any files    # - エラーで中断 
$ rm b.sh                # - 代わりにシェルのrmコマンドなら削除出来る
$ 

⑨$ Vim .gitignore →fileをGitが認識する/無視する

.gitignoreにfile名を登録すると認識対象から外され無視される。その登録を抹消すると再び認識対象になる。なお一旦Gitの追跡対象になったfileはここに記載しただけではGitの追跡対象から外れないので、⑦で記載した方法でGitの追跡対象から外す必要がある。改めて⑦で示した図表を再掲する

f:id:ProgrammingForEver:20210913094105j:plain

⑩$ git commit →fileをcommit

git commitでfileをcommitする

$ git commit