Gitを使っていると、1つの機能追加が複数のコミットに分かれていたり、1つのコミットに関係ない修正が2つ以上含まれてしまっていたりすることが良くあると思います。
個人的な理想はリリース用ブランチのコミット (のタイトル) を見るだけで、プロダクトにいつ・どんな修正が加わったか分かるようにしたいのですが、そのためにはコミットの粒度を適切に分割・統合する必要があります。
git rebase
を使えば、連続していない複数のコミットを1つにまとめたり、1つのコミットを2つ以上に分割することが出来ます。
普段コミットのタイトルや粒度を意識してなかった人も、ぜひここで紹介するテクニックを使って、レビュー前にコミットを分かりやすく整理してみて欲しいと思います。
またこのテクニックを覚えると、余計なコミットを作るのを避けてギリギリまでgit commit
出来なかった人も、一時保存のために気軽にgit commit
が出来るようになると思います。
rebase練習用リポジトリとコミットの作成
今回は実際にrebaseでのコミットの分割・統合を経験してもらえるよう、練習用のリポジトリとコミットを作って、これを操作する前提で解説していきます。
練習用のコミットして、下記のコミットを作っていきます。
- commit1: A機能用ソースファイル追加
- commit2: A機能の説明をREADMEに追記
- commit3: READMEにタイプミス発覚→修正
- commit4: B機能用ソースファイル追加
- commit5: B機能の説明をREADMEに追記
- commit6: B機能に仕様変更発生→ソース修正
- commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
- commit8: A機能でバグ発覚→A機能のソース修正
#練習用リポジトリ作成
mkdir git-rebase
cd git-rebase/
git init
#first commit作成
touch README.md
git add README.md
git commit -m "first commit"
#開発用ブランチ作成 (メインブランチからforkした機能開発用のブランチ)
git switch -c develop
git branch
* develop #←現在developブランチで作業していることを確認 (*がアクティブなブランチ)
master
#commit1作成
echo "feat A" > featA.src
git add featA.src
git commit -m "commit1: A機能用ソースファイル追加"
#commit2作成
echo "fead A's README" >> README.md
git add README.md
git commit -m "commit2: A機能の説明をREADMEに追記"
#commit3作成
sed -i "s/fead/feat/g" README.md
git add README.md
git commit -m "commit3: READMEにタイプミス発覚→修正"
#commit4作成
echo "feat B" > featB.src
git add featB.src
git commit -m "commit4: B機能用ソースファイル追加"
#commit5作成
echo "feat B's README" >> README.md
git add README.md
git commit -m "commit5: B機能の説明をREADMEに追記"
#commit6作成
echo "feat B modified" > featB.src
git add featB.src
git commit -m "commit6: B機能に仕様変更発生→ソース修正"
#commit7作成
git revert HEAD
##エディタが開くので1行目を下記のように修正
commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
##:wq or ZZで上書き保存してエディタを抜ける
#commit8作成
echo "feat A modified" > featA.src
git add featA.src
git commit -m "commit8: A機能でバグ発覚→A機能のソース修正"
#コミットグラフを確認
git log --graph --all --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* b3efbfe (HEAD -> develop) commit8: A機能でバグ発覚→A機能のソース修正
* 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
* b522341 commit6: B機能に仕様変更発生→ソース修正
* 0ce0d64 commit5: B機能の説明をREADMEに追記
* 139de71 commit4: B機能用ソースファイル追加
* 25d6a63 commit3: READMEにタイプミス発覚→修正
* d21ffa1 commit2: A機能の説明をREADMEに追記
* d0290fa commit1: A機能用ソースファイル追加
* b7b9572 (master) first commit
最終的にリポジトリのファイル内容は下記の状態になります。
(#
行が出力内容です。コマンドを区別するために頭に#
を追加しています)
ls | sed "s/^/# /g"
# featA.src
# featB.src
# README.md
cat featA.src | sed "s/^/# /g"
# feat A modified
cat featB.src | sed "s/^/# /g"
# feat B
cat README.md | sed "s/^/# /g"
# feat A's README
# feat B's README
rebase作業の前準備
今回はコミット履歴を改変するテクニックを紹介しますので、失敗すると元に戻せなくなるおそれがあります。
rebaseの途中であれば、git rebase --abort
でなかったことに出来ますが、rebaseを完了させてしまうと後戻り出来ません。
そこで、現在のブランチはそのままに、rebase作業用のブランチを作ってそこで作業するのが安全です。
現在のブランチを残しておけば、作業後に元のブランチとrebase後のブランチをdiffして、差分が無いことを確認することも出来ます。
git branch
* develop #←現在のブランチを確認 (*がアクティブなブランチ)
master
#rebase作業用にworkブランチを作成してスイッチ
git switch -c work
git branch
develop
master
* work #←現在workブランチで作業していることを確認 (*がアクティブなブランチ)
git diff develop work
#出力なし。まだ何もしてないので差分はない
#これからコミットをこねくり回すが、最終的にこのコマンドで差分が出ないことを確認する
複数のコミットをまとめたり順番を変えたりする
バラバラになっているコミットを適切な単位にまとめましょう。
整理前のコミットがこちら。
git log --graph --all --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* b3efbfe (HEAD -> work, develop) commit8: A機能でバグ発覚→A機能のソース修正
* 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
* b522341 commit6: B機能に仕様変更発生→ソース修正
* 0ce0d64 commit5: B機能の説明をREADMEに追記
* 139de71 commit4: B機能用ソースファイル追加
* 25d6a63 commit3: READMEにタイプミス発覚→修正
* d21ffa1 commit2: A機能の説明をREADMEに追記
* d0290fa commit1: A機能用ソースファイル追加
* b7b9572 (master) first commit
これを下記のように整理します。
- commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
- A機能で1つにまとめる
- タイプミスの修正やリリース前のバグ修正の履歴を公開する必要はないので削除する
- commit4+5: B機能とB機能の説明を含むREADME
- B機能で1つにまとめる
- 「リリース前に一度追加したけど削除した機能」の履歴を公開する必要はないのでcommit6,7は削除する
ここからrebaseに入りますが、途中で操作を間違えた場合はgit rebase --abort
を実行することでここまで巻き戻ることが出来ます。
abort後はここからやり直してください。
git branch
develop
master
* work #←現在workブランチで作業していることを確認 (*がアクティブなブランチ)
#インタラクティブモードのrabaseを起動
##first commitから現在までのコミットを整理したいので、rebase先はmasterを指定する。first commitのコミットIDでも可
##仮にcommit4から現在までを整理したい場合はcommit3のコミットIDをrabase先に指定する
git rebase -i master
#viエディタが起動し、下記のように表示される
pick d0290fa commit1: A機能用ソースファイル追加
pick d21ffa1 commit2: A機能の説明をREADMEに追記
pick 25d6a63 commit3: READMEにタイプミス発覚→修正
pick 139de71 commit4: B機能用ソースファイル追加
pick 0ce0d64 commit5: B機能の説明をREADMEに追記
pick b522341 commit6: B機能に仕様変更発生→ソース修正
pick 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
pick b3efbfe commit8: A機能でバグ発覚→A機能のソース修正
#これを下記のように書き換える
## コミットの順番を変える
## 上と統合したいコミットのpickをsquashに書き換える
## 不要なコミットの行を削除する
pick commit1 A機能用ソースファイル追加
squash commit2 A機能の説明をREADMEに追記
squash commit3 タイプミス発覚→ソース修正
squash commit8 A機能バグ修正
pick commit4 B機能用ソースファイル追加
squash commit5 B機能の説明をREADMEに追記
#:wqまたはZZでviエディタを終了する
コミット整理用のviエディタを閉じると、commit1+2+3+8のコミットメッセージを編集するviエディタが起動します。
内容は下記の通りです。
# This is a combination of 4 commits.
# This is the 1st commit message:
commit1: A機能用ソースファイル追加
# This is the commit message #2:
commit2: A機能の説明をREADMEに追記
# This is the commit message #3:
commit3: READMEにタイプミス発覚→修正
# This is the commit message #4:
commit8: A機能でバグ発覚→A機能のソース修正
コミットメッセージなので内容は何でも良いのですが、今回は下記のように書き換えます。
(実際の開発現場では他の人にも分かりやすいコミットメッセージを書くように心掛けてください)
commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
commit1: A機能用ソースファイル追加
commit2: A機能の説明をREADMEに追記
commit3: READMEにタイプミス発覚→修正
commit8: A機能でバグ発覚→A機能のソース修正
上記の通り編集したら:wq
orZZ
で上書き保存してviエディタを終了してください。
すると次にcommit4+5のコミットメッセージを編集するviエディタが起動します。
内容は下記の通りです。
# This is a combination of 2 commits.
# This is the 1st commit message:
commit4: B機能用ソースファイル追加
# This is the commit message #2:
commit5: B機能の説明をREADMEに追記
こちらも下記ように書き換えてください。
ommit4+5: B機能とB機能の説明を含むREADME
commit4: B機能用ソースファイル追加
commit5: B機能の説明をREADMEに追記
編集を終えたら:wq
orZZ
で上書き保存してviエディタを終了してください。
その後下記のメッセージが表示されたらrabase完了です。
[detached HEAD 014d976] commit4+5: B機能とB機能の説明を含むREADME
Date: Wed Jun 8 07:25:27 2022 +0000
2 files changed, 2 insertions(+)
create mode 100644 featB.src
Successfully rebased and updated refs/heads/work.
コミットグラフを確認すると、整理前に8個だったコミットが2個にまとまっているのが分かります。
git log --graph --all --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* 014d976 (HEAD -> work) commit4+5: B機能とB機能の説明を含むREADME
* 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
| * b3efbfe (develop) commit8: A機能でバグ発覚→A機能のソース修正
| * 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
| * b522341 commit6: B機能に仕様変更発生→ソース修正
| * 0ce0d64 commit5: B機能の説明をREADMEに追記
| * 139de71 commit4: B機能用ソースファイル追加
| * 25d6a63 commit3: READMEにタイプミス発覚→修正
| * d21ffa1 commit2: A機能の説明をREADMEに追記
| * d0290fa commit1: A機能用ソースファイル追加
|/
* b7b9572 (master) first commit
git diff develop work
#出力なし
#rabase前のdevelopブランチとrebase後のworkブランチを比較して差分が無いことを確認する
1つのコミットを2つ以上に分割する
今度は、さっきまとめたコミットを、機能追加とREADME追加で別のコミットに分離してみます。
分割前の状態がこちら。
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* 014d976 (HEAD -> work) commit4+5: B機能とB機能の説明を含むREADME
* 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
* b7b9572 (master) first commit
このうち、commit1+2+3+8を分割して下記のようにしてみます。
- commit1+8: A機能(バグ修正済み)追加
- commit2+3: A機能のREADME追加
- commit4+5: B機能とB機能の説明を含むREADME
まずは今回も安全のために作業用ブランチを作成します。
git branch
develop
master
* work #←現在workブランチで作業していることを確認 (*がアクティブなブランチ)
#分割作業用に新しいブランチを作成する
git switch -c work2
git branch
develop
master
work
* work2
そしてrebaseしていきます。
rebase中に操作を間違えた場合はgit rebase --abort
を実行することでここまで巻き戻ることが出来ます。
abort後はここからやり直してください。
#インタラクティブモードのrabaseを起動
git rebase -i master
#viエディタが起動し、下記のように表示される
pick 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
pick 014d976 commit4+5: B機能とB機能の説明を含むREADME
#これを下記のように書き換える
##分割したいコミットのpickをeditに書き換える
edit 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
pick 014d976 commit4+5: B機能とB機能の説明を含むREADME
#:wqまたはZZでviエディタを終了すると下記のメッセージが表示される
Stopped at 8be3cfc... commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue
ここからコミットを分離していくのですが、厳密には「コミットを分離する」という機能はrabaseにはありません。
ここからはインタラクティブrabaseの状態で、任意の粒度のコミットを手動で作り直していく作業になります。
まず、現在の状態はこのようになっています。
git status
interactive rebase in progress; onto b7b9572
Last command done (1 command done):
edit 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME #←このコミットを適用した状態
(後略)
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* 8be3cfc (HEAD) commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
* b7b9572 (master) first commit
rebaseするときにcommit1+2+3+8をeditにしておいたのでここでrebaseが一時停止しています。
commit1+2+3+8がすでにコミットされていますが、今回はこのコミットを分割したいので、このコミットはresetで無かったことにします。
#git resetで強制的に一つ前の状態まで巻き戻す
##--hardオプション付きでgit resetすると、ファイル内容も含めて指定コミットの状態に戻ります
##HEADは現在位置、^は一つ前を意味します。HEAD^で現在の一つ前という意味になります
git reset --hard HEAD^
HEAD is now at b7b9572 first commit
#HEADが一つ前に戻っていることを確認
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* b7b9572 (HEAD, master) first commit
git log --graph --all --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* 014d976 (work2, work) commit4+5: B機能とB機能の説明を含むREADME
* 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
| * b3efbfe (develop) commit8: A機能でバグ発覚→A機能のソース修正
| * 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
| * b522341 commit6: B機能に仕様変更発生→ソース修正
| * 0ce0d64 commit5: B機能の説明をREADMEに追記
| * 139de71 commit4: B機能用ソースファイル追加
| * 25d6a63 commit3: READMEにタイプミス発覚→修正
| * d21ffa1 commit2: A機能の説明をREADMEに追記
| * d0290fa commit1: A機能用ソースファイル追加
|/
* b7b9572 (HEAD, master) first commit
#git statusでもクリーンなことを確認
git status
interactive rebase in progress; onto b7b9572
Last command done (1 command done):
edit 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
(中略)
nothing to commit, working tree clean #←このメッセージが出ることを確認
これでcommit1+2+3+8を無かったことにしたので、まずは「commit1+8: A機能(バグ修正済み)追加」というコミットを作っていきます。
#commit1+2+3+8 (コミットID: 8be3cfc) からfeatA.srcを復元してコミットする
git restore --source 8be3cfc featA.src
git add featA.src
git commit -m "commit1+8: A機能(バグ修正済み)追加"
[detached HEAD 3e4a18d] commit1+8: A機能(バグ修正済み)追加
1 file changed, 1 insertion(+)
create mode 100644 featA.src
#コミットグラフを確認
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* 2ca3823 (HEAD) commit1+8: A機能(バグ修正済み)追加
* b7b9572 (master) first commit
#現状確認
git status
interactive rebase in progress; onto b7b9572
Last command done (1 command done):
edit 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME #←まだここのedit中
(後略)
これでcommit1+8のコミットが出来上がったので、次は「commit2+3: A機能のREADME追加」を作成します。
#commit1+2+3+8 (コミットID: 8be3cfc) からREADME.mdを復元してコミットする
git restore --source 8be3cfc README.md
git add README.md
git commit -m "commit2+3: A機能のREADME追加"
[detached HEAD 5875d0f] commit2+3: A機能のREADME追加
1 file changed, 1 insertion(+)
#コミットグラフを確認
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* d4a6299 (HEAD) commit2+3: A機能のREADME追加
* 2ca3823 commit1+8: A機能(バグ修正済み)追加
* b7b9572 (master) first commit
#commit1+2+3+8 (コミットID: 8be3cfc) と差分が無いことを確認
git diff 8be3cfc
#出力なし
これでcommit1+2+3+8をcommit1+8とcommit2+3に分割出来たので、rabaseのステータスをcommit1+2+3+8の編集状態から次へと進めます。
#現状確認
git status
interactive rebase in progress; onto b7b9572
Last command done (1 command done):
edit 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME #←まだここのedit中
(後略)
#rebaseのステップを1つ進める
git rebase --continue
Successfully rebased and updated refs/heads/work2.
これでrebaseが完了したので状態を確認します。
git status
On branch work2
nothing to commit, working tree clean
git log --graph --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* a152d3d (HEAD -> work2) commit4+5: B機能とB機能の説明を含むREADME
* d4a6299 commit2+3: A機能のREADME追加
* 2ca3823 commit1+8: A機能(バグ修正済み)追加
* b7b9572 (master) first commit
git log --graph --all --pretty="format:%C(red)%h%C(yellow)%d %C(reset)%s"
* a152d3d (HEAD -> work2) commit4+5: B機能とB機能の説明を含むREADME
* d4a6299 commit2+3: A機能のREADME追加
* 2ca3823 commit1+8: A機能(バグ修正済み)追加
| * 014d976 (work) commit4+5: B機能とB機能の説明を含むREADME
| * 8be3cfc commit1+2+3+8: A機能(バグ修正済み)とA機能の説明を含むREADME
|/
| * b3efbfe (develop) commit8: A機能でバグ発覚→A機能のソース修正
| * 9b13b8c commit7: B機能の仕様変更は取り下げになりソースを元に戻す(commit6のrevert)
| * b522341 commit6: B機能に仕様変更発生→ソース修正
| * 0ce0d64 commit5: B機能の説明をREADMEに追記
| * 139de71 commit4: B機能用ソースファイル追加
| * 25d6a63 commit3: READMEにタイプミス発覚→修正
| * d21ffa1 commit2: A機能の説明をREADMEに追記
| * d0290fa commit1: A機能用ソースファイル追加
|/
* b7b9572 (master) first commit
git diff work work2
#出力なし。コミット分割前と比べて差分なし
git diff develop work2
#出力なし。コミット結合・分割前と比べても差分なし
commit1+8とcommit2+3はrebase中に手動で作成したコミットで、commit4+5はrebaseが自動的に作成したコミットです。
work2ブランチをmasterまでresetして手動で1つ1つ任意のコミットを作っていっても同じことが出来ますが、コミットの数が多いときは手動編集したいコミット以外はrebaseにお任せ出来るので楽です。