git rebaseを掘り下げる

Gitの核心的な付加価値の1つは履歴を編集する能力にあります。 履歴を聖なる記録として扱う他のバージョン管理システムとは異なり、gitでは履歴を必要に応じて変えられます。 これにより沢山の強力な道具がもたらされ、良いコミット履歴を吟味できるのです。 良いソフトウェア設計の慣習を遵守するためのリファクタリングと同じです。 これらの道具は新参者にとって少々取っ付きにくいことがあり、gitの中級利用者にとってもそういうことがありますが、この手引きでは強力なgit-rebaseの謎を解き明かす手助けをしていきます。

注意事項:公開されているブランチや共有されているブランチや安定しているブランチの履歴を変更することは、一般にはなさらないよう忠告します。 機能ブランチや個人のフォークの履歴については問題なく、まだプッシュしていないコミットの編集はいつでも大丈夫です。 git push -fを使うと、コミットを編集した後に個人のフォークや機能ブランチに変更をプッシュできます。

警告で脅かしはしましたが、この手引きにある全てのことは非破壊的であるということは、書いておいた方が良いでしょう。 実はgitで永久にデータを喪失するのはかなり難しいのです。 間違いを犯したときに物事を修正することについてはこの手引きの末尾で押さえられています。

実験環境を準備する

実際のリポジトリを散らかしたくはないので、この手引きでは一貫して実験環境リポジトリを扱うことにします。 以下のコマンドを走らせて、始める準備をしてください。

git init /tmp/rebase-sandbox
cd /tmp/rebase-sandbox
git commit --allow-empty -m"Initial commit" # 1

困ったことになったら、rm -rf /tmp/rebase-sandboxとして、上記の手順を再び振り出しから走らせるだけです。 この手引きのそれぞれの節では、さらの実験環境から実行できるため、それより前の節の内容をやり直す必要はありません。

目次

  1. 最後のコミットを訂正する
  2. 比較的古いコミットを整頓する
  3. git rebase --autosquashを使う
  4. 複数のコミットを1つに押し込む
  5. 1つのコミットを複数に分割する
  6. コミットの順番を変える
  7. git pull --rebase
  8. git rebaseを使って……リベースする
  9. 競合を解決する
  10. 壊してしまった、どうしよう!

最後のコミットを訂正する

最新のコミットを直すという、単純なところから始めましょう。 実験環境にファイルを追加して、間違えてみます。

echo "Hello wrold!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

この誤りを直すのはかなり簡単です。 以下のように、ファイルを直して--amendでコミットするだけです。

echo "Hello world!" >greeting.txt
git commit -a --amend

-aを指定すると、gitが既知の全ファイルを自動的にステージ(つまりgit add)し、--amendとすると変更を最新のコミットに押し込みます。 保存してエディタを終了してください(このときコミット文言を変える機会があります)。 git showを走らせると修正されたコミットを表示できます。

commit f5f19fbf6d35b2db37dcac3a55289ff9602e4d00 (HEAD -> master)
Author: Drew DeVault <sir@cmpwn.com>
Date:   Sun Apr 28 11:09:47 2019 -0400

    Add greeting.txt

diff --git a/greeting.txt b/greeting.txt
new file mode 100644
index 0000000..cd08755
--- /dev/null
+++ b/greeting.txt
@@ -0,0 +1 @@
+Hello world!

比較的古いコミットを整頓する

amendは最も直近のコミットにのみ機能します。 比較的古いコミットを正す必要があるときはどうしましょうか。 以下にしたがって、実験環境の準備から始めましょう。

echo "Hello!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

echo "Goodbye world!" >farewell.txt
git add farewell.txt
git commit -m"Add farewell.txt"

greeting.txtに「world」が欠けているようです。 普通にそれを修正するコミットを書きましょう。

echo "Hello world!" >greeting.txt
git commit -a -m"fixup greeting.txt"

これでファイルは正しくなりましたが、履歴はもっと良くできます。 最後に新規作成したコミットを使って「整頓」しましょう。 これには対話的なリベースという新しい道具を導入する必要があります。 直近3コミットをこの方法で編集するつもりなので、git rebase -i HEAD~3-iは対話的にするためのもの)を走らせます。 こうすると以下のような内容でテキストエディタが開きます。

pick 8d3fc77 Add greeting.txt
pick 2a73a77 Add farewell.txt
pick 0b9d0bb fixup greeting.txt

# Rebase f5f19fb..0b9d0bb onto f5f19fb (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# f, fixup <commit> = like "squash", but discard this commit's log message

