白いカードに赤のGitのひし形ロゴと濃い茶色のロゴタイプ

git logで規約を読み、reset --softで切り直す — セッションの作業をコミットの形にする

コミットの形はまずリポジトリの履歴を読んで決めます。git reset --softを使えば、未pushのコミットを履歴に合うまで何度でも切り直せます。

目次

はじめに

長時間の作業セッションの終わりに、エージェントがそれまでの変更に対して3コミットの計画を提案してきました。しかし実際には1コミットに落ち着きました。その判断は下すのは簡単でも説明するのは難しく、私はそのギャップが気になりました。自分がいま適用したばかりのルールを、言葉にして書き出すことができなかったのです。

誰もが繰り返すアドバイスは「作業を論理的なコミットに分割せよ」というものです。それは正しいのですが、十分ではありません。コミットには意味があるべきだと教えてくれても、目の前の変更の山を何個のコミットにすべきか、どこに境界線を引くべきかまでは教えてくれません。同じdiffを持つ2つのリポジトリが、まったく異なる答えを求めることもあります。

ルールは意外にシンプルです。**形はgit logから読み取り、pushする前に考えを変えられるのがgit reset --softです。**この記事では、着地するリポジトリに合ったコミット履歴を作り出すための5つの手順を見ていきます。

5 つの手順と、その間のループ5 つの手順が番号付きで縦に並ぶ。履歴を読む、形を決める、制約を洗い出す、切り直す、反映する。3 番目から 2 番目へ破線の矢印が戻っており、制約が見つかれば形を決め直すことを示している。1履歴を読む作業単位あたりのコミット数、書式、チケット番号、マージコミットの有無2形を決める各コミットが何のためのものかを 1 文で言えるか確かめる3制約を洗い出す無視されているもの、自分が変更していないもの4切り直す履歴に合うまでまとめるか分ける。ファイルは変わらない5反映する作業ブランチを畳んでから push する制約が見つかれば形を決め直す
ステップ3が後戻りを引き起こす手順であり、だからこそステップ4は安価でなければなりません。

なぜコミットの形はリポジトリに属するのか

diffそのものには、どう分割すべきかについての意見はありません。意見を持っているのはプロジェクトです。リリースプロセス、チケットシステム、レビューの習慣、そして誰かがgit bisectを実行することがあるかどうか、といった点です。

2つのプロジェクトを考えてみましょう。1つはmainが活発で、多数のコントリビューターがいて、小さく焦点を絞ったコミットの履歴を持つライブラリです。ここで変更を3つのコミットに分割するのは明らかに正しい判断です。それぞれがプルリクエストの中で個別に読まれるからです。もう1つはバージョン管理されたリリース単位で出荷するプロジェクトで、すべてのリリースは1つのコミットであり、そのメッセージにはチケット番号が入ります。**このプロジェクトできれいに3分割するのは、より良いプラクティスではありません。ノイズです。**そしてこの分割は、リリース・コミット・チケットという、プロセス全体が依存する1対1対応を崩してしまいます。

2 つの履歴、2 つのコミットの形2 つの列が左右に並ぶ。左は小さく分割されたコミットの履歴で、3 つに分割するという結論に至る。右は 1 行ごとがチケット番号を持つリリースである履歴で、1 コミットという結論に至る。下部の帯は、差分は同じで形だけが違うと述べている。git log --oneline が示すものmain が活発なライブラリ小さく分割されたコミットa1b2c3d fix: guard the null casee4f5a6b test: cover the retry pathb7c8d9e docs: note the new flag3 つに分割リリース単位のリポジトリリリースごとに 1 コミットa1b2c3d feat: … (PROJ-142)e4f5a6b fix: … (PROJ-141)b7c8d9e fix: … (PROJ-139)1 コミットにまとめる履歴が求める形差分は同じ。形が違う。
diffはどちらの列でも同一です。異なるのは、それぞれが着地する履歴の方です。

どちらのリポジトリも、これをどこにも文書化していません。しかしどちらも、決して古くならない唯一の場所に記録しています。

ステップ1: 何かをステージする前に履歴を読む

最初に打つべきコマンドはgit addではありません。次のコマンドです。

ターミナルウィンドウ
git log --oneline -5

私が作業していたプロジェクトの出力は、詳細を一般化すると次のようなものでした。

