以下は社内向け勉強会のLT枠で話した内容をベースにして編集増補したものである。増補しただけでなくそもそも1回分を記事にしたものではなかったりもするので、この内容を5分で話したわけではない。

git resetコマンドの概要

  • 「HEADが参照しているブランチの参照しているコミットを変更」するコマンド
  • git checkout(git restore)でできるような「ファイルの復元」も行うこともできるが、git checkout(git restore)とは元々の守備範囲が異なるので全く同じことができるわけではない

立ち位置としてはgit checkout(git restore)がgit addの逆操作のコマンドであり、git resetはgit commitの逆操作のコマンドである。もっとも、どちらもその領域を超えた機能も持っているわけだが。

git resetは良く「コミットの取り消しを行うコマンド」として紹介されているが、本質的には上に書いた通りのHEADが参照しているブランチ、つまり作業者から見れば現在作業中のブランチの参照している「コミットを変更するコマンド」である。変更先のコミットに指定できるのは、リポジトリ内に存在するコミットならどれでも任意なので、コミット履歴の1つ前のコミットに移動すれば「コミットの取り消し」が行われているように見え、またその使い方がgit resetにとって最もポピュラーなので「コミットの取り消し」をするコマンドと紹介されることが多いわけである。実際のところは履歴の100個前のコミットにでも、履歴に載っていないコミットにでも移動可能である。

HEADが参照しているブランチの参照しているコミットを変更

HEADが参照しているブランチのコミットを変更するコマンドは以下である。

git reset <モード> <変更先commit-ish>

コマンドを見ればわかるように、この形式ではファイル名は指定できないのでリポジトリ全体のファイルに対する処理となる。もっとも、全体とは言ってもgit checkoutやgit restoreと同様、git管理外のファイル(これにはインデックスにもいずれのコミットにも含まれていないファイル、つまりgit statusで”Untracked files”に表示されるものも含む)が変更されることはない。

モードは独立した項目で説明する。

変更先はcommit-ishが指定できる。つまり純粋なコミットIDだけでなく、”HEAD^”等としてHEADの1つ前のコミットを指定するのが良く見られる形である。

変更先にはそれ以外にも「コミットの打ち消しの打ち消し」として良く紹介される”HEAD@{1}”としてreflogの最新の1つ前を指定する、というのもある。git resetは、実体がリファレンスである、ブランチのコミットを変更するコマンドなので、この形式で行われたすべての実行履歴がリファレンスの操作ログであるreflogに記録される。よって先程実行したgit resetによるコミットの変更を元に戻したければreflogの最新の1つ前を指定すれば良い、ということになる。reflogの各履歴はコミットへの参照を持つのでcommit-ishである。

reflogに記録されるのはこの作業者のローカルのリポジトリで行った操作だけであるが、git resetだけが記録されるわけではないので、1つ前と決め込まずにgit reflogでいくつ前の実行履歴だったか確認した方が良い。人間はパニックになった時はわけのわからない操作をすることもあるのである。

git resetコマンドのモード

git resetコマンドのモードは、HEADが参照するコミットを変更した後、ついでに何をするかを指定するものであり、–soft、–mixed、–hard、–keep、–mergeの5つのうち1つを選択する。デフォルトは–mixedなので何も指定していなければ–mixedが指定されている扱いとなる。メジャーなのは–soft、–mixed、–hardの3つで、後の2つ、–keep、–mergeはあまり使われていないと思われる。

念を押すと、どのモードであってもHEADが参照するコミットの変更処理は行われる(もちろん、移動前と移動先のコミットが同じなら実質的には変更は発生しない)。モードはあくまでHEADが参照するコミットの変更以外に何をするか、を決めるものである。

「何をするか」というのは具体的にはインデックスやワークツリーのファイルをどうするか、である。インデックスやワークツリーについてはgit checkoutの記事にて説明している。大雑把に言えばgit addする前の作業するための場所がワークツリー、git addした後のファイルの置き場がインデックスである。

