はじめに
私が所属している開発チームでは数年前に決めたルールに従ってコミットメッセージを書いている。
近くジュニアメンバーが参画することから、コミットの仕方について整理して伝える必要があった。
そこで、改めて「良いコミット」について考えてみることにした。
なぜコミットメッセージが重要か
開発でのあらゆるアウトプット、例えばソースコード、テストコード、コメント等は全てソフトウェアを説明するドキュメントとしての役割を持つ。
コミットもその一つで、コードの歴史を記録するものである。
後から見たときに「なぜその変更をしたのか」が明確でないと、レビューや保守が難しくなる。
例えば、バグの原因と思しき箇所を特定した時に、その行のコミットメッセージが
wip
fix
レビュー対応
のようなものだった場合、その情報だけでは修正すべきかどうかの判断は難しい。
一方、
Change: 複数アカウントログイン対応に伴い認証プロバイダの設定を見直し
Add: CLS 対策のために初期読み込み時のローディングを追加
このようなコミットメッセージだった場合は、修正すべきかどうかの大きな判断材料になるのではないだろうか。
また、レビュワーにとってもコミットの粒度やコミットメッセージは重要だ。
コミットの粒度が適切なプルリクエストは、
- このプルリクエストで何を実現したかったのか
- どこに注目してレビューすれば良いのか
が明確であるため、レビュー時間を大幅に短縮できる。
このように、コミットの質はチームの生産性を大きく左右するものである。
開発におけるコミットの位置づけ
以下は @t_wada 氏による有名なポストである。
私はこの考え方が大好きだ。コード、テストコード、コミット、コメントの役割が端的に表現されている。
これらを踏まえたコミットの例を AI で生成した。
Add: アクセスログ機能を追加して監査要件に対応
コード
async function logUserAccess(userId, action, resource) {
const timestamp = new Date().toISOString();
const ipAddress = getClientIpAddress();
const logEntry = {
userId,
timestamp,
ipAddress,
action,
resource
};
// レートリミットを考慮したリトライ回数上限を設定している
let attempts = 0;
while (attempts < 3) {
try {
return await logService.saveEntry(logEntry);
} catch (error) {
attempts++;
if (attempts >= 3 || !isRetryableError(error)) {
throw error;
}
await sleep(1000 * attempts);
}
}
}
テストコード
describe('logUserAccess', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(logService, 'saveEntry');
});
test('正常系: ユーザーアクセスログが保存される', async () => {
logService.saveEntry.mockResolvedValue(true);
const result = await logUserAccess('user123', 'view', 'document/456');
expect(result).toBe(true);
expect(logService.saveEntry).toHaveBeenCalledTimes(1);
expect(logService.saveEntry).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user123',
action: 'view',
resource: 'document/456'
}));
});
test('異常系: API制限エラー時にリトライする', async () => {
const apiLimitError = new Error('API rate limit');
isRetryableError.mockReturnValue(true);
logService.saveEntry
.mockRejectedValueOnce(apiLimitError)
.mockRejectedValueOnce(apiLimitError)
.mockResolvedValueOnce(true);
const result = await logUserAccess('user123', 'edit', 'document/456');
expect(result).toBe(true);
expect(logService.saveEntry).toHaveBeenCalledTimes(3);
});
test('異常系: 最大リトライ回数を超えるとエラーを投げる', async () => {
const apiLimitError = new Error('API rate limit');
isRetryableError.mockReturnValue(true);
logService.saveEntry.mockRejectedValue(apiLimitError);
await expect(
logUserAccess('user123', 'delete', 'document/456')
).rejects.toThrow('API rate limit');
expect(logService.saveEntry).toHaveBeenCalledTimes(3);
});
});
コミットメッセージでは、「監査要件に対応」という背景(Why)を明記している。
コードは引数や変数の命名やシンプルな手続き的な記述で、どのような処理をしているか(How)を明確に表している。
コード中のコメントでは、「なぜリトライが 3 回なのか = なぜ 3 回以外の回数ではないのか」の理由(Why not)を示している。
テストコードでは、このコードがどのような振る舞いをするのか(What)が表現されている。
このように役割を明確化することで、コミットに書くべき内容で悩むことは少なくなるだろう。
良いコミットの粒度とは
一つのコミットの粒度を考える時に重要なのは「複数のことをやらない」だろう。
例えばバグ修正と linter によるファイル全体の修正が混在してしまうと、レビュー時にどこを見ればいいのか分からなくなってしまう。
コミットには必ず一つの変更目的を入れることで、後から見たときに変更意図が伝わりやすい。
個人的には、この考えにプラスして、「revert
または cherry pick
しても問題なく動く単位」でコミットすることを心がけている。
revert
やcherry pick
は日常的にすることではないと思うが、それができるようなコミットは、意味のある変更のまとまりになっていると言える。
もちろん変更範囲が大きい場合はその限りではない。ただしその場合でも「その機能に関連するコミット3つ全て revert すればその機能に関するコードが完全に消える」という状態になっていると望ましい。
コミットと生産性の関係
良いコミットは個人の作業効率だけでなく、チームの生産性にも寄与する。
良いタスク分割が良いコミットを産む
大きなタスクを小さなサブタスクに分けると、自然とコミットも適切な粒度になる。
例えば「ログイン機能追加」を
- UI作成
- 認証ロジック実装
- エラー処理追加
のように分割すると、作業イメージが湧きやすく、そのまま1つの変更だけを含む「良いコミット」を作ることができる。
逆も然りで、日頃から「良いコミット」を意識していると、タスク分割のスキルが向上すると考えている。
タスク分割が上手くできると、作業ごとの認知的負荷が減り、生産性が高まる。
チームの負荷軽減
良いコミットを残すことで、レビュー時のレビュワーの負担を下げることができる。
個人的な体感としては、適切な粒度のコミットはそうでないコミットの 1/10 以下の時間でレビューすることができる。
レビューだけでなく、その後のチームや開発者自身のためにもなる。
エディタ上で何年も前のコミットメッセージを見たことがある人は多いと思う。コミット履歴は数年後やそれ以上後に参照されることも少なくない。
その時、明確で分かりやすいコミット履歴があると、修正の方針を立てる時間を大幅短縮できる。自分を含む未来のチームメンバー全員が恩恵を受けることができる。
コミットのテクニック
ちょっとしたコミットのテクニックを知っているだけで、綺麗なコミットを作るハードルは大きく下がる。
私がよく使うテクニックを2つ紹介したい。
行ごとのコミット
コードを書いている時、一つのファイルに複数の変更が含まれることがしばしばある。
そういった時に、一旦コミットしたくない部分を削除してコミットした後、削除した部分を戻してコミット... のような手順を踏んでいると非常に手間がかかる。
そんな時には行単位でコミットする方法が有効だ。
git コマンドだと
git add -p
VSCode 系のエディタだと、
- ステージしたいコードを範囲選択して右クリック
- Stage Selected Ranges を選択
で行ごとにステージ→コミットができる。
前のコミットに変更を追加する
コミットした直後に修正漏れに気づいて対応する、というのは日常のようにあると思う。
git reset --soft
git add .
git commit -m "fix: ..."
とするのはちょっと面倒。
そういう時は、
git add .
git commit --amend --no-edit
で OK。
前のコミットに変更差分を追加できる。
知っている人は当たり前と思いがちだが、意外と知らないまま何年も開発しているエンジニアも少なくないのではないだろうか。私がそうだったように。
コミットメッセージのルールの提案
チームで開発していると、開発者によってコミットメッセージの書き方が異なったり、粒度がバラバラになったりして、コミット履歴が読みづらくなってしまうことがある。
コミットメッセージのルールを作ることで一貫性を保ち、読みやすくすることができる。
prefix をつける
prefix をつけることで、下記のようなメリットが得られる。
- 過去の変更を探すときに探しやすくなる
- レビュワーが変更の概要を掴みやすくなる
- 開発者が自然とコミットの粒度を意識するようになる
プレフィクスを考えるコストは増えるが、チーム開発では上記のメリットの方がデメリットを上回ると考えている。
下記に私が利用している prefix を示す。
prefix | 説明 |
---|---|
fix | 既存の機能の問題を修正する場合に使用する |
add | 新しいファイルや機能を追加する場合に使用する |
update | 既存の機能に問題がないが修正を加えたい場合に使用する |
change | 仕様変更により既存の機能に修正を加えた場合に使用する |
clean | 不要なコードの削除などに使う |
delete | ファイルやモジュールを削除する場合に使用する |
refactor | ソフトウェアの振る舞いは変えずにコードの内部構造を改善する時に使用する |
rename | ファイル名を変更する場合に使用する |
move | ファイルを移動する場合に使用する |
install | ライブラリの追加などに使用する |
upgrade | バージョンをアップグレードする場合に使用する |
revert | 以前のコミットに戻す場合に使用する |
docs | ドキュメントの追加や変更に使用する |
コミットメッセージの詳細部分を書く
コミットメッセージの1行目には端的なメッセージを書く。
詳細を書く必要がある場合は空行を挟み、詳細の説明を書くようにする。
このルールは詳細を書く必要があるときだけ適用する。
例
fix: 認証処理でタイムアウトが発生していた問題を修正
セッションの有効期限を30秒から60秒に延長した。
これにより、ネットワーク遅延が発生した場合でも認証が正常に完了しやすくなる。
また、ログにリクエスト開始時間を記録するように変更しタイムアウト発生時のデバッグを容易にした。
PR にコミットメッセージと重複した内容を書かない
プルリクエスト本文には、コミットメッセージを見れば分かることは書かないようにするのが望ましい。
むしろコミットメッセージからは読み取れない、全体の修正方針や、動作確認の内容などを中心に書く。
レビュワーは、原則コミットメッセージだけを見て修正内容を読み取る。
開発者は、レビュワーが読んで理解しやすいことを意識してコミットメッセージを書く。
このような役割分担ができると無駄のないレビューフローになりそうだ。
さいごに
この記事を紹介したい。
プロジェクトのフェーズによっては、丁寧さよりも速さが重視されることは往々にあるだろう。
速さを取ったとして、短期的に得られる価値もある。それが本当に重要であることもある。
しかしそのような状況でも、すべてのアウトプットを作品のように仕上げていくと、長期的に得られる価値は何倍にもなると考えている。
最初は時間がかかるかもしれないが、継続していくうちにそれが当たり前になっていく。すると自分の中での基準が高くなり、さらに良い作品を作ることができるようになる。日常の作業が成長につながるようになる。
チームにとってもそのようなアウトプットの価値は計り知れない。開発におけるアウトプットは、そのアウトプットを生み出す時間よりも、後に参照される時間の方が長いからだ。
「作品」を見たチームメンバーにもその価値は伝播していくだろう。私がかつてそうであったように。
この記事を書いて、改めて作品のような「良いコミット」をしていこうと思った。
とても良い機会になった。