Git Reset・Revert・Rebase を実際に叩いて覚えてみた

git resetgit revertgit rebase といった、過去のコミットを操作するコマンドを実際に叩いて勉強した結果を残す。

2022-04-14 : 今になって見返してみると、特にチーム開発時に迷惑をかけかねないポイントもあったので、要所要所で追記しています。

目次

git init … お試しブランチを作る

# 適当な作業用ディレクトリを作る
$ mkdir Test
$ cd Test
# Git でのバージョン管理を始める
$ git init
# master ブランチを作成する
$ git checkout -b master

これで適当なディレクトリにローカルブランチを作れた。以降はこのローカルブランチ内でアレコレ叩くだけなので、どこにも影響ない。気が済んだら「Test」ディレクトリごと消してしまえばいい。

git reset … 過去のコミットを削除してなかったことにする

# 適当にテキストファイルを作ったり変更したりしてコミット履歴をいくつか作る。

# ローカルブランチでこういう状態だとして、以下を進める。
$ git log --oneline
2297cd1 2017-03-08 Edit 2 [Neos21] (HEAD -> master)
8a15f33 2017-03-08 Edit 1 [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

git reset は過去のコミットを削除し、そのコミットをなかったことにできる。

$ git reset --soft HEAD^

これで1つ前のコミット (= 先程のコミット履歴でいうと「Edit 2」) が削除され、そのコミットの内容が取り消される

--soft オプションを指定しているので、「Edit 2」にコミットしていた分のファイルの変更は保持され、現在のローカルブランチ内に残っている。直後に git status を確認すると、「Edit 2」でコミットしていたはずの内容が差分として見える。

このとき、未コミット状態となるファイルは git add した状態になっているので、このまままた git commit すれば、「Edit 2」と同じ内容を再度コミットできる。

つまり、git reset --soft で直前のコミットを削除すれば、コミットコメントだけ直して再コミットしたり、コミット漏れしていたファイルを追加で git add して再コミットしたり、といった操作ができる。

一方、--hard オプションを付けてやると、戻したコミット時点のファイルたちに書き換えられる。つまり「Edit 2」でコミットしていたファイルの変更は全て破棄され、「Edit 1」をコミットした直後の時点に完全に戻る。

--soft--hard は取り消したコミットの変更内容をステージングに保持するか否かの違いだけで、どちらの場合もコミット自体は削除されるので、以下のようなログになっているはずである。

# git reset したあとは以下のようになっているはず
$ git log --oneline
8a15f33 2017-03-08 Edit 1 [Neos21] (HEAD -> master)
d5dc6fe 2017-03-08 First Commit [Neos21]

別のやり方を紹介。先程と同じ「Edit 2」までコミットしていた状態だとして…。

# またこの状態にしたとして。
$ git log --oneline
2297cd1 2017-03-08 Edit 2 [Neos21] (HEAD -> master)
8a15f33 2017-03-08 Edit 1 [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

# 「Edit 1」のコミット ID を書く
$ git reset --soft 8a15f33

このようにコミット ID を指定してやることでも、「Edit 2」を取り消した同じ状態にできる。「8a15f33」は「Edit 1」のハッシュなので、「Edit 1」のコミット直後の時点まで戻す = 「Edit 2」のコミットを削除する、という動きになる。

「Edit 2」のコミットを消そうとして、「Edit 2」自体のコミット ID を入力してもうまくいかないので注意。「Edit 2」は「HEAD」自体なので、「1つ前のコミット『Edit 1』のコミット ID を指定する」=「『HEAD^』で1つ前のコミットを指定する」と覚えよう。

直前より以前のコミットを指定したらどうなる?

「Edit 2」までのコミットがある状態で、その2つ前の「First Commit」のコミットまで戻すとどうなるか。

# またこの状態にしたとして。
$ git log --oneline
2297cd1 2017-03-08 Edit 2 [Neos21] (HEAD -> master)
8a15f33 2017-03-08 Edit 1 [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

# 以下の2つのコマンドは同じ動きをする

# 2つ前のコミット (=「First Commit」) まで戻す
$ git reset --soft HEAD^^

# 2つ前のコミットのコミット ID (=「First Commit」のコミット ID) を指定してそこまで戻す
$ git reset --soft d5dc6fe

このようにすると、「Edit 2」と「Edit 1」のコミットがなかったことにされている。--soft 指定の時はステージングに「Edit 2」と「Edit 1」でそれぞれコミットしていたファイルが混在した状態になる。

# 2つ前のコミットまで戻すとこんなコミットログになる
$ git log --oneline
d5dc6fe 2017-03-08 First Commit [Neos21]

git reset はココまで。

2022-04-14 : git reset は、一度作ったコミットを削除してなかったことにする操作なので、リモートに Push した後のコミットを削除するような使い方は原則すべきでない。一度したコミットを何らかの理由で取り消したい場合は、git reset ではなく、後述する git revert が適している。

リモートに Push する前の feature ブランチなんかで、誤操作によりコミットしてしまったモノを取り消す、といった使い方であれば git reset でも問題ない。また、コミット前の操作を全て破棄する目的で、$ git reset HEAD といった使い方はアリ。

パスワードなどのクレデンシャル情報を誤ってリモートに Push してしまった場合は、git revert による取り消しコミットではクレデンシャル情報を削除できないので、こういう場合は仕方なく git reset して、強制的に Push することでリモートのコミット情報を書き換えて完全削除できる。この場合、チームメンバには git pull をうまくやり直してもらわないと、ローカルにあるコミット履歴とのズレが生じて正常に Pull できないので、作業する前にはチームメンバ全員に周知してから、慎重に行うべきである。

git revert … リセットしたコミットを残す

git reset はコミット自体が削除されるのに対し、git revert は「あるコミットの内容を取り消して、その前の時点に戻しましたよ」というコミットを新たに作り出す。

「戻したい時点のソースコードの状態に手動で戻して、変更を相殺するためのコミットを打つ」というのと同じことをしてくれる。

特にチーム開発などしている時に、「あのコミットを取りやめた」という履歴を残すために使えるだろう。

git revert --no-commit とすると、「戻したい時点のソースコードの状態に手動で戻して、」という部分を自動的にやってくれる。つまり、相殺する差分だけを作ってくれる。

2022-04-14 : git reset でも若干前述したとおり、あるコミットを取り消すための操作が git revert である。Git に限らず、ファイルの変更履歴管理は時系列的に一直線に進むモノで、本来は「過去のコミット」自体に干渉するような操作はしてはならない。「以前の状態に戻したい」という時に、git reset で直接コミットを削除して戻るのではなく、「あの時のコミットと同じ状態に戻す変更を加えた」という新たなコミットを打ち、時系列は先に進ませるのみ、という使い方が適切である。

なので、基本的にはこの git revert の使い方だけ覚えればよく、git reset は緊急時、後述の git rebase は作業中にやれれば使う、程度の覚え方で良いだろう。

git rebase … コミットログを詳細に変更する

今度は例のために「Edit 3」までのコミット履歴を作った。

# コミット履歴がこんな状態だとして。
$ git log --oneline
657475f 2017-03-08 Edit 3 [Neos21] (HEAD -> master)
ec65417 2017-03-08 Edit 2 [Neos21]
a87e4d1 2017-03-08 Edit 1 [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

git rebase はコマンドライン上で対話形式で過去のコミットを操作できるコミット自体の削除だけでなく、コミット内容の変更やコミットコメントの訂正だけなど、様々な操作ができる。

ここでは、上のコミットログのうち、「Edit 1」のコミット内容を修正し、それ以外のコミットはそのまま保持するといったことをやってみようと思う。

「Edit 1」のコミットを修正するには、その手前のコミット「First Commit」のハッシュを選択する。

# 「First Commit」のハッシュを指定する
$ git rebase -i d5dc6fe

するとテキストエディタ (たいていは Vim) が開き、以下のような内容が表示される。

pick a87e4d1 Edit 1
pick ec65417 Edit 2
pick 657475f Edit 3

# Rebase d5dc6fe..657475f onto d5dc6fe (3 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

最初の「pick」から始まる3行が、コミットログを操作するための場所で、それ以下はコメントなので無視。この画面では、「どのコミットに何の操作をするのか」をテキスト編集によって指定する

今回は「Edit 1」のコミットコメントを変えようと思うので、以下のように「Edit 1」の「pick」部分を「reword」に書き換える。Vim の操作なので、「a」で挿入モードにして書き換える。

reword a87e4d1 Edit 1
pick ec65417 Edit 2
pick 657475f Edit 3

書き換えたら「Esc:wq」で閉じる。するとまた Vim が起動し、以下のような内容が表示される。

Edit 1

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Wed Mar 8 09:56:57 2017 +0900
#
# interactive rebase in progress; onto d5dc6fe
# Last command done (1 command done):
#    reword a87e4d1 Edit 1
# Next commands to do (2 remaining commands):
#    pick ec65417 Edit 2
#    pick 657475f Edit 3
# You are currently editing a commit while rebasing branch 'master' on 'd5dc6fe'.
#
# Changes to be committed:
#       modified:   Test1.txt
#       new file:   Test2.txt
#

この画面で、「Edit 1」のコミットコメントを書き換えられる。ここでも、# 始まりの行はコメントなので無視して良い。

1行目の「Edit 1」が実際のコミットコメント部分なので、ここを「Edit 1 Rebase!」のように書き換える。改行も入れられると思う。

:wq」で保存終了すると元のターミナルに戻り、以下のように表示されている。

$ git rebase -i d5dc6fe
[detached HEAD 4992001] Edit 1 Rebase!
 Date: Wed Mar 8 09:56:57 2017 +0900
 2 files changed, 4 insertions(+)
 create mode 100644 Test2.txt
Successfully rebased and updated refs/heads/master.

# コミットログを確認する
$ git log --oneline
7110995 2017-03-08 Edit 3 [Neos21] (HEAD -> master)
bbccb70 2017-03-08 Edit 2 [Neos21]
4992001 2017-03-08 Edit 1 Rebase! [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

「Edit 1」だったコミットコメントが「Edit 1 Rebase!」になっている。

ただし、1つ注意点がある。

# 以下は rebase 前のログ。rebase 後のコミットログと見比べてみると…?
657475f 2017-03-08 Edit 3 [Neos21] (HEAD -> master)
ec65417 2017-03-08 Edit 2 [Neos21]
a87e4d1 2017-03-08 Edit 1 [Neos21]
d5dc6fe 2017-03-08 First Commit [Neos21]

git rebase する前のログと比べると、変更した「Edit 1」のコミット以降のハッシュが全て書き変わっている

コミット内容の編集によって「Edit 1」のハッシュが変わり、その後のコミットは手前のコミットとの繋がりの情報を保持しているので、変更された「Edit 1」のハッシュを保持するために後ろのコミットもハッシュが変わる。GitHub などにプッシュしてしまったコミットを後から編集すると、色々と不整合が起こるので、プッシュ済みのコミットの Rebase は避けた方が良い。

2022-04-14 : Push 済みのコミットを Rebase するのは、git reset と同様に NG 行為である。あえて問題が少ない場面があるとすると、feature ブランチを切ってから自分が打ったコミットであれば、Rebase して強制再 Push しても問題ないかと思う。レビュー前の feature ブランチのコミット履歴を git rebase で整形して、レビュアーが分かりやすいように直してからコードレビューしてもらい、それを master ブランチ等にマージする、という形であれば、迷惑をかけるメンバはほぼいないだろう。

ただ、自分が見てきた限りだと、git rebase を使いこなしてコミット履歴を綺麗にしてくれるようなリテラシーを持つ SIer は皆無。コミットを大切にするような人は珍しく、基本的には現場から求められることもないので、今回の3つのサブコマンドの中では一番覚えなくても良いコマンドかもしれない。

以上

実際に操作してみてかなり感覚が掴めた。「こうしたいんだけどどうやるんだろう?」というインデックスは出来た気がする。

参考