新人だって怖くない!Gitで焦らないためのコマンド操作解説:スタッシュ・コンフリクト解消・マージの取り消し

| 9 min read
Author: kohei-tsukano kohei-tsukanoの画像

昨年新入社員として入社しました、塚野です。
今回は新人向け記事ということで、去年の自分がまずつまずいたGitについて「こんな時どうすれば…!」となる場面ごとに解決方法とその仕組みを解説していきたいと思います。
Gitは大変便利なバージョン管理ツールで多くの現場で導入されていることと思いますが、新人の自分にとっては謎の呪文だらけで「不意にデータを上書きしないか心配」「Gitに怒られたけど何言ってるのかわからん」となっていました。
しかしGitは使い方さえ理解できれば怖いツールではありません。Git ハ トモダチ、コワクナイヨ。

はじめに

#

そもそも新人で入った自分はGitすらも知らなかったため基本的な部分はサル先生のGit入門で学びました。
Gitの入門として有名な通称サルGitです。まずは入門編を読むことでGitとはやpull、pushといった基本的なGitで使用する操作について学ぶことができます。
本記事を読むにあたってはサルGitの入門編とブランチについて理解できれば十分かと思います。
Gitの基本概念とコマンドライン操作については O'Reillyから出版の実用Gitが詳しいです。本記事執筆にあたりこの実用Gitを大変参考にさせていただきました。また、少し古い記事ですがこちらも新入社員向けにGitについてまとめられており参考になるかと思います(新入社員による新入社員のためのGit)。

新人が焦る場面として本記事では、

  • ブランチを切り忘れてmain(master)ブランチ編集しちゃったんだけど…
  • 競合が起きちゃってマージできないんだけどどうすればいいの…
  • 作業用ブランチからmainにマージまでしちゃったけどやっぱり取り消したい!

といった3パターンを取り上げ、解決法について解説します。Gitの操作についてはコマンドライン、TortoiseGitなどのGUIツール、IDEでの操作等様々ありますが、この記事では基本となるコマンドラインでの操作について取り上げます。GUIやIDEなどでのGit操作も、結局はコマンドラインでの操作を便利に行うもので、「何が起こっているのか」を理解するためにはコマンドライン操作を理解するのが一番だと考えるからです。

「ブランチ切り忘れてた/切り替え忘れてた…」

#

焦るシチュエーションその1です。
まず、mainブランチなど元となるブランチから作業用ブランチを新規に切り、そのブランチで作業しなくてはならないところ、元のブランチでファイルの編集をしてしまった場合です。編集内容をmainブランチにコミットしていないのであれば、単純に今から新規ブランチを作成すれば大丈夫です。

$ git switch -c <新規ブランチ名>

でブランチを新規作成し、現在のブランチを作成したブランチに変更しましょう。このコマンドではブランチが枝分かれする点は、現在のブランチの最新のコミットからになります。git switch -c <新規ブランチ名> <コピー元ブランチ名>とすれば現在のブランチ以外からでも新規ブランチが作成できます。

一方、元からある作業用ブランチで作業するはずが別のブランチでファイル編集してしまった…。編集したファイルをそのままにブランチを切り替えたい。となると単純にブランチを切り替えるだけではうまくいきません。ファイルの編集を行いコミットせずにブランチを切り替えようとすると、切り替え先のファイル内容で上書きされてしまうため、以下のようなエラーが出てブランチの切り替えが中断されます。

error: Your local changes to the following files would be overwritten by checkout:
        原因となるファイル名
Please commit your changes or stash them before you switch branches.
Aborting

上書きされてもいい!という場合はgit switch - f <ブランチ名>で強制的にブランチの切り替えができますが、そうでない場合はstashを使用することで変更内容を一時退避させることができます。

$ git stash push -m "index.htmlの編集を一時中断"
…
$ git stash pop