モードが–softの場合はHEADが参照するコミットを変更するだけで、インデックスやワークツリーのファイルには変更は加えない。

モードが–mixedの場合はHEADが参照するコミットを変更し、加えてその移動先コミットのファイル内容にインデックスを変更する。ワークツリーのファイルには変更は加えない。

モードが–hardの場合はHEADが参照するコミットを変更し、加えてその移動先コミットのファイル内容にインデックスとワークツリーを変更する。

–hardはよく使われる割に作業中のファイルが全部リセットされる危険なモードである。–hardで消えてしまった作業中だったコミットされていない変更はreflogの履歴等を使って元のコミットに移動しても帰ってくることはない。コミットの内容を元にコピーされるのだから当然である。せめてインデックスに登録してあったならgit fsck --lost-foundでコミットされなかった、かつてインデックスにだけあったファイル内容が(git gcあるいはgit gcを暗黙的に実行する別のコマンド等でまだ掃除されてなければ).git/lost-foundディレクトリの中に書かれるのでそこから探すことができる。ファイル名は保持されていないので内容で探すこと。インデックスにも登録されていなければgitの力による復旧はできない。

この問題はgit checkout(git restore)でも発生するが、git reset –hardで良くやられているのはコマンド自体の知名度によるものだろうか。

–keepというモードは–hardの危険性を多少緩和するものである。HEADが参照するコミットを変更する以外に何が起こるかというと、まず–mixedと同じくインデックスが移動先コミットの内容に書き換えられるのは確定なのだが、ワークツリーに関しては移動先コミットの内容に書き換えられるファイルとそのままにされるファイルとに分かれる。また、それぞれのファイルの移動元コミット、移動先コミット、インデックス、ワークツリーの内容によってはコマンドがエラーとなり失敗することがある。manの説明文に書かれている内容からは微妙に異なる結果が出た(というか、説明が足りない?)ので、以下に表にまとめる。

  • git reset –keepの結果(ファイルごとに判定される。凡例を以下に示す)
    • 同じ大文字アルファベットや同じ数字は同じ内容のファイルを示す
    • 違う大文字アルファベットはそれぞれ違う内容のファイルを示す
    • 数字は任意のファイルの意味で、いずれかの大文字アルファベットのファイルや他の数字の内容と同じでも違っても良い
    • 「後のワークツリー」の太字は–hard相当のパターン、斜字は–mixed相当のパターン
    • エラーaのメッセージは「error: Entry ‘<ファイル名>’ not uptodate. Cannot merge.」
    • エラーbのメッセージは「error: Entry ‘<ファイル名>’ would be overwritten by merge. Cannot merge.」
移動元 移動先(=非エラー時、後のインデックス) 元のインデックス 元のワークツリー 後のワークツリー
A A 2 1 1
A B A A B
A B A A以外 (エラーa)
A B B 1 1
A B C 1 (エラーb)

ワークツリーが移動先コミットの内容に書き換えられるのは移動元コミットと移動先コミットで内容が異なり、インデックスとワークツリーが共に移動元コミットの内容と同じ、言い換えると編集中ではないファイルである。そのままにされるのは移動元コミットと移動先コミットとで内容が同じか移動先コミットとインデックスとで内容が同じファイルである。このような条件下で変更作業中のファイルでもその変更内容を残すようにしたもの、と考えることができる。

–mergeモードはまず–mixedと同じくインデックスが移動先コミットの内容に書き換えられるところまでは–keepとも同じで、ワークツリーに関してはインデックスとワークツリーが同じなら–hardと同じで、移動先コミットとインデックスが同じ時は–mixedと同じになる。それ以外の場合はエラーとなる。–keepよりは–hard寄りと言えるだろう。こちらはmanの説明文通りの結果が出たが以下に表にまとめる。

  • git reset –mergeの結果(ファイルごとに判定される。凡例は–keepと同じ)
