feature ブランチでコンフリクトを起こして解決する― AI に「丸投げ」せず「相談」する

第8回で、AI に作らせた最小の TODO アプリを GitHub に公開しました。ここから先は、そのアプリに機能を 1 つずつ足していきます。今日足すのは、完了チェック、削除ボタン、そして未完了の件数表示。Git の観点では、機能ごとに feature ブランチを切ってマージする、という王道のワークフローを実際のアプリでなぞります。

そして 3 つ目の件数表示で、わざとコンフリクトを起こします。コンフリクトは、AI 時代にこそ向き合い方が問われる場面です。「解決して」と AI に丸投げすると、AI が勝手に最終形を決めてしまう。でも頼み方を少し変えるだけで、AI は選択肢を整理して、私の判断を待つようになります。この違いが、今日いちばんの学びです。

TL;DR

  • 第8回のアプリに、完了チェックと削除ボタンを feature ブランチで追加し、--no-ff でマージします (機能ごとにブランチを切る王道のやり方です)。
  • 3 つ目の件数表示は、同じ見出しを 2 つのブランチで別々に書き換えて、わざとコンフリクトを起こします。
  • コンフリクトを「解決して」と AI に丸投げすると、AI が勝手に最終形を決めます (反面教師)。
  • 「解決しないで、状況と選択肢を説明して」と頼むと、AI は 3 択を整理して待ちます。選ぶのは私です。この分担は、revertreset など判断が要る場面でも同じです。

今回のゴール

先に、今日作り終えたあとの履歴を見せます。git log --oneline --graph で表示したものです。

*   2aa8406 Merge branch 'feature/pending-count-alt': 見出し書式を「残り N 件」に統一して解決
|\
| * 1f611da feat: 見出しに未完了タスク件数を「未完了: N件」形式で表示
* | 4766b09 feat: 見出しに未完了タスク件数を「TODO(残り N)」形式で表示
|/
*   8c51421 Merge branch 'feature/delete-task': 削除ボタン機能を追加
|\
| * 234170f feat: 各タスクに削除ボタン(×)を追加
|/
*   27a4fb9 Merge branch 'feature/toggle-complete': チェックで完了/打ち消し線機能を追加
|\
| * 4c9ba19 feat: チェックボックスで完了状態を切り替え、打ち消し線を表示
|/
* 03ed327 Initial commit: add todo app

03ed327 が、第8回で作った最初のコミットです。そこから上に、枝分かれして合流する菱形が 3 つ積み重なっています。1 つの菱形が、1 つの機能を feature ブランチで作って main に合流させた跡です。今日はこの形を、実際に手を動かして (正確には AI に頼んで) 作っていきます。

ブランチの正体が「コミットを指す 40 文字のしおり」で、マージに早送り (fast-forward) と 3 方向マージの 2 種類があることは、第4回で確かめたとおりです。今回はその仕組みを、本物のアプリ開発の中で使います。

完了チェックを feature ブランチで追加する

まず、完了したタスクにチェックを付けられるようにします。私が AI に渡したのは、このひと言です。

完了したタスクにチェックを付けられるようにして。チェックを入れたら文字に打ち消し線が
付く感じで。新しいブランチを切ってやって、できたら main に取り込んで。

「新しいブランチを切ってやって、できたら main に取り込んで」と頼んだだけで、AI は次の手順を踏みました。

git checkout -b feature/toggle-complete
# index.html を編集(チェックボックスと打ち消し線の CSS を追加)
git add index.html
git commit -m "feat: チェックボックスで完了状態を切り替え、打ち消し線を表示"
git checkout main
git merge feature/toggle-complete --no-ff

git checkout -b は、第4回で覚えた git switch -c と同じ「ブランチを作って移動する」操作です。今ふうの switch ではなく古くからある checkout を AI が選んだのも、私が指定しなかったからです。