git stash pushで現在の変更内容(ステージングされている内容とローカルの変更内容)がスタッシュスタックに格納され、現在のブランチの最新のコミット状態まで巻き戻ります。スタッシュにpushする際には-mオプションでコメントを付けることができ、一覧から特定のスタッシュを利用する際に便利です。また、git stashのデフォルトオプションはpushのためpushは省略可能です。
スタッシュから変更内容を復元する際にはgit stash popコマンドを使用します[1]。このコマンドによりスタッシュスタックへ最後にpushされた内容から復元が行われます。

さらに、スタッシュスタック内のインデックスを指定することでスタックトップ以外のスタッシュを使用できます。

$ git stash list
stash@{0}: On main: Main.javaの編集を一時中断
stash@{1}: On main: index.htmlの編集を一時中断

$ git stash pop stash@{1}

ちなみに、stashの正体はコミット(.git/ref/stashで管理されるcommitオブジェクト)です。popコマンドで作業ディレクトリに復元する際にはこのコミットからのマージが行われます。そのため、スタッシュしたファイルと復元先のファイルで食い違いがある場合は、ブランチのマージと同様にコンフリクトが発生して取り込みが中断されていしまいます。コンフリクトの解消については次節で解説します。

「競合起きちゃったんだけど…」

#

焦るシチュエーションその2です。新人はこれが一番焦る場面かなと思います。
競合(コンフリクト)はブランチのマージの際に、取り込む側のブランチと取り込まれる側のブランチ間で変更内容の食い違いが発生している状態です。この場合Gitは勝手にどちらかを採用することはなく、解決方法を開発者にゆだねます。これがコンフリクトの解消で、手順さえ理解してしまえば恐ろしいことではありません。
まずコンフリクトが発生した際に何が起こるかを説明します。
例として開発ブランチであるdevelopブランチからmainブランチへマージを行った際に、conflict.htmlの内容でコンフリクトが発生する場合をあげます。

mainブランチとdevelopブランチにおいてconflict.htmlを図1のような内容で別々に編集し、mainブランチにdevelopブランチをマージさせたところ、conflict.htmlでコンフリクトが発生しました(図1)。

図1 conflict.htmlでコンフリクトが発生

git mergeコマンドでマージを行いますが、コンフリクトが発生した場合マージは中断され、コンフリクトを解消しその結果をコミットせよと言われます。

$ git switch main
…
$ git merge develop
Auto-merging conflict.html
CONFLICT (content): Merge conflict in conflict.html
Automatic merge failed; fix conflicts and then commit the result.

コンフリクトが起きた場合、コンフリクトを起こしたファイルにはGitによって「ここがコンフリクト起こしているよ!」と知らせてくれるコンフリクトマーカと呼ばれる目印が挿入されます。

$ cat conflict.html
<html>      
    <p>     
<<<<<<< HEAD
        fuga
=======     
        piyo
>>>>>>> develop
    </p>
</html>

conflict.html内の<<<<<<<=======などの記号がコンフリクトマーカです。
HEADはgit refと呼ばれる別名がつけられたコミットで、ハッシュ値による指定ではなくこの別名を使用して参照できます。
HEADとは現在のブランチの最新のコミットを指すrefになります[2]
したがって、コンフリクトマーカで「現在のブランチ(取り込み側)では"fuga"、developブランチ(取り込まれる側)では"piyo"となっているのでここを直してね」と言われています。
競合している箇所を修正し、コンフリクトマーカを削除したら最後にその編集結果をコミットしてコンフリクト解消完了です。
コンフリクトが起きている状態で、作業ディレクトリとインデックスの状態を表示するgit statusコマンドを実行すると、

$ git status
On branch main
Your branch is up to date with 'origin/main'.

You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
        both modified:   conflict.html

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