これはリベースの計画であり、このファイルを編集することで、gitに履歴を編集する方法を伝えられます。 ここでは今回手引きで関係する詳細だけに説明を端折っていますが、遠慮せずテキストエディタで全体の内容に目を通してください。

エディタを保存して閉じると、gitはこれらのコミットを履歴から削除し、1行ずつ実行します。 既定ではそれぞれのコミットを拾い、山からつまみ出してブランチに加えます。 このファイルを全く編集しなければ、全てのコミットをそのまま拾う訳ですから、振り出しに戻ります。 ここでは筆者のお気に入りの機能の1つであるfixupを使います。 3行目を編集して操作を「pick」から「fixup」に変更し、「整頓」したいコミットの直後に移動します。

pick 8d3fc77 Add greeting.txt
fixup 0b9d0bb fixup greeting.txt
pick 2a73a77 Add farewell.txt
豆知識:「f」と縮めることもできます。手際良くなっていきましょう。

エディタで保存して終了すると、gitはこれらの指令を走らせます。 ログを確認すると結果を検められます。

$ git log -2 --oneline
fcff6ae (HEAD -> master) Add farewell.txt
a479e94 Add greeting.txt

git rebase --autosquashを使う

上記の工程はもっと自動的なやり方にもできます。 git rebase--autosquashオプションとgit commit--fixupオプションの組み合わせを活用するのです。

git commit -a --fixup HEAD^
git rebase -i --autosquash HEAD~3

こうすると、並び替わって動作が設定されたコミットを含むリベースの計画が用意されます。

pick 8d3fc77 Add greeting.txt
fixup 0b9d0bb fixup! Add greeting.txt
pick 2a73a77 Add farewell.txt

# Rebase f5f19fb..0b9d0bb onto f5f19fb (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# f, fixup <commit> = like "squash", but discard this commit's log message
        

--fixupに加えてgit commit--squashオプションもあり、こちらはコミット文言を編集できます。

最後に、構成でこの挙動を既定として設定すれば、--autosquashオプションを省けます。

git config --global rebase.autosquash true

複数のコミットを1つに押し込む

作業中、小さな中間目標に到達したり、以前のコミットの不具合を修正したりしつつ、沢山のコミットを書くのが便利だと思うこともあるでしょう。 しかしこうしたコミットを一緒くたに「押し込ん」で、作業内容をmasterにマージする前に履歴をより明快にできると便利そうです。 このためには「squash」操作を使います。 沢山のコミットを書くところから始めましょう。 ショートカットとして以下を切り貼りするだけで大丈夫です。

git checkout -b squash
for c in H e l l o , ' ' w o r l d; do
    echo "$c" >>squash.txt
    git add squash.txt
    git commit -m"Add '$c' to squash.txt"
done

「Hello, world」と書かれたファイルを作るコミットが沢山ありますね。 ここでも対話的なリベースを開始して一纏めに押し込んでいきましょう。 なお、最初に今回いじるブランチをチェックアウトしました。 こうしておいてgit rebase -i masterとすると、分岐以降の全コミットを手早くリベースできます。 結果は以下の通りです。

pick 1e85199 Add 'H' to squash.txt
pick fff6631 Add 'e' to squash.txt
pick b354c74 Add 'l' to squash.txt
pick 04aaf74 Add 'l' to squash.txt
pick 9b0f720 Add 'o' to squash.txt
pick 66b114d Add ',' to squash.txt
pick dc158cd Add ' ' to squash.txt
pick dfcf9d6 Add 'w' to squash.txt
pick 7a85f34 Add 'o' to squash.txt
pick c275c27 Add 'r' to squash.txt
pick a513fd1 Add 'l' to squash.txt
pick 6b608ae Add 'd' to squash.txt

# Rebase 1af1b46..6b608ae onto 1af1b46 (12 commands)
#
# Commands:
# p, pick <commit> = use commit
# s, squash <commit> = use commit, but meld into previous commit
豆知識:手元のmasterブランチは遠隔のmasterブランチとは独立して進展し、gitはリモートブランチをorigin/masterとして保管します。 こうした仕組みになっていることから、まだ上流からマージしてきていない全てのコミットをリベースする上で、git rebase -i origin/masterがしばしばとても便利な方法となります。

これらの変更全てを最初のコミットに押し込んでいきます。 このためには、最初の行を除く全ての「pick」操作を「squash」に変えます。 以下のような感じです。