移動元 移動先(=非エラー時、後のインデックス) 元のインデックス 元のワークツリー 後のワークツリー
A A 1 1 A
A A C C以外 (エラーa)
A 2 2 1 1
A B A A B
A B A A以外 (エラーa)
A B C C B
A B C C以外 (エラーa)

正直–mergeは何故こういう仕様なのか使いどころがわからない。まだ–keepの方は使うこともあるかも、という気分にはなる。

インデックスのファイルの復元

git resetには以下のようなもう1つの形式がある。この形式では指定したファイルをインデックスにおいて復元元の内容と同じにする。

git reset [<復元元tree-ish>] [--] <ファイル名>...

復元元として指定するtree-ishについては、git checkoutのページの方で説明している。省略時はHEADである。

ファイル名は複数指定、ワイルドカード、「.」(カレントディレクトリとサブディレクトリにあるすべてのファイル、の意味)も指定可能である。

今度はファイル名は指定できるがモードを指定できない。git checkoutでも上記コマンドのresetの部分をcheckoutにしただけの同じ形式のコマンドがある。効果は異なり、git resetだと復元されるのはインデックスであるのに対して、git checkoutだと復元されるのはインデックスとワークツリーの両方である(但し、復元元省略時は復元元はインデックスで復元先はワークツリーのみ)。つまりgit resetの方はgit restore [-s <復元元tree-ish>] -S [--] <ファイル名>...と同じ効果であり、git checkoutの方は復元元が省略されていなければgit restore [-s <復元元tree-ish>] -S -W [--] <ファイル名>...と同じ効果である。

この形式ではHEADが参照しているブランチのコミットの変更は発生しない。よってリファレンスへの操作が起こらないのでreflogにも記録されない。

コミットの削除

ここからはgit resetの話からはちょっと離れるが、類似する話題として取り上げる。

git reset <モード> <変更先commit-ish>は「コミットの取り消し」ではなく「HEADが参照しているブランチの参照しているコミットの変更」であると先に述べた。つまりこのコマンドを実行しても直ちにコミット自体が削除されるわけではない。直ちに~ではない、というのは先に書いたようにgit gcあるいはgit gcを暗黙的に実行する別のコマンド等で削除されることはある、という意味である。その場合でもわざわざ保護期間をなしにしておかない限りすぐに消えたりはしないが。

しかしながら、パスワード等の機密情報を含めてしまった、Gitで管理したくないとても大きなファイルを含めてしまった等の理由で1つ前のコミットに戻ってやり直すだけでは済まず、ディスクに書き込まれた情報ごと本当にコミットを削除しなければならない時がある。パスワードなら変更したりキーなら無効化したりして1つ前のコミットに戻ってやり直す方が簡単ならそうした方が良いが。

コミットを削除するには何に対して作業を行われなければならないだろうか。凡そ書き出してみると以下のようなものになる。

  • コミット自身の削除
  • 削除されるコミットが参照しているツリーに含まれる一連のファイルの削除。但し残されるコミットのツリーにも含まれるファイルは除く
  • (削除されるのが履歴の途中のコミットなら)コミットの履歴の繋がりの修正
  • 削除されるコミットを参照しているブランチやタグといったリファレンス等の書き換え
  • 削除されるコミットを参照しているreflogの削除

もしコミットの削除ではなく、コミット内の一部のファイルの書き換えを行ったとしても、コミットIDが変更されるので大体同じような作業となる。

これだけいろいろやるのは大変であるので普通はそれ用のツールを使って削除することになる。Git標準ではこの用途に使えるgit filter-branchというサブコマンドが提供されていたが今は非推奨となっているので、代わりにサードパーティ製のツールを使用する。GitHubのドキュメントの「Removing sensitive data from a repository」というページにはそれらのツールが紹介されている。Python製のgit filter-repoとJava製のBFG Repo-Cleanerである。

ここではgit filter-repoを使ったやり方を紹介する。git filter-repoの実行にはPython 3.5以上とGit 2.24以上(最小では2.22以上と書かれているが、2.24と比べて何ができなくなるのか良くわからない)が必要である。

