第8回で、AI に作らせた最小の TODO アプリを GitHub に公開しました。ここから先は、そのアプリに機能を 1 つずつ足していきます。今日足すのは、完了チェック、削除ボタン、そして未完了の件数表示。Git の観点では、機能ごとに feature ブランチを切ってマージする、という王道のワークフローを実際のアプリでなぞります。
そして 3 つ目の件数表示で、わざとコンフリクトを起こします。コンフリクトは、AI 時代にこそ向き合い方が問われる場面です。「解決して」と AI に丸投げすると、AI が勝手に最終形を決めてしまう。でも頼み方を少し変えるだけで、AI は選択肢を整理して、私の判断を待つようになります。この違いが、今日いちばんの学びです。
TL;DR
- 第8回のアプリに、完了チェックと削除ボタンを
featureブランチで追加し、--no-ffでマージします (機能ごとにブランチを切る王道のやり方です)。 - 3 つ目の件数表示は、同じ見出しを 2 つのブランチで別々に書き換えて、わざとコンフリクトを起こします。
- コンフリクトを「解決して」と AI に丸投げすると、AI が勝手に最終形を決めます (反面教師)。
- 「解決しないで、状況と選択肢を説明して」と頼むと、AI は 3 択を整理して待ちます。選ぶのは私です。この分担は、
revertやresetなど判断が要る場面でも同じです。
今回のゴール
先に、今日作り終えたあとの履歴を見せます。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 merge は CONFLICT (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
main は 2aa8406 のままクリーンです。実機のリポジトリを汚さずに、学びだけを取り出しました (使い捨てブランチを -D で丸ごと捨てるのは、第5回で扱ったとおりです)。
2 つの頼み方を並べると、違いがはっきりします。
| 私の指示 | 起きること | |
|---|---|---|
| 丸投げ (反面教師) | 「ぶつかったら解決して」 | AI が勝手に最終形を決める。私は後から知る |
| 理想形 | 「解決しないで、状況と選択肢を説明して」 | AI が整理して 3 択を出す→私が選ぶ→ AI が仕上げる |
コンフリクトは、AI に決めさせる問題ではありません。AI に整理させて、決めるのは私。この分担は、コンフリクトだけの話ではありません。次回以降に出てくる revert や reset、force 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回で仕組みを見た revert や reset、reflog を、今度は本物のアプリで使います。取り消しとやり直しを、AI とどう分担するかも、今日と同じ目で見ていきます。
なお、この記事で AI に渡したひと言は、すべて Claude(Sonnet) に実際に渡し、書いてあるとおりに動くことを確かめたものです。とくにコンフリクト解決は、「解決して」と「説明して」の両方を実際に試し、AI の振る舞いが変わることを確認しています。モデルやその日の状態によって、AI が勝手に決める値 (ブランチ名や見出しの文言など) は変わります。指定しなかった部分は変わりうる、と思って読んでください。
パイソンエンジニア部 

