Gitの使い方(応用編)

Gitトラブルシューティング

Gitを使っていると、たまに「しまった!」と思うことがある。Gitに慣れていないとトラブルが起きた時に何が起きたかわからず、適切に対処することが難しい。以下ではありがちなトラブルとその対処について説明する。

コミットメッセージを間違えた(git commit --amend)

Gitはコミットの際にメッセージをつけることが必須である。ちゃんとエディタで書く人もいるだろうが、コマンドラインからgit commit -mでメッセージを書いてしまうことが多いだろう。その際、コミットの後に「あ!打ち間違えた!」と思うことがある。

たとえばtest.txtを修正し、git addgit commitしたとしよう。

git add test.txt
git commit -m "updaets test.txt"

そしてコミット直後に「あ!updatesを打ち間違えている!」と気づくが、すでに歴史に間違いが刻まれてしまった。

$ git log --oneline
8f7d4f8 (HEAD -> main) updaets test.txt
78efaf0 initial commit

このままではとてもかっこ悪い歴史が残ってしまう。そこで、git commit --amendを実行することで、直前のコミットのコミットメッセージを修正することができる。そのまま実行するとエディタが開くが、-mも指定してメッセージを上書きするのが楽であろう。

git commit --amend -m "updates test.txt"

歴史を確認しよう。

$ git log --oneline
52304ef (HEAD -> main) updates test.txt
78efaf0 initial commit

無事にコミットメッセージが書き換えられた。

なお、ここで先ほどとコミットハッシュが変わっている(8f7d4f852304ef)ことに注意したい。git commit --amendによりコミットメッセージを修正すると、コミットハッシュが変わってしまう。git rebaseの時と同様に歴史がおかしくなるため、git pushした後にはgit commit --amendを実行してはならない1

修正を取り消したい(git restore)

ファイルを修正したが、その修正をなかったことにしたい、ということがある。例えば、最後にコミットした状態からtest.txtに修正が加えられたとしよう。git diffはこうなっている。

$ git diff
diff --git a/test.txt b/test.txt
index e965047..4f34f18 100644
--- a/test.txt
+++ b/test.txt
@@ -1 +1,2 @@
 Hello
+Modification to be undone

「Modification to be undone」という行が追加されている。これを取り消すには、git restore ファイル名とする。

git restore test.txt

これにより、test.txtは最後にコミットした状態に戻る。なお、git restoreはオプションを指定しなかった場合--worktreeが付く。これはワーキングツリーのファイルを修正する。--worktree-Wでも良い。

ステージングを取り消したい(git restore --staged)

先ほどの修正をした後、git addまでした状態を考える。

$ git diff --staged
diff --git a/test.txt b/test.txt
index e965047..5c936d2 100644
--- a/test.txt
+++ b/test.txt
@@ -1 +1,2 @@
 Hello
+Modification to be undone

ステージングした状態を取り消すにはgit restore --staged ファイル名とする。

git restore --staged test.txt

これで、先ほどの「最後のコミットからワーキングツリーのみ修正された状態」に戻る。--staged-Sでも良い。

あまりないと思うが、ワーキングツリーとインデックス両方に修正がある場合は-W -Sで両方一度に取り消すことができる。

$ git status -s
MM test.txt      # ワーキングツリーとインデックス両方に修正がある

$ git restore -W -S test.txt # 両方一度に取り消し

git checkoutは使わない

git switchgit restoreはGitのバージョン2.23.0から追加された機能であり、それまではgit checkoutgit resetがその役目を担っていた。

例えば以下は同じ意味だ。

git checkout feature
git switch feature

また、ステージングされていないファイルの修正もgit checkoutでできる。以下は同じ意味だ。

git checkout test.txt
git restore test.txt

もともと、git checkoutに役目が多すぎたためにコマンドが分けられた背景がある。現在、git checkoutを使う必要はほとんどない。また、git switchと異なり、git checkoutは直接コミットハッシュを指定することができる。

例えば、いまカレントブランチがmainであり、コミット9b662efを指している状態であるとしよう。

$ git log --oneline
9b662ef (HEAD -> main) test

この状態で9b662efを指定してgit checkoutすると、HEADがブランチではなく、直接コミットハッシュを指す「detached HEAD」状態となる。