pick 1e85199 Add 'H' to squash.txt
squash fff6631 Add 'e' to squash.txt
squash b354c74 Add 'l' to squash.txt
squash 04aaf74 Add 'l' to squash.txt
squash 9b0f720 Add 'o' to squash.txt
squash 66b114d Add ',' to squash.txt
squash dc158cd Add ' ' to squash.txt
squash dfcf9d6 Add 'w' to squash.txt
squash 7a85f34 Add 'o' to squash.txt
squash c275c27 Add 'r' to squash.txt
squash a513fd1 Add 'l' to squash.txt
squash 6b608ae Add 'd' to squash.txt

エディタで保存して閉じると、gitはこれについて一瞬考えを巡らせ、最終的なコミット文言を変更できるよう再びエディタを開きます。 以下のようなものが表れるでしょう。

# This is a combination of 12 commits.
# This is the 1st commit message:

Add 'H' to squash.txt

# This is the commit message #2:

Add 'e' to squash.txt

# This is the commit message #3:

Add 'l' to squash.txt

# This is the commit message #4:

Add 'l' to squash.txt

# This is the commit message #5:

Add 'o' to squash.txt

# This is the commit message #6:

Add ',' to squash.txt

# This is the commit message #7:

Add ' ' to squash.txt

# This is the commit message #8:

Add 'w' to squash.txt

# This is the commit message #9:

Add 'o' to squash.txt

# This is the commit message #10:

Add 'r' to squash.txt

# This is the commit message #11:

Add 'l' to squash.txt

# This is the commit message #12:

Add 'd' to squash.txt

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Sun Apr 28 14:21:56 2019 -0400
#
# interactive rebase in progress; onto 1af1b46
# Last commands done (12 commands done):
#    squash a513fd1 Add 'l' to squash.txt
#    squash 6b608ae Add 'd' to squash.txt
# No commands remaining.
# You are currently rebasing branch 'squash' on '1af1b46'.
#
# Changes to be committed:
#	new file:   squash.txt
#

既定では押し込まれたコミット文言全てが結合したものですが、大抵はこのままにしておきたくありません。 新しくコミット文言を書くときに、古いコミット文言が参照用として役に立つかもしれませんが。

豆知識:前節で学んだ「fixup」命令は今回の用途でも使えます。 ただし押し込まれたコミットの文言は捨てられます。

全てを削除し、より良いコミット文言で置き換えましょう。 以下のようにします。

Add squash.txt with contents "Hello, world"

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Sun Apr 28 14:21:56 2019 -0400
#
# interactive rebase in progress; onto 1af1b46
# Last commands done (12 commands done):
#    squash a513fd1 Add 'l' to squash.txt
#    squash 6b608ae Add 'd' to squash.txt
# No commands remaining.
# You are currently rebasing branch 'squash' on '1af1b46'.
#
# Changes to be committed:
#	new file:   squash.txt
#

エディタで保存して終了し、git logを調べてください。 うまくいきましたね。

commit c785f476c7dff76f21ce2cad7c51cf2af00a44b6 (HEAD -> squash)
Author: Drew DeVault <sir@cmpwn.com>
Date:   Sun Apr 28 14:21:56 2019 -0400

    Add squash.txt with contents "Hello, world"

先に進む前に、masterブランチに今回の変更を取り込んで、この下書きを除去しておきましょう。 git mergeのようにgit rebaseを使えますが、マージコミットは作成されません。

git checkout master
git rebase squash
git branch -D squash

一般に、本当に無関係な履歴を統合するのでない限りは、git mergeは使わない方が良いです。 2つの相異なるブランチがあるとき、git mergeは記録をするのに役立ちはするのですが、……その記録の内容はいつそれがマージされたかというものです。 通常の作業工程に於いては、大抵リベースの方が適切です。

1つのコミットを複数に分割する

時に逆の問題が起こることがあります。 1コミットが単に大き過ぎるのです。 分割する方法を見ていきましょう。 ここでは実際にコードを書いてみます。 単純なCプログラム2から始めます(ここでも手早く以下のスニペットをシェルに切り貼りすれば良いです)。

cat <<EOF >main.c
int main(int argc, char *argv[]) {
    return 0;
}
EOF

まずはコミットします。

git add main.c
git commit -m"Add C program skeleton"

次に、プログラムを少し拡張します。

cat <<EOF >main.c
#include <stdio.h>

const char *get_name() {
    static char buf[128];
    scanf("%s", buf);
    return buf;
}

int main(int argc, char *argv[]) {
    printf("What's your name? ");
    const char *name = get_name();
    printf("Hello, %s!\n", name);
    return 0;
}
EOF

これをコミットしたら、分割する方法を学ぶ支度ができています。

git commit -a -m"Flesh out C program"

初めの一歩は対話的なリベースです。 両方のコミットをgit rebase -i HEAD~2でリベースしましょう。 こうすると以下のリベースの計画を貰います。

