フューチャー技術ブログ

2024年Gitワークフロー再考

春の入門祭り2024の2記事目です。

Gitは、出自としては1週間で作られたLinuxカーネルのための分散バージョン管理システムでした。当時のワークフローに合わせてパッチをテキスト化してメールに添付できるような機能だったりが備わっています。

一方で、現代のGitは、デファクトスタンダードなバージョン管理システムになりLinuxカーネル以外のアプリケーション開発で利用されています。分散バージョン管理ではあるものの、サーバー・クライアント型の使われ方をしていて、GitHubやGitLabを核にして、ローカルで作ったブランチをpushして、Pull Requestの形にして管理しています。少なくとも周りで見る限りでは、それ以外の使われ方の方が少なくなってきてます。そんなこんなで求められている使われ方が変わってきていて、それに合わせた機能がぼちぼち増えています。それを活用することで、ウェブ画面上での差分がみやすく、レビューもしやすくなります。

本来はそれに合わせてワークフローや設定を見直していくべきなんだとは思いますが、なんとなく昔覚えたメンタルモデルのまま使っているな、と思ったので、直近増えた機能を試しつつ、どんなお作法で使っていくべきか思考実験してみたのがこの記事です。基本路線としては、Gitの使い方の議論ポイント、履歴をきれいにする使い方を広めたいが、チームメンバーがついてこれない、というのはよく聞く話だと思いますが、それに対し、今時の設定を使ってmergeとrebaseの両方があって使い手が選べる世界ではなく、rebaseのみがある世界を作っていこうというものです。

実際に複数人で使ってみるとまたいろいろ気づきはあるかもしれません。あと、僕はgit操作はCLIでやっているのですが、これをきちんとガイドライン化するなら、利用者の多いGitクライアントツールの操作も併記するといいですね。

本エントリーが想定する現代のGit使いに求められるメンタルモデル

  • メインのブランチへのマージはPR単位。大きくなるほどコンフリクトしやすく、それの解決が大変になるので、Pull Request上でレビューを行うし、 CIのテストが走っていて、メインとなるブランチが不安定な状態にならないようにする
  • Pull Request上ではレビュー指摘に対する修正コミットがわかりやすい(前のコミットに対する差分が見える)が、マージ後は1つのPull Request==1コミットで、どのPR由来の変更か追いかけやすくする(squash merge)
  • 複数の修正はPull Requestには混ぜない。Pull Requestごとに、GitHubの機能を使ってrevertする

開発はPull Requestベースで行いますが、1つのPull Requestをマージしてマージコミットが生成されて、他のPull Requestがぶっ壊れたら困るわけで、ようはRebaseをきちんとやる、という方向ですね。

マージコミットを生成するとどのようにマージしたのかの記録を残す、という言説はあったけど、マージコミットの情報が役に立ったことがないし、並列数が増えてくると困ることが増えてくると思うのでマージコミットは作らないようにしたいという感じかなと。

設定

GitHub

設定のGeneralのPull Requestsの項目にある、Allow merge commitsとAllow rebase mergingのチェックを外し、Allow squash mergingだけにしておきます。

スクリーンショット_2024-03-26_12.21.11.png

ついでに、Automatically delete head branchesもチェックしておいて良いでしょう。

Pull Requestのマージ先のdevelopなどはプロテクトブランチ設定しておきます。承認まわりとかCIはプロジェクト固有の話が出てきますのでそこはスキップして共通的な話だけを用意するとなると、次の2を設定すると良いでしょう。

  • Require a pull request before merging
  • Require linear history

後者を設定すると、Pull Requestのボタンは「Squash and merge」がデフォルトになるっぽいです。

なお、rebase主体だと一部force pushは必要となります。名前通りの--force自体は不要ですが(後述)、develop以外のfeatureブランチに対しては許可しておきます。

ローカル

次の2つを設定します。

  • git config pull.rebase true
  • git config rerere.enabled true

開発時のモデル操作

開発時にどのような操作をしていくのかをまとめます。

1. featureブランチを作成して実装

マージ先となるブランチ(一般的にはdevelop)から新しいフィーチャーブランチを作成します。 feature/名前みたいな。ローカルで一通り実装します。

もし、ローカル実装中にdevelopの最新を取り込む必要があればgit pull origin developします。コンフリクトがあればrebaseをきちんと行います。

一通り動くまで実装します。

2. リモートにpush前に整理