$ git checkout 9b662ef
Note: switching to '9b662ef'.

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 9b662ef test

ブランチを介さないでGitを操作するのは事故のもとである。一方、git switchは直接コミットを指定することはできず、コミットハッシュとブランチ名を同時に指定する必要がある。

$ git switch -c newbranch 9b662ef
Switched to a new branch 'newbranch'

したがって、git checkoutの代わりにgit switchを使った方が良い。同様な理由でファイルの修正を元に戻すのもgit restoreを使った方が良い。古い本やサイトには、まだgit checkoutを使う方法が説明されていたりするので注意が必要だ。

リモートを間違えて登録した(git remote remove)

GitHubを使っていて、リモートリポジトリのアドレスを間違えることがよくある。例えば、GitHubで新しいリポジトリを作り、そこに既存のリポジトリをプッシュしようとして、

git remote add origin https://github.com/appi-github/somerepository.git
git branch -M main
git push -u origin main

を実行してusernameを聞かれ、「あっ!SSHのつもりがHTTPSを選んじゃった」と気が付いた時だ。ここで、改めて

git remote add origin git@github.com:appi-github/somerepository.git

と、SSHで再登録しようとしても、「error: remote origin already exists.」とつれない返事が返ってくる。この時、まずoriginとして登録されたリモートを削除してから再登録すれば良い。

git remote remove origin

これで、リモートリポジトリoriginは削除されたので、改めてSSHプロトコルで再登録すれば良い。

git remote add origin git@github.com:appi-github/somerepository.git

メインブランチで作業を開始してしまった(git stash)

Gitでは原則としてメインブランチでは作業せず、必ずフィーチャーブランチを切って作業する。ところが、ファイルを修正した後で「あっ!メインブランチで作業してた!」と気が付いたとしよう。そんな時はgit stashを使う。git stashはコミットを作らずに変更を退避するコマンドだ。

今、mainブランチにいるままtest.txtを結構修正してしまった状態にある。

$ 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:   test.txt

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

この状態でgit stashを実行すると、最後のコミットからの修正が退避される。

$ git stash  # 修正が退避される
Saved working directory and index state WIP on main: 57222d5 update

$ git status # カレントブランチはきれいな状態に戻る
On branch main
nothing to commit, working tree clean

git stashはスタックになっており、どんどん修正を積み上げることができる。積み上げた修正はgit stash listで見ることができる。

$ git stash list
stash@{0}: WIP on main: 57222d5 update

積んだ修正はgit stash popで適用できる。新しいブランチを切ってから適用しよう。

$ git switch -c feature
Switched to a new branch 'feature'

$ git stash apply pop
On branch feature
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:   test.txt

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

これにより、あたかも「最初からfeatureブランチを切ってから修正をした」ような状態となった。あとはキリの良いところまで作業してコミットし、mainブランチにマージするなりその前にリベースするなりすれば良い。うっかりメインブランチで作業を開始しがちな人(例えば私)は覚えておきたいコマンドだ。

なお、git stashを実行するたびに修正が積みあがっていく。それぞれにstash@{0}stash@{1}という名前がつき、git stash applyにより名前を指定して適用することもできる。しかし、その場合は適用した修正がスタックに残るため、後でgit stash dropで消さなければならない。一方、git stash popは、最後に積んだ修正を適用し、その修正をスタックから削除する。

あまり積むと後で見てわからなくなるので、原則としてgit stashgit stash popと対で利用すると良い。

プッシュしようとしたらリジェクトされた

あなたは家で作業をして、一段落したのでコミット、プッシュしてから寝ようとしたら、無情にもrejectedというメッセージが出て拒否された。

$ git push
To /URL/to/repository.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to '/URL/to/test.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

そこであなたは、大学で修正をプッシュしたのに、家のリポジトリでgit fetchgit mergeするのを忘れていたことに気が付く。もしプロジェクトがバージョン管理されておらず、プッシュではなく単に大学のサーバにアップロードをしていたら、大学での修正は失われてしまっていたかもしれない。しかし、幸運なことにあなたはGitを使っており、大学で行った修正がGitHubに、家で行った修正がローカルにある。この状態で、まず学校の修正をローカルに持ってこよう。

