Gitで可能な数々の取り消し機能

このチュートリアルでは、Gitでの作業を元に戻すさまざまな方法を紹介します。 GitLabのGitドキュメントを参考にしてください。

また、ここではコマンドの一般的な情報のみを提供し、簡単なケースや例については十分な情報を提供しますが、より高度な情報についてはgitbookを参照してください。

ここでは、現在の開発者における変更のステージに応じて、変更を取り消すためのいくつかの異なるテクニックを説明します。 また、Gitでは本当に削除されるものは何もないということを覚えておいてください。

つまり、Git が切り離されたコミット (ブランチやタグでアクセスできないもの) を自動的にクリーンアップするまでは、git reflog コマンドでそれらを表示したり直接コミット ID でアクセスしたりできるようになるということです。詳しくは _アンドゥのやり直し_のやり直しについては以下のセクションを参照ください。

GitとGitLabの詳細については、こちらをご覧ください:

  • North Western Mutualがエンタープライズソースコード管理にGitLabを選んだ理由をご覧ください。
  • Gitの始め方を学びましょう。

導入

このチュートリアルでは、変更の取り消しや他の開発者との共有の可否について、開発のステージ別に説明します。 Git は変更を追跡しているので、作成したファイルや編集したファイルはステージされていない状態です (作成したファイルは Git によって追跡されていません)。リポジトリ (git add) に追加したらファイルをステージ状態にし、それをローカルリポジトリにコミット (git commit) します。その後、ファイルを他の開発者と共有 (git push) できるようになります。このチュートリアルで扱う内容は次のとおりです:

ブランチ戦略

gitは非中央集権型のバージョン管理システムです。つまり、リポジトリ全体の通常のバージョン管理だけでなく、他のリポジトリと変更を交換することもできます。

複数の真実のソースによる混乱を避けるために、様々な開発ワークフローに従わなければならず、特定の変更やコミットをどのように取り消したり変更したりできるかは、内部ワークフローに依存します。

GitLabFlowは、同じ機能を開発中に開発者同士が衝突することと、シームレスに協力することのバランスをうまく取っていますが、デフォルトでは複数の開発者が同じ機能を共同で開発することはできません。

複数の開発者が同じ機能を同じブランチで開発する場合、同期がうまくいかず衝突することは避けられません。しかし、適切な Git ワークフローを選択すれば、機能が完成したときに何かが失われたり同期がずれたりすることはありません。

このブログのGit Tips & Tricksを読んで、Git での簡単な操作方法を学んでください。

ローカル変更の取り消し

変更をリモートリポジトリにプッシュするまでは、その変更はあなたにしか影響しません。 そのため、元に戻す方法の選択肢が広がります。 それでも、ローカルでの変更はさまざまなステージにあり、それぞれのステージで取り組み方が異なります。

ステージされていないローカルの変更 (コミット前)

変更を加えたにもかかわらずステージツリーに追加されない場合、git自身が特定のファイルに対する変更を破棄する解決策を提案します。

お気に入りのエディタでファイルを編集し、内容を変更したとします:

vim <file>

ステージングにgit add <file> していないので、unstaged files (ファイルが作成された場合は untracked) にあるはずです:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   <file>
no changes added to commit (use "git add" and/or "git commit -a")

この時点で、ローカルでの変更を元に戻すには3つのオプションがあります:

  • ローカルでの変更はすべて破棄しますが、後で再利用できるように保存しておきます:

     git stash
    
  • ファイルに対するローカルの変更を(永久に)破棄します:

     git checkout -- <file>
    
  • すべてのファイルに対するすべてのローカル変更を永久に破棄します:

     git reset --hard
    

git reset --hardを実行する前に、git stashを使って変更をコミットせずに一時的に保存する方法もあることに留意してください。 このコマンドはすべてのファイルの変更をリセットしますが、後で適用したい場合に備えて変更内容も保存します。 詳細は以下のセクションを参照してください。

ローカルでの変更を素早く保存

その機能はまだ完成しておらず、別のブランチにスワップする必要があります。git stash を使って、それまでの作業を保存し、別のブランチにスワップし、コミット、プッシュ、テストを行ってから、前の機能のブランチに戻ってgit stash pop を実行し、中断した作業を再開します。