リモートにpushしますが、2つの操作を忘れずに行います。

  • git pull origin developで最新のdevelopを取り込む
  • git rebase -i developで最新のdevelopの上に変更が来るようにする。最後のコミット(一番上の行)以外はfixupして、1コミットにまとめる
pick   950b967 このコメントだけ残る
fixup 6b66e35 このコメントは無視される
fixup 806489c このコメントは無視される
fixup aaf0eda このコメントは無視される

これで1コミットにまとまるのでレビューしやすくなります。

なお、このステップはオプションでも良いかなと思います。ちょっと不安なら、bisectで問題を探すとかするかもしれません。またdevelopへのrebaseはGitHub上でもコンフリクトしない限りはやってくれはします。ただし、コンフリクトした場合はここで実施して解決しなければなりません。

3. リモートにpushしてPull Rquest作成

ここはいつもの操作なので省略します

4.1. レビュー指摘に対応する修正

レビューで指摘された修正を行います。修正したらcommit&pushします。一度Pull Requestを作成したら、rebaseで前のコミットにまとめたりはせず、コミットをバラバラの状態でpushします。レビューアが修正コミットのみを見たいと思うケースがあるからです。pushする前に「あ、ちょっとタイポ」とcommit --amendするかもしれませんが。そうすればみんなが嫌いな--forceは不要となります。

次のブログでは--fixupを推奨していますが、この形式のコメントはGitHubは現時点(記事公開日:2024/4/10)では考慮してくれないので気にしなくても良いです。

https://blog-dry.com/entry/2024/02/26/090146

4.2. developのrebase

PR作成時は良かったかもしれませんが、その後の他の人のPRが先にマージされるとコンフリクトが発生して再度rebaseが必要になります。その場合は、git pull origin developで最新のdevelopを取り込みます。

何度もdevelopをマージしなおすケースであればrerere.enabled trueのおかげで、変更を記録しておき操作が自動化されます。最初の一回の修正は必要ですし、コンフリクト時のgit add / git rebase --continue操作だけは必要ですが、何度もファイルを開いて修正する必要はありません。

rebase後は通常のpushは失敗するようになってしまいますが、--force-with-leaseという--forceのようで--forceではない、ちょっと--forceなオプションを使います。

$ git push origin [ブランチ] --force-with-lease

5. GitHub上でマージ

Squash and Mergeでマージします。Squash and Mergeするとこのブランチはもう再利用できないというか、ここから新しいコミットを伸ばしてマージしようとするとコンフリクトするのでブランチは削除しましょう。ローカルもです。

気軽にPull Requestに含まれる1コミットだけを取り消すといったこともできなくなります。

Git操作の考え方

上記のモデル操作における基本的なGit操作の考え方についても触れておきます。

develop上では1コミット= = 1 Pull Requestとなるようにしますが、一方で、Pull Request上では無理にマージする必要はありません。Pull Request前にコミットは可能なら1つにしても良いとは思いますが、素直にコミット履歴を重ねていく方針です。

コミットをまとめるsquash操作はGitHub上でのみ行います。ローカルでsquash mergeを自分の手でやるということはしません。また、squash mergeしてしまうと、部分的な修正は難しくなるのでPull Request上のレビューやCIのチェックはきちんと行う想定です。

rebaseは「過去を書き換える機能」ですが、普段使うのは、マージしやすいように自分のコミットを最新のコミットよりも後に持っていく、という方向の修正のみです。

コミットしたあとに何か不具合を見つけて戻す場合は、その修正のPull Requestを上げて修正すればいいと考えています。rebaseで過去のコミットを修正して無かったことにする、までは不要かなと。

push --force(--force-with-lease)は、rebaseを反映するためだけに利用します。

トレードオフ

この設定とか作法でもまだ100点ではないというか、一部ちょっとした困り事はあります。

rebaseをしっかり覚える必要がある

今まではrebase怖いと思っていた人もきちんとやっていく必要性があるかなと思います。しかし、やることといえば修正後にgit commitの代わりにgit rebase --continueをするぐらいと、コミット数分繰り返す必要がある点ですね。

いままでmergeの方がいいよ、という意見があったのはコンフリクト発生時の処理がrebaseの方がちょっと面倒だった、ということに尽きるかと思います。rerere.enabled trueでだいぶ楽になったとはいえ、rebase元との差のコミット数が増えてくるとローカルでのコンフリクト発生時が大変になります。