git fetch

これにより、ローカルのorigin/mainが大学で行った作業を反映したコミットを指すようになった。ローカルのmainと、origin/mainは、同じコミットから歴史が分岐した状態だ。これを一つにするにはマージすれば良い。

git merge origin/main

もし衝突したら、適切に修正してgit addgit commitすれば良い。これで両方の修正を取り込んだ新たな歴史ができた。この歴史は、リモートのmainと歴史を共有しているので、そのままgit pushができる。

git push # 問題なく実行できる

家と大学など、複数の場所で開発を進めることはよくあるであろう。その時、一方でpushを忘れてしまったり、fetch/mergeするのを忘れてコミットしてしまったりすると、git pushができずエラーが起きる。その場合は、慌てずにgit fetchgit merge origin/mainしてからgit pushすれば良い。

頭が取れた(detached HEAD)

通常、ブランチがコミットを指し、HEADがブランチを指すことで「カレントブランチ」を表現している。例えば適当なリポジトリでgit log --onelineを実行すると、

$ git log --online
fe81057 (HEAD -> main) updates from test2
4692a78 initial commit

などと表示される。これは、HEADmainブランチを指しており(カレントブランチがmainであり)、mainブランチはfe81057というコミットを指している状態だ。これにより、HEADmainを経由してコミットを指している。

しかし、Gitの操作の途中、HEADがブランチを経由せずにコミットを直接指している状態になることがある。これをdetached HEAD状態と呼ぶ。

例えば先ほどの状態でgit checkout fe81057を実行すると、git statusでこんな表示が出るようになる。

HEAD detached at fe81057
nothing to commit, working tree clean

これは、頭が取れた(detached HEAD)状態であり、HEADが直接コミットfe81057を指しているよ、という意味だ。git log --onelineはこんな表示になる。

$ git log --oneline
fe81057 (HEAD, main) updates from test2
4692a78 initial commit

先ほどはHEAD -> mainと、HEADmainを指していたが、いまはHEADmainが個別にコミットfe81057を指している状態であることがわかるであろう。

Gitでは、例えば以下の操作で頭が取れる。

git checkoutを直接使うことはあまりないであろう。git rebase中の衝突については後述する。git bisectの最中によくわからなくなったら、git bisect resetを実行してbisectから抜けよう。

それ以外で、「なんだかよくわからないが頭が取れてしまった」という状態になったら、まずはいまHEADが指しているコミットにブランチを作って貼っておこう。

$ git status
HEAD detached at 4692a78
nothing to commit, working tree clean

いま、頭が取れて、HEAD4692a78を指した状態だ。なぜこの状態になったかがよくわからないとしよう。ならば、後でこの状態に戻ってこられるように、ブランチを付けておこう。

git branch 20210918_detached_head

これで、4692a7820210918_detached_headというブランチがついた。この状態でmainブランチに戻る。

git switch main

ブランチを見てみよう。

$ git branch
  20210918_detached_head
* main

先ほど頭が取れた状態でHEADが指していたコミットに20210918_detached_headというブランチがついている。しばらくそのままにしておいて、不要だと思えば削除すれば良いだろう。ブランチをつけずにmainに戻ると、先ほどのコミットハッシュ4692a78を覚えていない限り、頭が取れた状態に戻ることはできなくなる。「理由もわからず頭が取れてよくわからない状態になったら、ブランチをつけてmainに戻る」と覚えておけばよい。

リベースしようとしたら衝突した

リベースとは、リベース先にしたいブランチ(例えばmain)と、現在のブランチ(例えばfeature)について、共通祖先からfeatureまでの修正をmainに順番に適用して、できた最後のブランチにfeatureブランチを移動させる操作だ。一度だけマージが行われるgit mergeとは異なり、パッチの数だけマージ作業が行われる。したがって、パッチの数だけ衝突の可能性がある。また、リベース中に衝突すると、いわゆる頭が取れた(detached HEAD)状態になるために焦りがちだ。その時、「リベースは、修正を次々と適用して新しいコミットを作っている」という感覚を持つと対応がイメージしやすい。

rebase_conflict

いま、上図のような状況を考えよう。rootというコミットから、mainbranchに歴史が分岐している。ここでbranchからmainに対してgit rebaseを実行する。