と表示され、Unmerged pathsにコンフリクトを起こしたファイルが並び、未マージのファイルがあるぞと言われます。
未マージかどうかをGitが把握する方法ですが、まずgit mergeコマンドを実行した際は以下3つのバージョンを比較してそれらを統合した1つのコミットを作成します[3]

  1. mainブランチとdevelopブランチが枝分かれした、共通祖先となるコミットにおけるバージョン。マージベース。(図1のc1コミット)
  2. マージのターゲットとなる現在のブランチのHEADコミットにおけるバージョン。oursバージョン。(図1のc2コミット)
  3. 現在のブランチへ取り込もうとしているコミット(.git/MERGE_HEADで管理され、MERGE_HEADというgit refで参照可能)におけるバージョン。theirsバージョン。(図1のc3コミット)

conflict.htmlでコンフリクトが発生した場合、上記3バージョンの統合ができずインデックス内のconflict.htmlが3つのバージョンに分かれます。

$ git ls-files -u
100644 102d11e6ef0dcd75574db478be9c8e7d9c736ded 1       conflict.html
100644 d264e8299b49c5d3700adc1eaaeb37728b78edce 2       conflict.html
100644 5787296473b24fc630c26de42f14d9647dbc5982 3       conflict.html

git ls-filesコマンドでインデックス内のファイルを表示でき、-uオプションで未マージ状態のインデックス内のファイルを表示できます。
SHA-1ハッシュ値の異なるconflict.htmlが3つ表示され1、2、3と番号が振られていますが、これは先のリストの番号と一致しており順にマージベース、HEADMERGE_HEADのバージョンを指します。
この状態をGitは未マージと判断し、conflict.html内の競合を解消した後git addでconflict.htmlをステージングすると、

$ git ls-files -s
100644 5787296473b24fc630c26de42f14d9647dbc5982 0       conflict.html
…
$ git status
On branch main
Your branch is up to date with 'origin/main'.

All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
        modified:   conflict.html

このようにインデックスのconflict.htmlは0番が振られた1つのバージョンとなり、git statusでもコンフリクトは解消されたがまだマージ中なのでコミットしましょうと言われる状態となります。
git addする前のstatusコマンドの出力結果にuse "git add <file>..." to mark resolutionとあるように、git addされた段階でコンフリクト解消済みと判断されるため、間違ってコンフリクトマーカを消し忘れたファイルをaddしないように気をつけましょう。
コンフリクトマーカが残っているかどうかはgit diff --checkコマンドを使用し調べることができます。
最後にgit commitを行うことでマージ中断中の状態も解除され、git pushをすればコンフリクトの解消とマージ作業が完了となります。
(参考:git merge解説_Simpline Blog)

「mainブランチにマージまでしたけど取り消したい…」

#

焦るシチュエーションその3です。
開発ブランチをmainブランチへマージした後に、やっぱり不具合が見つかってマージする前の状態に戻したい!そう思うこともあるかと思います。
マージが完了した状態のブランチからマージ前の状態へ戻したいときに使えるコマンドとして、resetコマンドとrevertコマンドがあります。

resetコマンド

#
$ git reset --hard ORIG_HEAD
…

resetコマンドは、指定したコミットの状態をHEADと作業ディレクトリに上書きします。ORIG_HEAD.git/ORIG_HEADで管理されるコミットであり、HEADを移動するコマンドを使用した際の、前のHEADだったコミットを指します。したがって、指定するコミットをORIG_HEADとすることで、マージ前コミット状態と同一の状態へHEADと作業ディレクトリを戻すことができます。--hardオプションは作業ディレクトリに現在編集中のファイルがあっても強制的に作業ディレクトリを上書きするオプションです[4]

revertコマンド

#

一方、revertコマンドは指定したコミットによる編集結果を打ち消すコミットを新たに追加することで、コミットの取り消しを行います。

$ git revert <コミットID>

revertコマンドではマージコミットも取り消すことができるため、resetコマンドと同じくマージが完了した状態からそのマージの取り消しができます。