上の例では、すべての変更を破棄することが常に望ましい選択肢であるとは限らないことを示しています。しかし、Git には、変更を保存しておいて後でそれを保存しない状態のリポジトリに戻す方法があります。これを実現するのが、Git stashing コマンドgit stashです。実際には、現在の作業を保存してgit reset --hardを実行しますが、これには次のようなさまざまな追加オプションもあります:

  • git stash save一時的なコミットメッセージを含めることができます。
  • git stash listこれは、popされていない、以前に隠したコミット(他にもあります)の一覧を表示します。
  • git stash pop前に隠した変更をやり直し、隠したリストから削除します。
  • git stash applyこれは、以前に隠した変更をやり直しますが、隠したリストは保持します。

ステージされたローカル変更 (コミット前)

ステージングにファイルを追加したけれども、現在のコミットからは削除したい、でもその変更は保持したい - ステージングツリーの外側に移動させたい、としましょう。git reset --hard ですべての変更を破棄することもできますし、先ほど説明したようにgit stash を考えることもできます。

好きなエディタでファイルを編集し、コンテンツを変更してステージングに追加する例から始めましょう。

vim <file>
git add <file>

ファイルがステージに追加されたことはgit status コマンドで確認できます:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

  new file:   <file>

変更を取り消すには4つのオプションがあります:

  • ファイルを現在のコミット(HEAD)にアンステージします:

     git reset HEAD <file>
    
  • すべてのステージを解除し、変更を保持します:

     git reset
    
  • ローカルでの変更はすべて破棄し、後で使えるように保存しておきます:

     git stash
    
  • すべてを永久に廃棄します:

     git reset --hard
    

コミットされたローカル変更

コミットすると、変更内容がバージョン管理システムに記録されます。 まだリモートリポジトリにプッシュしていないので、変更内容はまだ公開されていません (あるいは他の開発者と共有されていません)。 この時点では、元に戻すのはとても簡単で、回避方法もいくつかあります。 コードをプッシュすると、トラブルシューティングの選択肢が減ります。

歴史を修正することなく

開発プロセスを通じて、以前にコミットした変更が最終的な解決策に合わなくなったり、バグの原因になったりすることがあります。 バグを引き起こしたコミットを見つけたら、あるいは欠陥のあるコミットを見つけたら、git revert commit-idを使って簡単に元に戻すことができます。

このコマンドはそのコミットでの追加と削除を反転(入れ替え)し、履歴を変更しないようにします。 履歴を残しておくと、将来、過去に試した変更がうまくいかなかったことに気づくのに便利です。

この例では、ABCDE の順にコミットされたコミットがあると仮定します。A-B-C-D-EB 元に戻したいコミットです。B悪いB コミットBであることをB 識別Bするにはさまざまな方法がありますが、そのひとつにgit bisect コマンドに範囲を渡す方法があります。指定する範囲には、最後に確認された良いコミット(ここではAと仮定します)と、最初に確認された悪いコミット(バグが検出されたコミット - ここではEと仮定します)が含まれます。

git bisect A..E

Bisect はテストするコミットの中間コミットのコミット ID を提供し、簡単なバイセクトプロセスを案内してくれます。 詳しくは公式の Git Toolsを参照してください。 この例では、バグやエラーが発生したコミットBを削除することになります。このコミット (あるいはその一部) をリポジトリから削除するには、4 つの方法があります。

  • コミットBによって導入された変更を元に戻します (追加と削除を入れ替えます):

     git revert commit-B-id
    
  • コミットBからのファイルやディレクトリの変更を元に戻しますが、ステージ状態には保持します:

     git checkout commit-B-id <file>
    
  • コミットBからの単一のファイルまたはディレクトリの変更を元に戻します:

     git reset commit-B-id <file>
    
  • 忘れてはならないコマンドがあります。それは、変更が適用できない箇所や開発が行き詰まった箇所から新しいブランチを作成することです。 たとえば、feature-branch でコミットA-B-C-D を行った後、CD が間違っていることに気づいたとします。

    A-B-Fこの時点で、BにリセットしてコミットF を行うか (これはプッシュの際に問題が発生します。また、プッシュを強要された場合は他の開発者にも迷惑がかかります)、コミットB をチェックアウトして新しいブランチを作成し、コミットFを行うか (これは履歴を変更することになります)。最後のケースでは、他の開発者は自分の作業を続けながら新しいブランチを作成し、後でそれをマージすることができます。 GitLab を使えば、そのコミットを新しいマージリクエストにチェリーピックすることもできます。

    Create a new branch to avoid clashing

     git checkout commit-B-id
     git checkout -b new-path-of-feature
     # Create <commit F>
     git commit -a
    

