春の入門祭り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だけにしておきます。
ついでに、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 このコメントだけ残る |
これで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
のおかげで、変更を記録しておき操作が自動化されます。最初の1回の修正は必要ですし、コンフリクト時の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