この記事は株式会社ビットキー Advent Calendar 2023の11日目の記事です。
はじめに
ビットキーではFWエンジニアをやっています。普段はアセンブリとか通信プロトコルとか低レイヤー周りに興味が向いているんですが、今回は自分がGitを使った開発をしているときに気をつけていることとか、便利なTipsとかをノウハウとして雑多に書いてみようと思います。こういったノウハウって明文化して周りに伝えるきっかけがあまりないんですが、実際聞いてみるとちょっとうれしいことがあったりしますよね。
こんな人に読んでほしい
- とりあえず
git add .
→git commit
ってやってるけど、ちょっと変わった状況になると分からない - GitHubでPull Requestを出すときに内容がごちゃついてしまう
※この記事のGit操作環境は基本的にコマンドラインを想定しています。とはいえ、世の中にあるGUI操作でもほとんど同じことができると思いますので、自分の環境に合わせて適宜読み替えて頂ければと思います。
コミットの話
Gitでは何をするにもコミットが基本単位です。いかにキレイなコミットを積み重ねるかが、Gitをうまく使うためのカギと言っても過言ではないと思います。
コミットの粒度
まずは、コミットの粒度についてです。ここで大事なのは変更量(変更した行数)ではなく、ひとつの変更としてまとまっているか、という観点です。つまり、変更が単一の目的で行われているかということです。例えば以下のようなケースは複数の目的が混在していると言えます。
- 新機能を開発している途中で既存のコードに誤字があることに気付いたので一緒に直した
- 誤字修正と機能開発という異なる目的があります
- あるコンポーネントの変更のために、別のコンポーネントで新しいインターフェースが必要になったので作成した
- 新しいインターフェースが汎用的なものなら変更自体は独立したものと言えます
- 新しい機能で既存の実装を流用したいので、一部を関数化して切り出し、それを利用する実装にした
- 既存処理の切り出しと新規処理の作成は別の目的と言えます
とはいえ、実際に作業をしているといろんな目的をもった変更が混在してしまいますよね。例えば誤字修正とかも見かけたときに修正しないとすぐ忘れてしまったりもします。
別々の目的で変更したファイルが複数ある場合、git add <file>
でファイル単位のステージングを行ってからコミットすることで履歴の分離が可能です。ただ、これだけだとファイル単位で変更が分離できているときしか有効に使うことができません。そんなときは-p
オプションを使うことで、あるファイル内においてもどの変更をステージングするかどうか選択することができます。
完全に変更を後回しにする場合はgit stash
しておくのも手でしょう。stashもaddと同様、-p
オプションが使えるので便利です。
また、目的の話とは少し違いますが、特別な理由がなければビルドは通る状態でコミットするべきでしょう。
注意点として、「単一の目的」と書いてはいますが、これはどのレイヤーから見るかによって変わるものでもあります。そのあたりは同じリポジトリを変更するメンバー間で認識を合わせられるとよいと思います。個人的には、コミットは内部仕様レベルや実装レベルで単一目的になることを意識し、外部仕様レベルでの話はPull Requestのような形でひとまとまりにするのが好みです。大きめのPull Requestをレビューするときに、コミット単位でひとつひとつ見ていくと分かりやすい、みたいな状況が理想ですね。
コミットメッセージ
コミットメッセージだけでもボリュームのある記事が書けてしまうくらいなので、ここではあまり深入りしません。お手本となる良記事も多数ありますので、そちらにお願いしてしまいます。
記事でも言及されているように、他人に対して意味のある情報になっているか、という視点を持つのが大事だと思っています。(コミットの粒度も同じで、「今の自分はこれをひとまとまりだと考えている」という情報になるわけですね。)
ただし、個人的な考えにはなりますが、コミットメッセージの記載レベルはプロジェクトの規模にもよると思います。プロジェクト規模が大きくなるほど、様々な人がコミットメッセージを読むことになるので詳細な内容が必要になったり、コミットのラベリングみたいなものが必要になったりもするでしょう。丁寧なコミットメッセージの作成にはそれなりの手間がかかるので、プロジェクトにとって最適なレベルはどのようなものか、これもチームで議論できるとよいでしょう。
自分の考えばかり書いていてあれですが、コミットメッセージにタスク管理ツールのリンク(例えば、GitHubのissue番号とか)を記載するのは個人的にはあまりおすすめしていません。本来独立したサービスであるGitリポジトリとタスク管理ツールが切り離しにくくなるためです。
過去の改変
ここまではキレイにコミットを残していくための話でしたが、ここでは、すでにコミットしたものの後からやっぱり直したくなってしまった場合の話をしたいと思います。もちろんですが、過去の改変を行わずに済むならそれが一番です。
すでにリモートにpushしてあるコミットを変更するのは危険が伴うので、十分に注意して実行しましょう。私の感覚で言うと、過去の改変を行っていいのは、
- リモートにpushされておらず、ローカルにしかないコミットである
- リモートにpushはされているが、mainなどの主要ブランチではなく、自分(もしくは少数の開発者)しか使っていないfeatureブランチのコミットである
の、どちらかの場合のみです。変更がリモートに反映済みの場合はforce pushが必要になります。(現在は--force
ではなく--force-with-lease
を使うのが普通でしょう。いま久々に調べたら最近は--force-if-includes
というオプションもあるみたいですね。)
まずは簡単なところから、直近のコミットメッセージの内容を修正したい場合git commit --amend
で再度メッセージを編集することができます。コミットした瞬間に間違いに気付いたりするのはあるあるですね。
また、git rebase
の-i
オプションを使うことで、過去のコミットをいろいろと操作できます。特にsquash
を指定して複数のコミットをひとつにまとめる、という目的で使われることが多い気がします。他にも、過去のコミットメッセージを変更したり、順番を入れ替えたりすることもできます。
コミットメッセージやコミットの粒度でなく内容そのものを訂正したい場合は、git reset
で変更そのものをやり直すことができます。resetは--soft
、--hard
などのオプションを指定することも可能ですが、たいていの場合は何もオプションを付けず、デフォルト動作の--mixed
相当で問題ないと思います。
HEAD
git rebase -i
やgit reset
はHEADという概念とともに使われることが多いです。HEADとは、ざっくり言えばコミットの現在地のことです。間違えてaddしてしまった、というときもgit reset HEAD
でaddする前の状態に戻せます。(特定のファイルをaddしたかったのに手癖でgit add .
とかやっちゃって修正することが自分はよくあります。)
HEADに~
とか^
を付けると現在地から相対的な過去のコミットを指定できます。例えば、git diff HEAD^
とすればひとつ前のコミットの内容が参照できます。~
と^
は少し仕様が異なりますが、一本道の(途中にマージコミットのない)コミットをさかのぼる場合は特に気にする必要はなく、どちらを使っても大丈夫です。
ブランチの話
少しレイヤーを上げて、ブランチ周りの操作についても書いてみようと思います。機能ごとにfeatureブランチを作って、それをPull Requestなどの形式でレビューして、mainにマージする、といういわゆるGitHub Flowにおいて、ブランチ(Pull Request)は一連のコミットをまとめて管理するという意味でとても有用なものになります。
並列作業
実際にチームで開発していると、複数のfeatureブランチが同時に進行するのも珍しくないと思います。複数のブランチが全く競合(conflict)しない変更であれば特に問題にはなりませんが、そうでない場合でもrebaseをうまく使えばあまり危険な橋を渡らず、履歴をキレイにすることができます。
危険な橋、と言っているのはrebaseは原則的に履歴の改変を行うためです。結果としてforce pushが必要になってしまう場合が多く、初学者にはおすすめするのをちょっとためらうのですが、使いこなせばとても有用です。
ブランチに依存がない場合
まず前提として、目的が異なり、かつ、変更内容が全く競合しないような変更がある場合、ブランチを分けて並列的に作業するようにしましょう。この場合はPull Requestも特にrebaseなどせず、そのまま作成して問題ありません。
ブランチの依存が軽微である場合
コーディング自体はブランチを分けて並列的に作業して問題ありません。しかし、ひとつがmainに取り込まれた場合、他のブランチをマージしようとするとconflictが発生してしまいます。この場合、conflictが発生しているブランチを最新のmainにrebaseすることで、コミット履歴をキレイにすることができます。この使い方がrebaseが使われるよくある状況なのではと思います。
ブランチの関係を明確にすることで、履歴を見返すときの分かりやすさはもちろんのこと、別のリポジトリなどに変更を展開することも容易になります。
ちなみに、rebaseするブランチがすでにリモートにpushされている場合でも、featureブランチであれば編集している開発者が少ない(あるいは自分しか使っていない)ことが通常であるため、force pushを行う危険性も低い、というのがこのやり方のいいところでもあります。また、タイミング的に可能であれば、後発のブランチはリモートにpushしないままにしておき、別のブランチが取り込まれてからrebaseしてpushする、とすればforce pushの必要もなくなります。
ブランチの依存が大きい場合
最後に、あるブランチでの変更内容が、他のブランチでの変更に思いっきり依存しているパターンです。
こちらは完璧な方法はないかもしれませんが、私の場合は依存元のブランチを先に実装し、Pull Requestをレビューしてもらっている間、依存元のブランチから新たなブランチを生やして、そこで実装を行います。これにより作業を止めず、かつ目的は分離した状態でコーディングを続けることができます。その後、依存元のブランチがmainに取り込まれたタイミングで、依存元から生えていたブランチをmainにrebaseします。これにより最終的なコミット履歴もキレイになります。
ただし、この方法は依存元にレビューで大きな指摘が入れば手戻りが発生したり、最悪新しいブランチの作業が完全に無駄になったりします。なので、できればこのような依存が強い並列作業は避けるようにスケジュールを組みたいですね。
おわりに
Gitのコミット周りの仕様を面倒なものに感じる人も多いと思います。ですが、うまく活用すればレビューの見通しをよくして開発のスピードアップに貢献したり、問題発生時の原因探索を容易にしたり、様々なメリットを享受できるはずです。この記事が皆さんのチーム開発の助けになればうれしいです。