Git の Interactive rebase で自在にコミット履歴を操る

Simple rebase ( `-i` なしの rebase ) を取り扱った経験があるけど、interactive rebase で自分好みにコミット履歴を操ったことがない、という人向けに、Interactive Rebase ではこういうことができるぜ、というのを紹介していく記事です[^x-footnotes]。

この記事はゆめみ 24 卒アドカレの 6 日目です![^x-advent] 他の方が書かれた記事もぜひご覧ください。

# 復習

Rebase をするにあたって知っておきたいことについてさらっとまとめています。ここに記されている内容がよくわからない場合は、事前に調べておくとよりよいかと思います!

[ブランチや rebase については大丈夫だぜという方は、はじめての Interactive Rebase 節まで飛ばしていただければと思います!](#はじめての-interactive-rebase)

## ブランチ
たとえば、次のコマンドラインたちを実行して、Git のドキュメンテーション[^ref] から引用した次のコミットツリーを作り上げたとします。

```bash
git checkout -b master
git commit --allow-empty -m "D"
git commit --allow-empty -m "E"
git checkout -b topic
git commit --allow-empty -m "A"
git commit --allow-empty -m "B"
git commit --allow-empty -m "C"
git checkout master
git commit --allow-empty -m "F"
git commit --allow-empty -m "G"
```

```text:コミットツリー
          A---B---C topic
         /
    D---E---F---G master
```

ブランチというのはこの図のうちどれかというと `topic`, `master` ですが、じゃあその実体 (= データとしてのすがた) は何かと言うと、図における線自体やコミットの範囲*ではなく*[^x-branch-name]、**コミットを指し示す軽量 (lightweight) な[^x-lightweight?]ポインタ**のことをいいます。もっと簡単に言うと、**ブランチは特定のコミットを指し示すためのピン📌**とかとも言えるかもしれません。

詳細はこちらの記事を参照するとよく分かるかと思います!

https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell

https://blog-smatsuzaki.hatenablog.com/entry/2020/02/23/105624

## Rebase

Rebase はコミットをブランチの先頭に作り直す (Reapply commits on top of another base tip)[^ref] ことらしいです。といってもこれは simple rebase の話で、`-i` を使って Interactive rebase を使うと既存のコミットを変更/削除したりすることができます。

Simple rebase について、簡単な Example を Git のドキュメンテーション[^ref]から引用して復習します。このようなコミットツリーがあるリポジトリで……

```
          A---B---C topic
         /
    D---E---F---G master
```

`topic` ブランチにいるときに……

```bash
$ git rebase master
```

を実行すると………

```
                  A'--B'--C' topic
                 /
    D---E---F---G master
```

のように、topic ブランチの履歴上にあるコミットを[^x-commit] master ブランチの履歴の先頭に持ってくることができます。

### rebase する必要がないときに rebase する

先程のように、topic ブランチ上のコミットツリーがすでに master の先頭から生えているときでも、master の上に rebase するということも一応できます。

Simple rebase の場合は、(ほぼ) 特に何も起こりません。いえ、コミットハッシュや Commit date は変わるのですが、肝心のコードベースやコミットツリーには特に大きな影響はありません。
変な話なように聞こえますが、Simple rebase のときは確かにコミットハッシュが変わるだけで深い意味はありませんが (たぶん)、Interactive rebase のときはよくされます。

#### 具体的にどう動く?

このようなブランチを考えます。

```:コミットツリー
                  A'--B'--C' topic, HEAD
                 /
    D---E---F---G master
```

ここで、これを実行すると……

```bash
git rebase master
```

```:Rebasing (1/3)
                  A'--B'--C' topic
                 /
    D---E---F---G master
                 \
                 A'' HEAD
```

```:Rebasing (2/3)
                  A'--B'--C' topic
                 /
    D---E---F---G master
                 \
                 A''-B'' HEAD
```

```:Rebasing (3/3)
                  A'--B'--C' (旧 topic)
                 /
    D---E---F---G master
                 \
                 A''-B''-C'' topic, HEAD
```

のように動作し、似たコミットツリーが誕生します。

# はじめての Interactive Rebase

Interactive rebase とは、コミットを別ブランチの先頭に乗せるときに、**どのように乗せるか**を指定できるモードです。

```
                  A---B---C topic
                 /
    D---E---F---G master
```

例えば、こちらの `topic` ブランチを `master` ブランチの上に Rebase するとします。すでに `topic` ブランチは `master` ブランチの上に乗っかっていますが……

- 🧑‍💻「コミット B のメッセージタイポしてもた…… てか、もっと具体的でもっと良いコミットメッセージにしたほうがええなぁ……」
- 🧑‍💻「コミット C も、コミット B で修正し忘れた typo の修正やから、B とひとまとめにしてまいたいな」

というように、コミット履歴をいじりたくなったときに使える rebase です。

## これをやってみる
試しに、以下の変更を履歴に加えてみましょう:

- コミット B のコミットメッセージを別のものに変更する
- コミット C をコミット B に含める

##### 0. コミットツリーを確認する

冒頭に示したスクリプトでコミットツリーを作ると、このようなコミットツリーとなっているはずです:

```bash
$ git log --oneline --graph
* 932c987 (HEAD -> master) G
* 188ff71 F
| * 5db0352 (topic) C
| * 47bfc71 B
| * 9e4be7d A
|/
* eb9faa0 E
* b3ac56b D
```

##### 1. Interactive Rebase を始める

`topic` ブランチにいるときに、以下のコマンドを実行すると……

```bash
$ git rebase -i master
```

このようなコンテンツが含まれたファイルが開かれます[^x-interactive]:

```bash
pick 12345678 A
pick 90abcdef B
pick 01234567 C

# ... 下に多くの説明が含まれている
```

:::note info
ここでエディタが開かなかった人は、Linux 系の場合 `EDITOR` 環境変数の設定が必要かもしれません。こうするとうまくいくかもしれません:
```bash
$ export EDITOR=$(which vim)
$ git rebase -i master
```

また、冒頭に示したコマンドでコミットツリーを作られた方は `# empty` というテキストがコミットメッセージの横に出ているかと思います (単語の意味そのままで、Diff がないコミットという意味です)。
:::


##### 2. どのようにコミット履歴を変更するかを設定する

詳しくは後述しますが、このように変更すると上に提案したことを適用できます。

```bash
pick 12345678 A
reword 90abcdef B
fixup 01234567 C

# ... 下に多くの説明が含まれている
```

##### 3. 実際に rebase を開始する

ファイルを閉じてエディタを閉じると、設定した通りの Rebase を開始してくれます。

途中、コミット B のコミットメッセージを編集するための画面が表示されます:

```
B

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
```

`-m` オプションをつけずに `git commit` した後の画面と同じなので、コミットメッセージを設定するいつも通りの方法でコミットメッセージを変更することができます。

#### 4. Rebase 結果を確認する

上のエディタでコンテンツを保存して消すと、以下のようなコミットメッセージが表示されます。これで Interactive Rebase は完了です!

```
Successfully rebased and updated refs/heads/topic.
```

コミット履歴を確認すると、以下のようになっているのが分かるかと思います:

```bash
$ git log --oneline --graph --all
* 2e5c1c8 (HEAD -> topic) New B
* 1effbf9 A
* a5fea92 (master) G
* 84f96cf F
* 7d8678c E
* d384119 D
```

わかりにくいですが、つまりこうです:

```bash
                  A---New B topic
                 /
    D---E---F---G master          # A を G の横に移動すると、
                                  # 上と似たような感じになります
```

- コミット B だったもののコミットメッセージが変わっていて、
- コミット C だったものが消えている

ことが分かるかと思います! （コミット C は消えたわけではなく `New B` に吸収されたたので消えているように見えています。例がわかりにくい………）

# Interactive Rebase でいろいろやる

**本題です!** Interactive Rebase でコミット履歴をいろいろいじってみます。

## 基本: todo リストを知る

先程 `git rebase -i master` した直後に編集したファイルについてです。
先程、このようなファイルを編集しました:

```bash
pick 12345678 A
pick 90abcdef B
pick 01234567 C

# ... 下に多くの説明が含まれている
```

これは、このような構成をしています:

```bash
[操作] [ハッシュ] [コミットメッセージ]
[操作] [ハッシュ] [コミットメッセージ]
[操作] [ハッシュ] [コミットメッセージ]
# ... (# から始まる行はコメント)
```

:::note warn
todo リストでは、**上から下** に向かって新しいコミットが来ます。**`git log` の順番とは逆です!**
:::

操作の定義は、コミットの一覧の下にコメントとして書いてあります。

## いろいろやってみる

#### 0. Rebase を続ける/やっぱりやめる

Rebase 中に Ctrl+C した場合や、どこかでコンフリクトした場合、`edit` や `break` で止めた場合等、`git rebase` コマンドが Rebase  途中で止まることがあります。その場合、どうすればいいかは **`git status` でだいたい確認できます**が、だいたい `git rebase --continue` でまかり通ります。

一方で、Rebase を中断して全部やり直したい場合は以下のコマンドを実行すると元通りになります:

```
git rebase --abort
```

「ん? コンフリクトが多すぎるな　やっぱこの変更したくねえ」ってなったときなんかに便利です!

#### 1. コミットを入れ替える

Todo リストで行を入れ替えると、**コミットの履歴上の位置も入れ替える**ことができます。

```diff:Diff
+pick 90abcdef B
 pick 12345678 A
-pick 90abcdef B
 pick 01234567 C
```

ただし、入れ替えたコミットが別のコミットの変更に依存していた場合（例えば今回の場合、 `A` で作ったファイルを `B` が変更している場合）、コンフリクトの嵐となってつらいことになります。

#### 2. コミットを消す

行自体を消すとそのコミットを**無かった**ことにできます。

```diff:Diff
 pick 12345678 A
-pick 90abcdef B
 pick 01234567 C
```

#### 3. コミットをいじる

`edit` コマンドを使うと、コミットの内容をいじることができるようになります。

```diff:Diff
 pick 12345678 A
-pick 90abcdef B
+edit 90abcdef B
 pick 01234567 C
```

こうすると、**`B` が履歴に追加された後に `git rebase` コマンドが一旦終了し**、好きにコミットを変更できるようになります。この状態で `git commit --amend` 等をしてコミットの中身自体を編集することができます！変更が終わったら、`git rebase --continue` でまた Rebase の続きを始めることができます。

#### 4. コミットを分ける

履歴の奥底にあるコミットを分けることを考えてみます。

```diff:Diff
 pick 12345678 A
-pick 90abcdef B
+edit 90abcdef B
 pick 01234567 C
```

こうするとまた B が再度コミットされた直後にコマンドが一旦終了します。**ここで、ひとつ前のコミットに `git reset` します**:

```bash
$ git reset --soft HEAD~
```

すると、**`B` でしていた変更がワーキングツリーに入り、HEAD がそのひとつ前の `A` になります!** この状態で、`git add` や `git app -p` などを振り回して、`git commit` でコミットを追加することで、(実質的に) **コミット B を分割する**ということができます!
ふたたび、`git rebase --continue` を用いることで新しく追加したコミットの上で Rebase の続きをすることができます。

#### 5. コミットとコミットをくっつける

たとえば、(先程の例のような) コミット B の変更内容がコミット A の変更内容の細かい訂正でしか無いような場合、`squash` や `fixup` を用いてコミットを融合させることができます。

##### `squash` の場合

`squash` を使った場合は、途中コミットメッセージの設定の画面になります。

```diff
 pick 12345678 A
-pick 90abcdef B
+squash 90abcdef B
 pick 01234567 C
```

```bash
# This is a combination of 2 commits.
# This is the 1st commit message:

A

# This is the commit message #2:

B

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
```

この画面で、どちらかを消したり新しい適切なコミットメッセージを設定することができるようになります。

##### `fixup` の場合

`fixup` は `squash` と同じですが、**`squash` の一個前のコミットのコミットメッセージを自動的に使います。** なので……

```diff
 pick 12345678 A
-pick 90abcdef B
+fixup 90abcdef B
 pick 01234567 C
```

とすると、**特に何もコミットメッセージの入力が求められずに、自動的に `B` がコミット `A` に吸収されるような形**になります。

:::note info
`squash` や `fixup` を連続させることも可能です。その場合、コミットメッセージは……

- `squash` の場合は、`squash` が連続しているコミットとその一個前のコミットのメッセージが、コミットメッセージ設定時に利用できます。
- `fixup` の場合は、先頭の `fixup` の一個前のコミットのメッセージが使用されます。
:::

#### 6. コミットの情報を自動的にいじる

もっといい方法がありそうですが…… たとえば、メールアドレスを変えたなどで Committer / Author を変えたい場合、次のようなことができます:

```diff
 pick 12345678 A
 pick 90abcdef B
+exec git commit --allow-empty --amend --no-edit --author "Name <example@example.invalid>"
 pick 01234567 C
+exec git commit --allow-empty --amend --no-edit --author "Name <example@example.invalid>"
```

こうすると、変更したい各コミットに対して `--author` 引数付きの `--amend` を自動的にすることができ、各コミットを `edit` するよりも人の手を必要とせずに進めることができます!

:::note alert
悪用はしないでください! また、このような悪用から身を守るために GPG 鍵による署名を設定しておくと安心できます。
:::

:::note info
`--allow-empty` は、冒頭のスクリプトでツリーを作った場合各コミットが空なため、つけないとエラーになるために使用しています。普通はいらないはずです!
:::

絶対もっといい方法があるはずなので、どなたかご存知でしたらご教示いただきたいです……!

# おわりに
このように、Interactive Rebase を使うといろいろな形でコミット履歴を変更することができます！コミット履歴が整理できると、GitHub の Pull Request で Merge commit を作る形でコミットをしても、main ブランチのコミット履歴が大変なことになることなくマージすることができます。Rebase を積極的に、そして用法を守って活用して良きな履歴を作って、平和な Git ライフを送ることができればと思います!!

---
# Appendix

### Rebase に失敗した時

#### 前提: Rebase してもコミットが「消滅する」わけではない

前提として、Rebase をしても元のコミットは消滅しません (が、どのブランチからも参照されない状態になるかもしれません)。例えば、先程の例だと rebase 後はこのようになると説明しました:

```
                  A'--B'--C' topic
                 /
    D---E---F---G master
```

ですが元のコミットは消滅しないため、実際にはこうなっています[^x-abstraction]:


```
                  A'--B'--C' topic
                 /
    D---E---F---G master
        \
         A---B---C (旧 topic)
```

なので、元のコミットのハッシュがわかればブランチが参照するコミットを元に戻してブランチを rebase する前の状態に戻すことができます。例えば、コミット `C` のハッシュが `12345678`... であった場合、`topic` ブランチ上で、

```bash
$ git reset --hard 12345678
```

とすると、

```
                  A'--B'--C' (旧新topic)
                 /
    D---E---F---G master
        \
         A---B---C topic
```

とでき、topic については元の状態に戻すことができるようになります。

#### 失敗した時用にブランチを取っておく

```bash
git branch backup
```

などで、rebase 前のコミットでブランチを切っておくと、万が一大失敗したときにコミットハッシュの代わりにこのブランチを使って reset することができます。

#### コミットハッシュを持っていない
失敗すると思っていなかった rebase で失敗したとかでハッシュを持っていなかったとしても、reflog で探せるかもしれません。

```
git reflog
```

Rebase の各ステップごとに reflog のエントリが増えるので探すのが少し大変かもしれませんが、このコマンドがあれば確実に元のコミットがどこだったかを探すことができます[^x-lazygit]。




<!------- FOOTNOTES ---------->

<!-- References -->
[^ref]: https://git-scm.com/docs/git-rebase

<!-- Footnote Comments -->
[^x-footnotes]: 本文で触れるほどでは無いながらも重要だったりわりとどうでもいいわたしの考えとかを foodnotes で記述したりしています。参考にした記事等もこちらで参照しています。あまり技術記事を書いたことがないので、至らない点が多くあるかと思いますが、コメントや編集リクエスト等いただけますと嬉しいです!
[^x-advent]: 今アドカレ公開日の夜で、しかも電車の中です。やばいです!!
[^x-commit]: あるブランチにあるコミットというのは難しいかもしれません。 `master` にはなくて、`topic` にはあるブランチを `master` の先頭に持ってくるというのが正しい理解な気がします。
[^x-abstraction]: たぶんあまり正確に書きすぎて煩雑になってもしょうがないので省略していたんだと思います。でも大事なはなし
[^x-lazygit]: Lazygit という Git を TUI 上で管理できるツールがありますが、これにも reflog を漁る機能がついています。漁る上で結構便利なので、おすすめです! ([jesseduffield/lazygit](https://github.com/jesseduffield/lazygit))
[^x-lightweight?]: 「単純な」「簡単な」「簡易な」等とも読み替えられます。たしかに、実態はコミットのハッシュが書いてあるテキストファイルなだけなので、とても簡素です
[^x-branch-name]: Branch (枝) という名前をしていますが、違います! どちらかというと、"分岐" という意味としての branch が正しい気がします。
[^x-interactive]: Rebase 前にユーザにどうするかの確認が入るので Interactive という名前なんだと思います!