すると、rootからf1f1からf2といった「修正パッチ」を、現在mainが指しているm3に適用し、新たにf1'f2'f3'というコミットを作ろうとする。

しかしいま、f1'を作り、次にf2'を作ろうとしたところで衝突が起きてしまった。

$ git rebase main
Auto-merging test.txt
CONFLICT (content): Merge conflict in test.txt
error: 99a8712を適用できませんでした... f2
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 99a8712... f2

リベース中に衝突が起きると、detached HEADとなるため、例えばGit Bashなどでカレントブランチを表示している状態にしていても、ブランチが表示されなくなる。

現在の状態を見てみよう。

$ git status
interactive rebase in progress; onto 3152c68
Last commands done (2 commands done):
   pick 1c8a63e f1
   pick 99a8712 f2
Next command to do (1 remaining command):
   pick 6aa5661 f3
  (use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'branch' on '3152c68'.
  (fix conflicts and then run "git rebase --continue")
  (このパッチをスキップするには"git rebase --skip"を使用してください)
  (use "git rebase --abort" to check out the original branch)

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
        both modified:   test.txt

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

大量の情報があるが、

などが書いてある。繰り返しになるが、Gitのメッセージは非常にていねいかつ有用なので、「ちゃんと読む」のがGit上達の近道だ。

さて、先ほどの図を見るとわかる通り、このリベースで作ろうとしているコミットは3つだ。そのうち二つ目のf2'を作るところで止まっている。したがって今やるべきことは、f2'のあるべき姿をインデックスに再現してコミットし、リベースを続行することだ。

衝突が起きているファイルtest.txtを確認し、両方の修正を取り込んだら、git addgit commitしよう。

$ git add test.txt
$ git commit -m "merge"
[detached HEAD 10876ee] merge
 1 file changed, 1 insertion(+), 1 deletion(-)

「頭が取れているよ (detached HEAD)」という警告が出るが気にしなくて良い。この状態でgit rebase --continueを実行すれば、無事にリベースが完了し、HEADbranchを指してdetached HEADが解消する。

$ git rebase --continue
Successfully rebased and updated refs/heads/branch.

「Gitのリベースとは、共通祖先からの修正をリベース先に次々と適用して、新しいコミットを作っていくこと」だと理解していれば、なぜ衝突が複数回起きる可能性があるのか、なぜgit addが衝突解決をGitに教えたことになるのかが理解しやすいであろう。git addは衝突解決をGitに教えるコマンドではなく、あくまでもインデックスに修正をステージするコマンドであり、リベース中のステージングは、「次に作るべきコミットの姿をGitに教える」という役割を果たしている。

Gitのリベースは理解が難しいため、特に個人開発の場合は、慣れるまではgit mergeを使う、という運用で良い。Gitに慣れ、後でデバッグする時に歴史が整理されていないのが気になりはじめところでgit rebaseを使い始めると良いだろう。

その他の便利なコマンド

この部分はいつ誰が書いた?(git blame)

多人数開発をしていると、頻繁に「この部分はいつ誰が書いたんだ?」と思うことであろう。個人開発をしていてもたまに「これ誰が書いたんだよ!」と思うことが多い(もちろん自分である)。そんな時に便利なコマンドがgit blameだ。

いま、こんなPythonスクリプトがあったとしよう。

def func1():
    print("Hello func1")


def func2():
    print("Hello func2")


if __name__ == '__main__':
    print("Hello")
    func1()
    func2()

git blameにファイル名を指定すると、どの行が、いつ、誰によって修正されたかが表示される。

$ git blame test.py
56127fbb (H. Watanabe 2021-09-17 21:22:49 +0900  1) def func1():
56127fbb (H. Watanabe 2021-09-17 21:22:49 +0900  2)     print("Hello func1")
56127fbb (H. Watanabe 2021-09-17 21:22:49 +0900  3)
56127fbb (H. Watanabe 2021-09-17 21:22:49 +0900  4)
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900  5) def func2():
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900  6)     print("Hello func2")
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900  7)
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900  8)
^fea5775 (H. Watanabe 2021-09-17 21:22:08 +0900  9) if __name__ == '__main__':
^fea5775 (H. Watanabe 2021-09-17 21:22:08 +0900 10)     print("Hello")
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900 11)     func1()
26bdec20 (H. Watanabe 2021-09-17 21:23:31 +0900 12)     func2()