a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)
e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)
b7c8d9e fix(prompts): prevent arithmetic errors and define the aggregation range (PROJ-139)
1a2b3c4 feat(pipeline): add a deterministic accuracy test series (PROJ-138)
5d6e7f8 fix(prompts): correct a syntax error and add reviewer checks (PROJ-137)

この5行から読み取れることは4つあり、そのどれもリポジトリの他のどこにも書かれていません。

着目点 この履歴が語ること
作業単位あたりのコミット数 1つ。各行がそのままリリース全体です。
メッセージの書式 Conventional Commits、日本語の説明、末尾にチケット番号。
チケット番号 必須。件名の末尾に(PROJ-NNN)の形式で付く。
マージコミット なし。ブランチはマージではなく畳み込まれている。

**この表こそが仕様書です。**続く4つのステップはすべて、これを満たすための試みです。

ステップ2: 形を提案し、各コミットの役割に名前をつける

規約が明らかになったところで、エージェントは3つのコミットを提案しました。データと設定ファイル、プロンプトファイル、ドキュメントというように、触れた成果物の種類ごとにグループ化したものです。私はそのグループ分けを承認し、ステージングを始めました。

この段階で役立つテストは、グループ分けが整って見えるかどうかではありません。**その場にいなかった読者にも伝わる一文で、各コミットが何のためのものかを言えるかどうかです。**そう説明できないコミットは、2つに分けるべきか、そもそも要らないかのどちらかです。これは定番のアドバイスの中で、実際のリポジトリに触れても生き残る部分であり、覚えておく価値があります。

定番のアドバイスがカバーしていないのは、この段階での提案があくまで暫定的だという点です。まだ確認していないことが2つありました。

ステップ3: コミットしてはいけないものを見つける

3つ目のコミットをステージしようとしても、何も起きませんでした。git status --shortにファイルが一切表示されなかったのです。

ターミナルウィンドウ
git status --short

理由は1つのコマンドで判明しました。

ターミナルウィンドウ
git check-ignore -v docs/draft-report.md output/question-set.xlsx
.gitignore:17:docs/ docs/draft-report.md
.gitignore:13:output/ output/question-set.xlsx

両方のディレクトリは、このセッションよりずっと前から意図的にignoreされていました。**3つ目のコミットには中身がなく、最初からあり得なかったのです。**計画は3コミットから2コミットに変わり、そこに入るはずだった成果物はそもそもバージョン管理の外にあることが分かりました。

もう1つ、より気づきにくい制約がありました。作業ツリー内の2つの変更済みファイルは、このセッションが始まる前に、別の誰かの作業によって変更されていたものでした。エージェントはそれらに触れていなかったため、git add -Aでまとめて取り込む代わりに切り分けてフラグを立てました。**コミットしてよい範囲は、自分が実際に変更した範囲に限られます。**そして何時間も続くセッションこそ、まさにこの境界が忘れられがちな状況です。

どちらの制約も、形が提案された後になって現れました。この順序は避けるべき失敗ではありません。むしろ、次のステップが存在する理由そのものです。

ステップ4: git reset --softで切り直す

この時点で2つのコミットが存在していましたが、それらはチケット番号が必須だと気づく前に書かれたものであり、履歴が求めていたのは2つではなく1つでした。どちらもまだpushされていませんでした。

pushされるまで、コミットはまだ下書きです。git reset --softは、それをそのとおりに扱うツールです。

ターミナルウィンドウ
git reset --soft <base-sha>

このフラグが安全性の理由です。--softはブランチポインタを指定したコミットまで戻し、そこで止まります。ステージされていたものはすべてインデックスに残り、作業ツリーは変更されず、**ディスク上のファイルは1つも変わりません。**消えるのはコミットの境界だけです。破棄されたコミットに含まれていたものはすべてステージされた状態のまま残っており、いま望む形で改めてコミットできます。

git reset --soft が動かすのはブランチの位置だけ2 つのパネル。左では base コミットの上に 3 つのコミットが積まれ、HEAD は最上段にある。git reset --soft base と書かれた矢印が右のパネルへ続き、そこでは 3 つのコミットが消え、HEAD は base にあり、その内容がステージ済みの変更として現れている。下部の帯は両パネルでワーキングツリーが同一であることを示し、ファイルは 1 つも変わらないと述べている。実行前実行後コミットC add the report draftB update the promptsA update the question setHEADbaseステージ済みの変更HEADbasegit reset--soft baseワーキングツリーquestion-set.yaml, prompts/, docs/question-set.yaml, prompts/, docs/=ファイルは 1 つも変わらない
コミットの列は変わり、作業ツリーの列は変わりません。この非対称性こそが、何度繰り返しても安全である理由です。