$ git revert -m 1 <コミットID>

マージコミットのrevertをする際には-mオプション(--mainlineの略)を付けます。-mの後ろの1はparent-numberで、マージコミットは親となるコミットが2つ存在するため、どちらの親の状態に戻すのかを1、2で指定します。
1はマージの際に取り込みを行った側のコミット、2は取り込まれた側のコミットになります。
自分がどちらの親コミットに戻したいのか確認したいときには、

$ git show <コミットID>
commit コミットID
Merge: 親コミット1 親コミット2

と、上記のようにgit showコマンドで親コミットを調べることができ、親コミット1を選ぶときはparent-numberに1を指定すればよいです。

resetとrevertの違い

#

resetとrevertコマンドの違いとして「元に戻した」という履歴が残るかどうかがあります。revertは修正がコミットログとして残る一方、resetは指定のコミット状態にHEADをリセットするコマンドになっており、その修正はログに残りません。

図2 resetとrevertの違い

よって複数人で開発をしている場合、resetコマンドを使用した結果ほかの開発者とコンフリクトが起きたり、リモートリポジトリの状態と合わなくなりgit push -fで強制プッシュ[5]を行わないといけなくなる場合があります。
複数人で開発する場合はresetコマンドよりrevertコマンドを使った方が安全です。

マージ作業が完全に完了しておらず、コンフリクトの解決中にやっぱりマージ作業やめたい!と思った場合はmergeコマンドの--abortオプションを使用しマージ処理を中断させることができます。

$ git merge --abort

このコマンドはマージ中断状態に使用できるコマンドであり、マージ中断状態とは具体的に.git/MERGE_HEADが存在する状態を指します。
コンフリクトを解消し、最終的にgit commitを行うとマージが完了したとして.git/MERGE_HEADが削除されるため、コミットを行う前に使用可能なコマンドになります。
このコマンドにより作業ディレクトリとインデックスの状態をgit mergeを行う前の状態へ戻すことができます。

さいごに

#

今回ご紹介したコマンドは一部オプションを除いて、Gitの基本的なコマンドライン操作を学べる「Learn Git Branching」というサイトで試すことができます。このサイトでは課題を通してGitコマンドの基本的な勉強と、コマンド操作によってブランチがどのような状態になるのか視覚的に学ぶことができ、Git初学者が勉強するのにおすすめです。

また、Gitでのうっかりミスを未然に防ぐためにはGit hookを利用するのも効果的かと思います(Git フック)。これはプッシュやコミットの際に指定のスクリプトを実行するよう設定できるもので、mainブランチへの直プッシュを禁止したりコンフリクトマーカがある状態でコミットできないようにするといった設定が組めます。
この記事でGitの操作で焦ることが少しでも減れば幸いです。


  1. popコマンドはスタッシュの作業ディレクトリへの反映と、スタッシュスタックからの削除を行います。スタッシュスタックから削除したくないという場合にはgit stash applyでスタッシュから作業ディレクトリへの反映のみを行い、その後git stash dropでスタッシュスタックからの削除ができます。 ↩︎

  2. プロジェクトのルートディレクトリにはローカルリポジトリの初期化時にGitのメタデータを格納する.gitディレクトリが作成されます。通常git refは.git/refs配下で管理されますが、一部のrefは特別に.git直下で管理されます。HEADもその特別なrefであり、.git/HEADで管理されます。 ↩︎

  3. recursive three-way mergeが行われる一般的な場合です。 ↩︎

  4. resetのオプションはほかに--soft--mixedがあります。それぞれどこまでローカルの状態を残して上書きするかによって使い分けられます。 ↩︎

  5. 漢は黙ってgit push -fは嘘です。-fオプションを使用する際は代わりにより安全な--force-with-lease--force-if-includesオプションを使用しましょう(git push -f が更に安全になる --force-if-includes_id:onk のはてなブログ)。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。