履歴修正あり

履歴を変更するためのコマンドは1つあり、それはgit rebaseです。このコマンドは対話型モード(-i フラグ)を提供し、これを利用することで履歴を変更することができます:

  • コミットメッセージを書き換えます(最後のコミットメッセージを編集するためのgit commit --amend もあります)。
  • コミット内容(コミットによって導入された変更)とメッセージを編集します。
  • 複数のコミットをひとつにまとめ、カスタムまたは集約されたコミットメッセージを持つことができます。
  • コミットを削除します。
  • その他、いくつかのオプションがあります。

Bを削除したいコミットA-B-C-D があります。

  • 現在のコミットDからAへの範囲をリベースします:

     git rebase -i A
    
  • コマンドはお好みのエディタを開き、コミットBの前にdrop と書きますが、他のコミットはデフォルトのpick のままにしておきます。 保存してエディタを終了すると、リベースが実行されます。 覚えておいてください: 保存してエディタを終了する前に、ファイル全体の内容を削除するのをキャンセルしたい場合

コミットBで導入された何かを修正したい場合。

  • 現在のコミットDからAへの範囲をリベースします:

     git rebase -i A
    
  • コマンドはお好みのテキストエディタを開き、コミットBの前にedit と書きますが、他のコミットはデフォルトのpick のままにしておきます。 保存してエディタを終了すると、リベースが実行されます。

  • 編集を行い、変更をコミットしてください:

     git commit -a
    

以下のセクションで、履歴の変更方法を説明します。

アンドゥのやり直し

時には、元に戻した変更が有用であったことに気づき、それを元に戻したいと思うこともあるでしょう。git reflogコマンドを使えば、コミット ID を参照したり適用したりすることで、切り離したローカルコミットを呼び出すことができます。 ただし、本当に古いコミットが reflog に表示されるとは思わないでください。Git はブランチやタグで到達できないコミットを定期的にクリーンアップしているからです。

リポジトリの履歴を表示したり、古いコミットを追跡したりするには、以下のコマンドを使用します:

$ git reflog show

# Example output:
b673187 HEAD@{4}: merge 6e43d5987921bde189640cc1e37661f7f75c9c0b: Merge made by the 'recursive' strategy.
eb37e74 HEAD@{5}: rebase -i (finish): returning to refs/heads/master
eb37e74 HEAD@{6}: rebase -i (pick): Commit C
97436c6 HEAD@{7}: rebase -i (start): checkout 97436c6eec6396c63856c19b6a96372705b08b1b
...
88f1867 HEAD@{12}: commit: Commit D
97436c6 HEAD@{13}: checkout: moving from 97436c6eec6396c63856c19b6a96372705b08b1b to test
97436c6 HEAD@{14}: checkout: moving from master to 97436c6
05cc326 HEAD@{15}: commit: Commit C
6e43d59 HEAD@{16}: commit: Commit B

コマンドの出力はリポジトリの履歴を表示します。最初のカラムにはコミットIDがあり、次のカラムにはHEAD の横にある数字が何コミット前に行われたかを示し、その後に行われたアクション(コミット、リベース、マージ、…)のインジケータがあり、最後にそのアクションの説明があります。

履歴を変更せずにリモートの変更を元に戻すことができます。

このトピックは、コミットされたローカルの変更を履歴を変更せずに修正することとほぼ同じです。リモートリポジトリや公開ブランチの変更を取り消すには、この方法が望ましいでしょう。 ブランチは、不具合のあった開発の履歴を保持しつつ、ある時点から新たに開始したい場合に最適なソリューションであることを覚えておいてください。

ブランチによって、(マージによって)既存の変更を新しい開発に含めることができ、また、明確なタイムラインと開発構造を提供します。

Use revert to keep branch flowing

あるコミットで行われた変更を元に戻したい場合は、新しく作成したコミットでcommit-id その変更 commit-id(追加と削除の入れ替え) を元に戻すだけです。

git revert commit-id

または新しいブランチを作成します:

git checkout commit-id
git checkout -b new-path-of-feature

履歴の変更でリモートの変更を元に戻す

これは、秘密のキーやパスワード、SSHキーなど、特定のものを隠したいときに便利です。 ミスを隠すために使うのはよくありませんし、使うべきでもありません。 この主な理由は、本当の開発の進捗がわからなくなるからです。また、変更履歴があっても、コミットは切り離されただけで、コミットIDからアクセスできることに注意しましょう。少なくとも、すべてのリポジトリが切り離されたコミットのクリーンアップを行うまでは(自動的に行われます)。

Modifying history causes problems on remote branch

履歴の修正が一般的に許容される場合

変更履歴はコミット ID が一致しないため、他の開発者の開発チェーンを壊してしまいます。 そのため、公開ブランチや他の開発者が使う可能性のあるブランチでは使用すべきではありません。 大きなオープンソースリポジトリ (たとえばGitLab自体) に貢献する場合は、コミットをひとつにまとめて、貢献の履歴を見やすくしてもかまいません。

マージリクエストで特定のコミットにつけられたコメントも削除されるので、GitLab でトレーサビリティを保持する必要がある場合は、履歴を変更することは受け入れられないことに注意しましょう。

マージリクエストの feature-branch は公開ブランチであり、他の開発者が使う可能性があります。しかし、プロジェクトのプロセスやルールによっては、レビューが終わった後にgit rebase (履歴を変更するコマンド) を使ってターゲットブランチの表示コミット数を減らすことが許可されていたり、そうする必要があったりすることがあります (たとえば GitLab など)。まさにそれを行うgit merge --squash コマンドがあります (feature-branch のコミットをつぶして、マージ時にターゲットブランチのコミットをひとつにまとめる)。

注意:master や共有ブランチのコミット履歴は決して変更しないでください。

履歴の修正方法

何を修正したいのか (履歴のどのあたりまで、あるいは古いコミットのどの範囲まで) がわかったら、git rebase -i commit-idを使ってください。 このコマンドは、現在のバージョンから選択したコミット ID までのすべてのコミットを表示し、そのコミットの修正、破棄、削除を許可します。

$ git rebase -i commit1-id..commit3-id
pick <commit1-id> <commit1-commit-message>
pick <commit2-id> <commit2-commit-message>
pick <commit3-id> <commit3-commit-message>

# Rebase commit1-id..commit3-id onto <commit4-id> (3 command(s))
#
# 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
注:出力されたコメントには、中止する場合はエディタを閉じずに(履歴を変更してしまうので)、コメントされていない行をすべて削除して保存するようにと明記されていることにインポートしてください。

これが、git rebase を共有ブランチやリモートブランチで注意深く使うべき理由のひとつです。しかし、リモートリポジトリにプッシュバックするまで、何も壊れることはありませんのでご安心ください(そのため、ローカルで自由にさまざまな結果を調べることができます)。

# Modify history from commit-id to HEAD (current commit)
git rebase -i commit-id

コミットから機密情報を削除

Git では、過去のコミットからセンシティブな情報を削除することもできます。 そのため、単独のトピックとしてではなくこのセクションに含めています。そのためには、git filter-branchを実行する必要があります。これは、特定のフィルターを使って履歴を書き換えることができます。このコマンドは rebase を使って履歴を書き換えるもので、特定のファイルを履歴から完全に削除したい場合に使用します:

git filter-branch --tree-filter 'rm filename' HEAD

git filter-branch コマンドは大きなリポジトリでは遅いかもしれないので、一般的なタスク(まさに機密情報ファイルを削除することです)をより速く実行できるように、Git の仕様の一部を使用できるツールがあります。 代替手段として、オープンソースコミュニティが保守しているツールBFGがあります。これらのツールがより速いのは、git filter-branch のような機能セットを提供していないからであり、特定のユースケースに焦点を当てていることに留意してください。

結論

どのようなバージョン管理システムにも、作業を取り消すためのさまざまなオプションがあります。しかし、Gitは非中央集権的な性質を持っているため、プロセスのステージによってこれらのオプションは倍増します(あるいは制限されます)。 また、Gitは履歴を書き換えることもできますが、複数の開発者が同じコードベースに貢献している場合に問題を引き起こす可能性があるため、これは避けるべきです。