ここでも「指定した値」と「AI が勝手に決めた値」が分かれます。私が伝えたのは「チェックで打ち消し線」「別ブランチで作って main に取り込む」だけ。次の点は AI が補いました。

  • ブランチ名を feature/toggle-complete(feature/ + 内容を表す英語) にした
  • UI を <input type="checkbox"><span> で組み、完了した行に done クラスを付けて打ち消し線にした
  • コミットメッセージを feat: で始まる形 (第3回で触れた Conventional Commits 風) にした

--no-ff とは何か

マージの行に --no-ff が付いているのに気づいたでしょうか。これは “no fast-forward”、つまり「早送りをしない」という指定です。

第4回で見たように、main が枝分かれ後に 1 歩も進んでいなければ、Git は新しいコミットを作らず main のしおりをスライドさせるだけで合流します (早送り)。このとき履歴は一直線になり、「ブランチがあった」という事実は残りません。

--no-ff を付けると、早送りできる場面でも Git はあえてマージコミットを作ります。さきほどのゴールのグラフで菱形になっていたのは、これが理由です。一直線ではなく菱形が残るので、「ここで feature/toggle-complete という枝が分かれて、合流した」と後から読めます。機能の区切りを履歴に刻みたいとき、--no-ff は素直な選択です。

削除ボタンを別の feature ブランチで追加する

次は、タスクを消せるようにします。今度も別のブランチで作業します。

タスクを消せるように、各タスクに削除ボタンも付けて。これも別のブランチで作業して、
できたら main に取り込んで。

AI の手順は完了チェックのときと同じ形です。ブランチを切り、編集してコミットし、main に戻って --no-ff でマージする。

git checkout -b feature/delete-task
# index.html を編集(×ボタンとクリックで行を消す処理を追加)
git add index.html
git commit -m "feat: 各タスクに削除ボタン(×)を追加"
git checkout main
git merge feature/delete-task --no-ff