pick 237b246 Add C program skeleton
pick b3f188b Flesh out C program

# Rebase c785f47..b3f188b onto c785f47 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# e, edit <commit> = use commit, but stop for amending

2つ目のコミットの指令を「pick」から「edit」に変更し、保存してエディタを閉じます。 Gitはこれについてちょっと考えて、以下を差し出します。

Stopped at b3f188b...  Flesh out C program
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

この説明の通りに新しい変更をコミットに追加できますが、その代わりにgit reset HEAD^を走らせて「ソフトリセット」3しましょう。 それからgit statusを走らせると、最新のコミットが取り消され、作業木に変更が追加されたことが分かります。

Last commands done (2 commands done):
   pick 237b246 Add C program skeleton
   edit b3f188b Flesh out C program
No commands remaining.
You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.
  (Once your working directory is clean, run "git rebase --continue")

Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git restore ..." to discard changes in working directory)

  modified: main.c

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

分割には対話的なコミットをしていきますが、これにより作業木から特定の変更だけを選んでコミットできます。 git commit -pと処理を走らせると、以下のプロンプトが現れます。

diff --git a/main.c b/main.c
index b1d9c2c..3463610 100644
--- a/main.c
+++ b/main.c
@@ -1,3 +1,14 @@
+#include <stdio.h>
+
+const char *get_name() {
+    static char buf[128];
+    scanf("%s", buf);
+    return buf;
+}
+
 int main(int argc, char *argv[]) {
+    printf("What's your name? ");
+    const char *name = get_name();
+    printf("Hello, %s!\n", name);
     return 0;
 }
Stage this hunk [y,n,q,a,d,s,e,?]? 

Gitはコミットを検討している「ハンク」(つまり単一の変更)を1つだけ示しています。 ですがこれでは大き過ぎます。 「s」指令を使い、より小さい部品にハンクを「分割」しましょう。