あとはrebase時は、他の人の修正がメインで自分のコミットがサブ側になるというのがちょっとわかりにくいとかはありますが、ここはすぐに慣れるでしょう。

branch -dでブランチが消せない

GitHub等でsquash and mergeを選ぶと、複数のコミットがマージされた1つのコミットになります。コミットが作り替えられます。マージ済みのdevelopをfetchしても、同一コミットは存在しないわけで、branch -dではfeatureブランチが消せません。強制的な削除のbranch -Dを使う必要があります。

基本的にやることはないと思いますが、マージ済みの作業ブランチでそのまま修正作業を継続するとコンフリクトが必ず発生するというのはありますが、これはまあ問題にはならないと思います。作業フローが悪い。

squashしてしまうと部分的なコミットの取り消しが不能になる

マージ後にPull Requestに問題があったと判明したとして取り消そうとした場合、Squashしていなければ内部に含まれるコミットがばらばらにあるので一部だけを消すなども可能です。しかし、Squashしてしまうと、履歴上は1つのコミットになってしまうので、一部だけをなかったことにはできません。

取り消しはGitHub上のrevert操作で良いと思いますが、これだと Pull Requestの単位での取り消しになります。でかすぎるPull Requestにならないように、小さい機能に分けてコミットしていくような配慮は必要となるでしょう。

まとめ

Gitのおもしろさ(難しさ)は、複数の機能の掛け算で、便利なワークフローが変わってくるところです。

git config rerere.enabled trueが入ったおかげで、長期間生存し、developからなんども変更を取り込むようなケースが楽になりました。--force-with-leaseというちょっと安全なpush --forceも入りました。

最初にメールでパッチを送るケースについて紹介しましたが、実際にはまだ現役で、最近のバージョンのリリースノートでも着実に機能強化はされています。ですが、GitHubやGitLab主体の開発というフローを考えると、着実にrebaseを便利にする機能が強化されており、今後もこの方向性でワークフローを考えていけばどんどん便利になっていきそうです。あとは、rebaseの繰り返しのcontinueが軽減される機能が入ったら完璧ですね。それに期待。

おまけ: pull.rebase trueか、pull.ff only

ローカルでは何も設定しない(git config pull.rebase false相当)と、git pullしてきたときにマージを行おうとします。そうするとマージコミットが出てしまうわけで今回の説明の「なるべく履歴は綺麗に」と違う結果になってしまいます。それ以外の結果を得る設定としては、次の2種類のオプションがあります。

  • git config pull.ff only
  • pull.rebase true

基本的に他の修正が入るdevelopブランチ上で直接作業しない限りは、pullしてコンフリクトすることはありません。また、1つのPull Requestを複数人で修正することはない、という前提に立てばpull.ff onlyで良いかと思います。

それぞれの状況や設定ごとのgit pullでどうなるかを表でまとめました。

設定 git pull実行時 操作結果 どうすればよい?
pull.ff only インデックスに未コミットのファイルがある(コンフリクトしてない) 成功
pull.ff only ff可能なコミットがある エラー (1)
pull.ff only ff不可なコミットがある エラー (1)
pull.rebase true インデックスに未コミットのファイルがある(コンフリクトしてない) エラー (2)
pull.rebase true ff可能なコミットがある 成功
pull.rebase true ff不可なコミットがある git rebaseがスタート

表でエラーが発生するのは3箇所(2種類)あります。

(1)のpull.ff onlyの方は、git pull origin develop --rebaseコマンドを使ってrebaseプロセスを始めれば問題なくいけます。

(2)はコミットするもしくはgit restore --staged <ファイル>...でインデックスから除外して再実行すればOKです。

僕個人は最後までコミットせずに作業して、pull request寸前にgit addすることが多いのでff onlyの方が便利だったりするのですが、周りを見ているとこまめにコミットする人が多いので、おそらくpull.rebase trueの方が良い人が多いと思います。なので、今回のこのガイドラインではpull.rebase trueの方を推しています。

参考文献

https://qiita.com/tearoom6/items/0237080aaf2ad46b1963

  • git config pull.rebase true
  • git config rerere.enabled true

https://blog-dry.com/entry/2024/02/26/090146

  • git commit --fixup

  • git push --force-with-lease

https://blog.colopl.dev/entry/2022/10/07/105919

  • scaler clone

https://zenn.dev/mary_pp/articles/eaac544eaf600a

  • git push –force-with-lease –force-if-includes