削除したいコミットがまだプッシュされていない場合

まずはまだ状況的にマシな、リポジトリが自分が持つ1つしかないか、まだ削除したいコミットをプッシュしておらず自分のローカルにしかない場合について説明する。

現在ワークツリーで作業中のファイルがあれば仮のコミットを作って退避する。その後、git filter-repoは-fオプションを付けない限り、クローンしてすぐのリポジトリで実行しないとエラーとなるのでまず、適当な新しいディレクトリにクローンする。元のリポジトリのルートにいるものとした場合、

git clone --no-local . ../cloned
cd ../cloned

で、新しいリポジトリで作業することになる。–no-localオプションを付けてgit cloneしないとgit filter-repoは-fオプションを付けろとエラーを出す。リポジトリの大きさ等の理由でクローンするのが現実的でない場合は-fオプションを付けてgit filter-repoをすることになるが、その場合でもバックアップをしてから実行することをお勧めする。リポジトリのディレクトリを丸ごとコピーを取れていればそれでバックアップになる。以下ではgit filter-repoには-fオプションを付けない形で記述する。

例えば新しいリポジトリである特定のファイルをすべてのコミットから削除する場合、以下を実行する。–invert-pathsを付けるのを忘れると–pathで指定したファイル以外が全部削除されるので注意。なお、コミットの中に削除したいファイル以外のファイルも含まれる場合は、削除したいファイルだけが削除されたコミットに書き換えられる。

git filter-repo --path <削除したいファイルのリポジトリルートからの相対ファイル名> --invert-paths

別の例として、ファイルの内容に対して、ある特定の文字列を別の文字列に書き換える場合は、まずリポジトリ外(でもディスクの同じパーティション内)に(書き換え前の文字列)==>(書き換え後の文字列)という内容のファイル(内容の例:password==>123456)を作って、以下を実行する。この場合は当然コミットの削除ではなく書き換えになる。ここでは../conv.txtを作ったとする。

git filter-repo --replace-text ../conv.txt

もっと単に最新のコミットを削除する場合は以下を実行する。”–refs HEAD^..HEAD”は最新1つ前のコミットより新しく最新のコミットを含めそれより古いの意味(つまり、最新のコミットだけ、の意味)、”–path-glob ‘*’ –invert-paths”は「すべてのファイルを削除する」の意味である。ちなみにgit clone直後、HEADが指すブランチはクローン元のリポジトリのそれと同じになっているので、クローン元リポジトリのHEADの最新コミットと同じコミットをクローン先で削除する場合はクローン後にブランチの切り替えを行う必要はない。

git filter-repo --refs HEAD^..HEAD --path-glob '*' --invert-paths

git filter-repoを実行するとリモートのoriginの設定は削除される(この削除されたリモートのoriginはgit clone元なので古いリポジトリのディレクトリ)。新しく作業用のリポジトリとすべく古いリポジトリと同じリモートoriginの設定を以下で行う。

git remote add origin <古いリポジトリのプッシュ先URL>

古いリポジトリのプッシュ先は.git/configの[remote “origin”]セクション(名前が”origin”の場合)のurlに書かれている。もちろん、元々リモートなんてなかった場合にはこの作業は不要である。

この作業の前提は「リポジトリが自分が持つ1つしかないか、まだ削除したいコミットをプッシュしておらず自分のローカルにしかない場合」なので、後は仮のコミットとして退避してあった作業中のファイルがあればgit reset HEAD^等で戻してGitで行う作業は終了である。

後は古いリポジトリを削除して、必要なら新しいリポジトリのディレクトリ名をリネームする。

機密情報をディスクから完全に消したいなら、先程「../conv.txt」のようなファイルを作ったとしたらそれも削除して、新しいリポジトリを別のディスクかパーティションに持っていって、必要なら.gitignoreを編集して機密情報を書くようなファイルをgit管理外にし、古いリポジトリのあったディスクかパーティションをフォーマットするとかddで消去するとかいったディスク上の掃除を行うことになる。そこまでやりたくないって? まあそれはお好きにどうぞ。