Split into 2 hunks.
@@ -1 +1,9 @@
+#include <stdio.h>
+
+const char *get_name() {
+    static char buf[128];
+    scanf("%s", buf);
+    return buf;
+}
+
 int main(int argc, char *argv[]) {
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? 
豆知識:その他の選択肢が気になったら、「?」を押すと要約が出ます。

このハンクは良さそうです。 単一で自己完結している変更です。 質問に「y」を押して答えて(これで「ハンク」がステージされます)、それから「q」として対話的なセッションを「終了」するとコミットへ向かいます。 エディタが立ち上がり、相応しいコミット文言を入力するよう尋ねられます。

Add get_name function to C program

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# interactive rebase in progress; onto c785f47
# Last commands done (2 commands done):
#    pick 237b246 Add C program skeleton
#    edit b3f188b Flesh out C program
# No commands remaining.
# You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.
#
# Changes to be committed:
#	modified:   main.c
#
# Changes not staged for commit:
#	modified:   main.c
#

エディタで保存して閉じたら、2つ目のコミットを作ります。 また別途対話的なコミットもできますが、このコミットの残りの変更を含めさえすれば良いので、以下とするだけで大丈夫。

git commit -a -m"Prompt user for their name"
git rebase --continue

最後のコマンドはgitにこのコミットの編集が完了したことを伝えており、次のリベースの指令へ続けさせています。 以上です! git logを走らせて仕上がりを見てみましょう。

$ git log -3 --oneline
fe19cc3 (HEAD -> master) Prompt user for their name
659a489 Add get_name function to C program
237b246 Add C program skeleton

コミットの順番を変える

これはとても簡単です。 実験環境を立ち上げるところから始めましょう。

echo "Goodbye now!" >farewell.txt
git add farewell.txt
git commit -m"Add farewell.txt"

echo "Hello there!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

echo "How're you doing?" >inquiry.txt
git add inquiry.txt
git commit -m"Add inquiry.txt"

git logはこのようになっているでしょう。

f03baa5 (HEAD -> master) Add inquiry.txt
a4cebf7 Add greeting.txt
90bb015 Add farewell.txt

明らかに順番が滅茶苦茶です。 過去3つのコミットの対話的なリベースをして解決しましょう。 git rebase -i HEAD~3を走らせるとリベースの計画が現れます。

pick 90bb015 Add farewell.txt
pick a4cebf7 Add greeting.txt
pick f03baa5 Add inquiry.txt

# Rebase fe19cc3..f03baa5 onto fe19cc3 (3 commands)
#
# Commands:
# p, pick <commit> = use commit
#
# These lines can be re-ordered; they are executed from top to bottom.

ここでの修正は直感的です。 コミットが現れてほしい順番に行を並び換えるだけです。 このような感じになるでしょう。

pick a4cebf7 Add greeting.txt
pick f03baa5 Add inquiry.txt
pick 90bb015 Add farewell.txt

エディタで保存して閉じると後はgitが片付けてくれます。 なお、実践でこれをすると競合に出喰わす可能性があります。 競合の解決策についてはこちらをクリックしてください

git pull --rebase

リモートがoriginであるとして、上流で更新のあったブランチ<branch>にコミットを書いている場合、通常git pullはマージコミットを作成します。 この点でgit pullの既定の挙動は以下と等価です。

git fetch origin <branch>
git merge origin/<branch>

これはローカルブランチ<branch>originリモートの<branch>を追跡するように構成されていることを前提にしています。 つまりは以下です。

$ git config branch.<branch>.remote
origin
$ git config branch.<branch>.merge
refs/heads/<branch>

他の選択肢もあります。 大抵はgit pull --rebaseの方がもっと便利で遥かに綺麗な履歴になります。 マージする手法とは違い、ほぼ4以下と等価です。

git fetch origin
git rebase origin/<branch>

マージする方法はより単純で理解するのが簡単ですが、git rebaseの使い方を理解してしまえば、git rebaseの方法こそがやりたいことでしょう。 気に入ったら以下のように既定の挙動に設定できます。

git config --global pull.rebase true

このようにした場合、技術的には次節で議論する手順を適用していることとなります……ので、これが何を意味しているのかについても是非ともご説明しましょう。

git rebaseを使って……リベースする

皮肉なことに、筆者が一番使わないgit rebaseの機能は、その名が示す「ベースを移す」ことなのです。 以下のブランチがあるとしましょうか。

A--B--C--D--> master
   \--E--F--> feature-1
      \--G--> feature-2

feature-2は、コミットEにあることからも、feature-1のいかなる変更にも依存していないと分かります。 そのため、単にmasterにベースを移し替えるだけで良いのです。 したがって修正は以下となります。

git rebase --onto master feature-1 feature-2

非対話的なリベースは、全ての関与するコミットについて既定の操作(「pick」5)をします。 これは単純に、feature-2にあるコミットのうち、feature-1にないものをmasterの先頭で再生します。 履歴は以下のようになります。

A--B--C--D--> master
   |     \--G--> feature-2
   \--E--F--> feature-1

競合を解決する

マージでの競合を解決することの詳細はこの手引きの範疇を越えています。 以後、この話題については別の手引きをあたってください。 一般的な競合の解決に慣れている前提で言うと、以下はリベースを適用する際の処方箋です。

時にリベースをしているときにマージでの競合が出ることがあります。 これは他のマージでの競合と全く同じように対処できます。 Gitは影響するファイルに競合の印を付け、git statusとすると解決する必要のある箇所が示されます。 またgit addないしgit rmで、解決済みのものとしてファイルに印を付けられます。 しかし、git rebaseの文脈に於いては、幾つかの注意すべきオプションがあります。

1つ目は競合解決の完結方法です。 git mergeで生じる競合を解決するときに使うであろうgit commitとは異なり、リベースでの適切なコマンドはgit rebase --continueです。 しかし、git rebase --skipという他の選択肢もあります。 こちらは作業しているコミットを飛ばし、リベースには含めません。 非対話的なリベースをしているときにはよくあることで、「other」のブランチから引っ張ってきたコミットが「our」のブランチと競合しているコミットの更新版であることをgitが認識しない場合がそうです。

Gitが再生に失敗したコミットの差分を見てみたければ、そしてそれがマージでの競合となっている場合には、git rebase --show-current-patchまたはそれと等価なgit show REBASE_HEADが使えます。

最後に特筆すべきこととして、git checkout --oursまたは--theirsを使って目録から特定の版をチェックアウトして競合するパスを手早く解決する場合、その選択肢の意味するところは通常のgit mergeのマージでの競合と比較して逆転しています。 リベースに於いては--theirsはリベース元のブランチ (REBASE_HEAD) 由来の変更を指しており、--oursはリベースする先のブランチ (HEAD) を指しているのです6

壊してしまった、どうしよう!

無理もありません。 リベースは時に難しいものです。 失敗を犯してしまって、必要なコミットを失いかけているなら、git reflogとすれば取り返しが付きます。 このコマンドを走らせると、refつまり参照を変更した全操作を表示します。 この参照とはブランチやタグのことです。 各行に古い参照が指すものが表示され、一時は失われたかに思われたgitのコミットに対し、git cherry-pickgit checkoutgit showなどで操作できます。