この機能で AI が勝手に決めたのは、削除ボタンを × の文字にして右端に寄せ (margin-left:auto)、マウスを乗せると赤 (#e55) にする、という見た目でした。私は「削除ボタンを付けて」としか言っていません。

機能ごとにブランチを切り、マージしたら次の機能でまた切る。この使い捨ての感覚が大事です。feature ブランチで何を試しても、合流するまで main は無傷のまま。main を常に「動く状態」に保てることが、ブランチを切るいちばんの理由です。

わざとコンフリクトを起こす

ここまでの 2 つは、すんなり合流しました。3 つ目は、わざとぶつけます。コンフリクトが起きる条件は、第4回で確かめたとおり「同じファイルの同じ行を、2 つのブランチで別々に変える」ことです。

仕掛けはこうです。削除ボタンまで入った状態 (共通の親) から main と別ブランチが分かれ、両方が同じ見出しを違う文言に書き換えます

flowchart TD
    P["共通の親 8c51421<br/>削除ボタンまで入った状態"] --> A["main 側<br/>見出しを「TODO(残り N)」に変更"]
    P --> B["別ブランチ側<br/>同じ見出しを「未完了: N件」に変更"]
    A --> M["マージすると…<br/>同じ行が 2 通り=コンフリクト"]
    B --> M

このときに私が AI へ渡したのは、こんなひと言です。

未完了のタスク件数を見出しに出したい。まず main 側で見出しを『TODO(残り N)』にする
変更をコミット。それとは別に、その変更前の状態から分けたブランチで同じ見出しを
『未完了: N件』にする変更を作ってコミット。最後にその別ブランチを main にまとめて。
ぶつかったら自然な形に解決して。

AI はこの設計どおり、main 側と別ブランチ側で同じ見出しを別々に書き換え、マージしました。

# main 側:見出しを「TODO(残り N)」にしてコミット
git checkout main
git add index.html
git commit -m "feat: 見出しに未完了タスク件数を「TODO(残り N)」形式で表示"

# 変更前の状態から別ブランチを分け、同じ見出しを「未完了: N件」に
git checkout -b feature/pending-count-alt 8c51421
git add index.html
git commit -m "feat: 見出しに未完了タスク件数を「未完了: N件」形式で表示"

# マージ→ぶつかる
git checkout main
git merge feature/pending-count-alt --no-ff
# CONFLICT (content): Merge conflict in index.html

git mergeCONFLICT (content): Merge conflict in index.html を返して止まりました。index.html を開くと、ぶつかった 2 か所にマーカーが入っています。見出しの HTML が 1 か所、それを書き換える JavaScript が 1 か所です。

<<<<<<< HEAD
    <h1 id="heading">TODO(残り 0)</h1>
=======
    <h1 id="heading">未完了: 0件</h1>
>>>>>>> feature/pending-count-alt
<<<<<<< HEAD
      heading.textContent = 'TODO(残り ' + pending + ')';
=======
      heading.textContent = '未完了: ' + pending + '件';
>>>>>>> feature/pending-count-alt

マーカーの読み方も、第4回でひととおり見ました。おさらいすると、<<<<<<< HEAD から ======= までが今いるブランチ (main) 側======= から >>>>>>> までが取り込もうとしているブランチ側です。Git は「この行はどちらを採用しますか」と聞いてきています。ここまでは、第4回schedule.txt の納期がぶつかった例と同じ構図です。

問題はこの先、誰がどう決めるかです。

「解決して」と丸投げすると、どうなるか

さきほどの指示文の末尾を、もう一度見てください。「ぶつかったら自然な形に解決して」。私はこう頼みました。AI は、この一言を受けて、自分の判断でマーカーを消し、見出しを「残り N 件」に統一して、コミットまで済ませてしまいました。

main 側の「TODO(残り N)」と、別ブランチ側の「未完了: N 件」。AI はそのどちらでもなく、「残り」と「件」を組み合わせた第三の案を選んだのです。

出来上がりとしては、悪くありません。むしろスッキリしています。でも、よく考えると引っかかります。見出しをどう見せるかは、アプリの設計判断です。それを私は見もせずに、AI に決めさせてしまった。「解決して」と頼んだ瞬間に、判断がまるごと AI に渡っていたのです。

私が結果を知ったのは、AI が「残り N 件にしました」と報告してきた後でした。なぜその形にしたのか、ほかにどんな選択肢があったのかは、説明されません。コンフリクトは、本来このアプリの作り手が決めるべき場面です。それを丸投げすると、自分の意図が静かに消えます。

コンフリクト解決の理想形は、説明させてから自分で選ぶ

では、どう頼めばよかったのか。同じコンフリクトをもう一度起こして、今度は頼み方を変えてみます。main を汚さないよう、使い捨ての study/ ブランチで同じ衝突を再現しました (本物の main は、さきほどの 2aa8406 のまま一切触りません)。

ぶつかった状態で、私はこう頼みました。

解決はしないで。状況と選択肢を説明して。

すると AI は、勝手に直すのをやめて、こう整理して返してきました。

まず、何が起きているか。「2 つのブランチが同じ行を別の内容に書き換えたため、Git がどちらを採るか判断できずに止まっている」。次に、ぶつかっているのは見出しの HTML と、それを書き換える JavaScript の 2 か所だ、という地図。そのうえで、3 つの選択肢を、それぞれの結果つきで並べてくれました。

選択見出しの表示印象
(a) main 側を採用TODO(残り 3)英語混じり、アプリ名が残る
(b) 別ブランチ側を採用未完了: 3件業務的で実用的
(c) 両方の良さを取る残り 3 件スッキリ、どちらの長所も

そして「あなたの決断を待っています」と言って、マージの途中で止まりました。コミットはまだしていません。

ここで初めて、私が選びます。今回は (c) を選びました。すると AI は、選んだ案でマーカーを消し、git add から git commit まで進めて合流を仕上げます。解決後のコードは、両方の見出しで '残り ' + pending + ' 件' に揃いました。

使い捨てで再現しただけなので、後片付けをします。

git checkout main          # 本物の main(2aa8406)に戻る
git branch -D study/todo-count-style-a study/todo-count-style-b

main2aa8406 のままクリーンです。実機のリポジトリを汚さずに、学びだけを取り出しました (使い捨てブランチを -D で丸ごと捨てるのは、第5回で扱ったとおりです)。

2 つの頼み方を並べると、違いがはっきりします。

私の指示起きること
丸投げ (反面教師)「ぶつかったら解決して」AI が勝手に最終形を決める。私は後から知る
理想形「解決しないで、状況と選択肢を説明して」AI が整理して 3 択を出す→私が選ぶ→ AI が仕上げる

コンフリクトは、AI に決めさせる問題ではありません。AI に整理させて、決めるのは私。この分担は、コンフリクトだけの話ではありません。次回以降に出てくる revertresetforce push のように「判断を間違えると痛い」場面でも、まったく同じ構えが効きます。AI に状況と選択肢を出させてから、自分で決める。これが AI 時代の Git の基本姿勢です。

GitHub に反映する

最後に、ここまでの main を GitHub にも送ります。

最後に、ここまでの main の状態を GitHub にも反映して。

第8回gh repo create のときに送り先 (origin) が登録済みなので、git push だけで送れます。

git push origin main

GitHub のリポジトリページで「Insights」→「Network」を開くと、さきほどの菱形 3 つが、そのままグラフで見えます。手元で作った履歴の形が、GitHub 上でも同じように再現されています。

AI に頼むときの言い方

今日も、毎回「指定した値」と「AI が勝手に決めた値」に分かれていました。1 枚に整理します。

種別こちらが指定した値指定しないと AI が勝手に決める値
完了 UI「チェックを入れたら打ち消し線」<input type="checkbox"> の構成・done というクラス名・CSS の具体値
削除 UI「削除ボタンを付けて」× の文字・右端寄せ・ホバー色 #e55
ブランチ名(指定せず)feature/ + 内容を表す英語 (例 feature/toggle-complete)
コミット文言(指定せず)feat: で始まる形 (日本語本文)
マージ方式(指定せず)--no-ff(マージコミットを残す)
コンフリクト解決「解決しないで、選択肢を説明して」この一言がないと「残り N 件」を勝手に採用する

いちばん下の行が、今日の核心です。同じコンフリクトでも、ひと言の違いで、AI は「勝手に決める相手」にも「整理して待つ相手」にもなります。

まとめ

第 9 回として、第8回のアプリに 3 つの機能を足しながら、ブランチ・マージ・コンフリクトを実際の開発で動かしました。

  • 完了チェックと削除ボタンを、機能ごとに feature ブランチで作り、--no-ff でマージした。履歴に菱形が残り、「どの機能をどこで足したか」が読める。
  • 3 つ目の件数表示で、同じ見出しを 2 つのブランチで別々に書き換え、わざとコンフリクトを起こした。
  • 「解決して」と丸投げすると、AI が勝手に最終形を決める。設計の判断が、見ないうちに AI へ渡ってしまう。
  • 「解決しないで、状況と選択肢を説明して」と頼むと、AI は 3 択を整理して待つ。決めるのは私。この分担が、コンフリクト解決の正しい形であり、これから出てくる取り消し系の操作でも同じ。

次回は、このアプリに「ブラウザを閉じても消えない保存」を入れます。そして保存まわりで何度かやり直すうちに、第5回で仕組みを見た revertresetreflog を、今度は本物のアプリで使います。取り消しとやり直しを、AI とどう分担するかも、今日と同じ目で見ていきます。

なお、この記事で AI に渡したひと言は、すべて Claude(Sonnet) に実際に渡し、書いてあるとおりに動くことを確かめたものです。とくにコンフリクト解決は、「解決して」と「説明して」の両方を実際に試し、AI の振る舞いが変わることを確認しています。モデルやその日の状態によって、AI が勝手に決める値 (ブランチ名や見出しの文言など) は変わります。指定しなかった部分は変わりうる、と思って読んでください。