削除したいコミットがすでにプッシュされている場合

問題はすでにプッシュしてあった場合である。プッシュ先のリポジトリが全世界に公開されたもので、そこに機密情報が含まれていたならすぐ気付いたとしてももう誰かに見られていると考えた方が良い。とりあえずGit周りの作業なんて後回しで可及的速やかにパスワードなら変更したりキーなら無効化したりしなければならない。

その後で、あるいは見られても問題はない情報か相手だったなら、そのリポジトリに対する作業者全員(少なくとも、消したい情報をすでにフェッチしてしまった全員)に残したい作業中のファイルを仮にコミットしてからローカルのコミットを全部プッシュしてもらい、そこでそのリポジトリに対する作業を中断してもらう。

修正したリポジトリを作業者全員に行き渡らせなければならないので、プッシュ先のリポジトリが修正されないとならない。プッシュ先がGithub等、こちらでリポジトリの中身を触ることができない場所ならサポートに連絡して対応を仰ぐことになる。

プッシュ先がこちらでリポジトリの中身を触ることができるなら2つのやり方がある。1つはローカルのリポジトリで上記「削除したいコミットがまだプッシュされていない場合」の対応をした後、コミット情報とタグ情報を以下でプッシュする。

git push origin --force --all
git push origin --force --tags

その次にプッシュ先のリポジトリでreflogの全削除を以下で行う。–expire=nowはgc.reflogExpire設定(デフォルト:90日以上前のものを削除する)を無視して現在より前のreflogを削除するオプション、–allはすべてのリファレンスに関するreflogを削除するオプションである。

git reflog expire --expire=now --all

さらにコミットに紐付かなくなったファイル等も以下で削除する。–prune=nowはgc.pruneExpire設定(デフォルト:2週間以上前のものを削除する)を無視して現在より前のどこからも参照されていないオブジェクトを削除する。オブジェクトはコミット、ツリー、ブロブ(ツリーに含まれるファイル)、タグの補足情報(git tagの-aや-sや-uのオプション付きで作ったタグに付けられる)の総称である。

git gc --prune=now

もう1つのやり方はプッシュ先のリポジトリで「削除したいコミットがまだプッシュされていない場合」の対応をすることである。ちなみにgit reflog expire --expire=now --allgit gc --prune=nowはgit filter-repoが実行時に行う作業としてmanに書かれているものそのままなので、git filter-repoを実行したらこれらも実行されている。

プッシュ先のリポジトリをきれいにできたなら、その後で作業者全員にその修正を行き渡らせる。以下のようないくつかのやり方があるだろう。

  • 手間が掛からないのはローカルリポジトリを削除して再度クローンして持ってくる方法である。手間は掛からないがリポジトリが大きいと時間は掛かる可能性がある。その後、git checkout <ブランチ名>で使うブランチを復帰させる。
  • 既存のリポジトリを生かすなら、git fetch --allですべてのリモートブランチを、git fetch --tags -fですべてのタグをフェッチし、git branch --format='%(refname:short)' | xargs -i{} sh -c "git checkout -B {} origin/{} || git branch -D {}"でローカルブランチを強制的に作り直す(リモートにないブランチは削除する)。その後は例によってgit reflog expire --expire=now --allgit gc --prune=nowを実行する。
  • プッシュしてないファイルがあるからそんなことしたくないという場合は、その作業者のリポジトリにもgit filter-repoをリモートの時と同じオプションで掛けてやる。その後、git remote add origin <元のプッシュ先URL>してからgit fetch --all等でフェッチ。

ここまででGitでの作業は終了である。後は「削除したいコミットがまだプッシュされていない場合」の古いリポジトリを削除して、以降と同じ作業となる。

なお、機密情報をプッシュしないように、という用途なら、git-secretsのようなコミットさせないようにするソリューションや、git-cryptのような自動で暗号化を行ってくれるようなソリューションの使用も検討すると良いだろう。

TOP