そこから先の切り直しは、2つのコマンドで済みます。

ターミナルウィンドウ
git add -A
git commit

結果として、このセッションの作業をまとめた1つのコミットができあがりました。履歴が求めていたチケット番号も入っています。

ターミナルウィンドウ
git log --oneline -3
9f8e7d6 fix(prompts): align evaluation questions with the reviewer's vocabulary (PROJ-143)
a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)
e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)

同じ仕組みは逆方向にも使えます。1つのコミットを複数に分割したいときは、git reset --soft HEAD~1のあとgit add -pで少しずつステージします。統合(squash)と分割は、後続の作業が違うだけの同じ操作であり、だからこそ「分割すべきかsquashすべきか」という問いの答えは、どちらも同じ1つの道具に行き着くのです。

これらすべての境界線は「公開されたかどうか」です。他の人がその上に作業を積み重ねるブランチにコミットがpushされた瞬間、書き換えはもう無償ではなくなります。このステップの操作がすべて安全なのは、何もこのマシンの外に出ていなかったからにほかなりません。

ステップ5: 反映し、そもそもブランチを切るかどうかを決める

エージェントは、何かをコミットする前にブランチを切っていました。

ターミナルウィンドウ
git switch -c fix/question-vocabulary

git switchはそれ自体、知っておく価値があるコマンドです。これはGit 2.23で追加され、git checkoutが担っていた「ブランチを切り替える」という役割を引き継ぎました。git checkoutは、ブランチ間の移動とファイルの復元という無関係な2つの仕事を抱え込んでいたのです。git switch -c <name>はブランチを作成して移動するだけで、ファイルに何かをこっそり行うことはありません。ファイルの復元は今やgit restoreが担っているからです。

このブランチが作られたのは、リポジトリのメインラインに直接コミットするのを避けるのがエージェントの既定の振る舞いだからです。それは妥当な既定値ですが、ここでは誤った判断でした。履歴がすでに語っていたとおり、マージコミットはどこにもなく、すべてのリリースがメインブランチに直接乗っていたのです。私はそれをメインブランチに畳み戻すことにしました。

ターミナルウィンドウ
git switch main
git merge --ff-only fix/question-vocabulary
git branch -d fix/question-vocabulary

--ff-onlyは、この一連の操作の中で実質的な仕事をしています。マージコミットの作成を拒否するので、ブランチをfast-forwardできない場合はコマンドが失敗し、この履歴には存在しないはずのマージバブルを黙って作ってしまうことがありません。**ステージングエリアとして使ったブランチを--ff-onlyで畳み戻せば、痕跡は残りません。**つまり、履歴がブランチを望んでいないと分かったとき、ブランチを切るコストはゼロだということです。

あとはpushすれば、形は確定します。

まとめ

5つの手順を、実行すべき順に示します。

  1. **git log --onelineを読み、**作業単位あたりのコミット数、メッセージの書式、チケット番号、マージコミットについて何を語っているかを書き出す。
  2. **形を提案し、**各コミットが何のためのものかを一文で言えるかどうかで確認する。
  3. 制約を見つける: 何がignoreされているか、何を自分が変更していないか。どちらも提案の前にではなく、後に現れる。
  4. **git reset --soft <base>で切り直す。**履歴に合うまで繰り返す。ファイルは変わらないので、何度でも安く繰り返せる。
  5. **反映する。**履歴にマージコミットがなければ、作業ブランチをgit merge --ff-onlyで畳み戻す。

この5つの手順の根底にある発見は、学ぶべき普遍的なコミットの形など存在しないということです。仕様は存在しますが、それはリポジトリごとに異なり、git logの最初の画面にそのまま置かれています。

このセッションはエージェント支援によるものだったので、人間の判断がどこにあったかを一言記しておきます。エージェントは履歴を調べ、3分割のグループ分けを提案し、.gitignoreの制約を見つけ、コミットメッセージを書きました。結果を左右した判断は私が下したものです。3つのコミットを1つにまとめたこと、そしてフィーチャーブランチを残す代わりにメインブランチに着地させたこと。エージェントは規約を読み取りましたが、それに従うかどうかを決めるのはエージェントの役割ではありませんでした。

参考リンク

この記事をシェア