これを見れば、func1func2がいつ作られたかがわかる。git blameには行を指定したり、コミットハッシュを指定したりするなど多くのオプションがあるが、エディタや統合環境と一緒に使うことがほとんどであろう。

個人開発でバグに気が付いた時、どの関数がどの順番で作られたかは非常に有用な情報なので、個人開発でも役に立つ。

このバグが入ったのはいつだ?(git bisect)

プログラムをずっと開発していて、ある時にバグに気が付いたとする。最近入れたバグならデバッグは比較的容易だが、ずいぶん前に入れてしまったバグが今になって顕在化した場合はやっかいだ。三日前の自分は全くの他人であり、そのバグの振る舞いからどこでどういう経緯でバグが入ったかをすぐに特定することは難しいであろう。しかし、少なくとも昔はバグが入っていなかった時があり、現在はバグっているのだから、どこかに「バグが混入したコミット」が存在するはずだ。これを二分探索で調べるためのコマンドがgit bisectである。

今、バグが入ったことが気が付いたブランチがある。例えばカレントブランチであるmainが指しているコミットはバグっているとしよう。そして、適当に探した昔のコミットe34d733はバグってなかったことが確認できたとしよう。バグはこの二つのコミットの間にある。二分探索を開始しよう。git bisect start 問題のある場所 問題のない場所を実行する。場所はコミットハッシュやブランチで指定できる。

git bisect start main e34d733

これによりGitは二分探索モードとなり、まずは適当なコミットを持ってくる。このコミットがバグっているかどうかGitに教えてやろう。もしバグっていたら

git bisect bad

もし問題がなければ

git bisect good

を実行する。その度にGitは問題の範囲を狭めていき、最終的にバグが混入したコミットを見つけてくれる。

$ git bisect bad
e6348e408b57fdb42eb1281cb77b5c331cd400e7 is the first bad commit
(snip)

上記は、最後にgit bisect badを実行したら、それによりGitが問題箇所を特定し、e6348e4が問題の入ったコミットだよ、と教えてくれた。ここでgit diffを取ったりいろいろできるが、とりあえず発見されたコミットに「バグった印」としてブランチをつけて置くと良い。

git branch bug e6348e4

これで、問題の入ったコミットにbugというブランチがついた。二分探索モードを抜けよう。

git bisect reset

後は先ほどつけたbugの時点にgit switchで戻って詳細を調べれば良い。特に、ここで初めてバグが入ったのだから、一つ前のコミットとの差分が重要な情報となるだろう。

git switch bug
git diff HEAD^

今回は手動でgood/bad判定をしたが、判定を自動実行するシェルスクリプトが書けるなら、上記の動作を自動化できる。例えば現在のリポジトリの状態に対して、問題がなければ成功(終了ステータス0を返す)、問題があれば失敗(終了ステータス1を返す)ようなtest.shというシェルスクリプトがあるなら、

git bisect start main e34d733
git bisect run ./test.sh

git bisect runコマンドにそのスクリプトを渡すだけで自動的に二分探索してくれる。一般に二分探索は非常に効率が良く、数回も実行すれば問題のコミットを特定できるが、いちいちgit bisect good/badと入力するのも面倒だし、また人力だと間違えることもあるので、可能なら自動化したい。これらについては後に演習で実際に体験する。

まとめ

Gitで直面しがちなトラブルとその対処法や、知っていると便利なコマンドについて紹介した。Gitのコマンドが実際に何をやっているかを理解していないと、トラブルの対処が難しい。単に「こういう場合はこうすれば良い」と場当たり的な対処を覚えるのではなく、「いまこういう状態で」「ここを解決したいのでこのコマンドを使っている」といったイメージを大事にして欲しい。


  1. 個人開発であれば強制プッシュ(git push -f)するという手もあるが、GitHubに強制プッシュの履歴が残り、やはりあまりかっこよくない。そもそもmainブランチで作業するのがよくないため、常に別ブランチで作業するようにして、mainブランチにリベースしてコミットやメッセージを整理してからマージする習慣をつけたい。↩︎