自分 1 人でも Pull Request を使う―フィルタ機能を gh pr create から squash マージまで

ここまでの実戦編では、機能を feature ブランチで作り、手元で git merge して main に取り込んできました。今回は手元のマージではなく、GitHub 上のプルリクエストを通します。

プルリクエストと聞くと「複数人のチームでやるもの」という印象があるかもしれません。でも、1 人のリポジトリでも使う価値があります。変更の差分が記録に残り、main を直接触らずにすむ。そして、細かいコミットを 1 つにまとめて履歴をきれいに保てます。第6回で仕組みを見たプルリクエストを、今度は自分のアプリで、本物の gh pr create から gh pr merge まで通してみます。

TL;DR

  • 第10回のアプリに、タスクを「すべて / 未完了 / 完了」で絞り込むフィルタ機能を feature ブランチで実装します。
  • これまでの手元マージではなく、本物のプルリクエストで取り込みます。gh pr create で PR を作り、gh pr merge --squash --delete-branch でマージとブランチ削除まで一度に。
  • 1 人のリポジトリでも PR を使う価値は、差分が記録に残ること、main が直接触られないこと、squash で履歴がきれいになることです。
  • --delete-branch はリモートだけでなくローカルのブランチも消します。最後に git pull で手元の main を最新にします。

今回やること――プルリクエストでフィルタ機能を足す

今日の流れは、GitHub flow と呼ばれる型です。第6回で図にしたものを、実際になぞります。

flowchart TD A["feature/filter-tabs で実装<br/>(ブランチを切る)"] --> B["push して<br/>Pull Request を作る"] B --> C["差分(Files changed)を<br/>自分でレビューする"] C --> D["squash でマージ<br/>+ブランチを自動削除"] D --> E["main が更新される<br/>git pull で手元も最新に"]

足すのは、タスクを「すべて / 未完了 / 完了」で切り替えるフィルタボタンです。機能としては小さいですが、これをローカルの git merge ではなくプルリクエスト経由で取り込むところが、今日の主役です。

AI に feature ブランチで実装させる

実装から PR、マージ、後片付けまで、流れごとひと言で頼みます。

タスクを「すべて / 未完了 / 完了」で絞り込めるボタンを付けて。新しいブランチ→
GitHub で Pull Request →自分でレビューOKでマージ→マージしたらブランチ削除、の流れで。

AI は feature/filter-tabs ブランチを切り、フィルタ機能を実装しました。中身でうまいと思ったのは、既存の機能にいっさい手を入れず、表示の切り替えだけで実現したことです。フィルタの本体は、この関数です。

let currentFilter = 'all';
// 現在のフィルタに合わせて各 li の表示を切り替える
function applyFilter() { list.querySelectorAll('li').forEach(function (li) { const isDone = li.classList.contains('done'); const visible = currentFilter === 'all' || (currentFilter === 'active' && !isDone) || (currentFilter === 'done' && isDone); li.style.display = visible ? '' : 'none'; });
}

タスクを消したり作り直したりするのではなく、li.style.display を切り替えて隠すだけ。だから、これまでに作った完了チェック・削除・件数表示・localStorage 保存には、まったく影響しません。ボタン側は 3 つ並べて、押されたら currentFilter を切り替えて applyFilter() を呼ぶだけです。

<div class="filter-row" id="filter-row"> <button data-filter="all" class="active">すべて</button> <button data-filter="active">未完了</button> <button data-filter="done">完了</button>
</div>

ここでも「指定した値」と「AI が勝手に決めた値」が分かれます。私が伝えたのは「すべて / 未完了 / 完了で絞り込むボタン」と「PR の流れで」だけ。次は AI が補いました。

  • ブランチ名を feature/filter-tabs にした
  • フィルタボタンを入力欄の直下に横並びで置き、押されている方を既存と同じ青 (#4a90e2) にした
  • タスクを作り直さず display を切り替える、影響範囲の小さい実装方式を選んだ

「既存に影響を出さない実装」を選んだのは、地味ですが効く判断です。機能を足すたびに前の機能が壊れていないか不安になりますが、AI は最初から「足すだけ・既存は触らない」形に寄せてきました。

gh pr create で Pull Request を作る

実装できたら、feature/filter-tabs を push して、プルリクエストを作ります。AI は GitHub の公式コマンド gh でこれをやりました。

gh pr create --title "feat: タスクフィルタ機能を追加(すべて/未完了/完了)" --base main --head feature/filter-tabs

--base main は「main に取り込みたい」、--head feature/filter-tabs は「この feature ブランチの変更を」という指定です。実行すると、GitHub 上にプルリクエストが 1 つ作られ、URL が返ってきます。今回できたのは、これです。

https://github.com/ikuma-hiroyuki/todo-app-git-deep-dive/pull/1

このページの「Files changed」タブを開くと、feature/filter-tabs で加えた差分が、追加行・削除行の色つきで一覧できます。これがレビュー画面です。たとえ自分 1 人でも、変更をまとめて眺めて「よし、これで main に入れていい」と確認する。この儀式が、あとから「この機能はいつ、何を変えて入ったか」をたどれる記録になります。手元で git merge してしまうと、この一覧は残りません。

レビューして、squash でマージする

差分を確認して問題なければ、マージします。

gh pr merge 1 --squash --delete-branch

1 は PR の番号です。--squash--delete-branch の 2 つが、今日のポイントです。

--squash は、feature ブランチにある複数のコミットを、main では 1 つのコミットにまとめて取り込みます。作業中の「とりあえず保存」みたいな細かいコミットが main の履歴に並ばず、「フィルタ機能を追加した」という 1 行だけが残ります。マージのしかたには 3 種類あって、この連載では squash を使います。

種別main の履歴ブランチのコミット
mergeマージコミットが 1 つ入るそのまま全部残る
squash1 コミットにまとまる (この連載で使う)1 つにつぶされる
rebase直列に並べ直される書き換えられる

個人の小さなアプリでは、機能 1 つ = main のコミット 1 つ、と読めるのがいちばん分かりやすいので、squash を選びます。マージ後の main の先頭は、こうなりました。

6865a3a feat: タスクを「すべて/未完了/完了」で絞り込むフィルタボタンを追加 (#1)

末尾の (#1) は、このコミットがプルリクエスト 1 番から来たことを示します。GitHub が自動で付けてくれるので、コミットから PR のレビュー画面へ後からたどれます。

マージ後の後片付け

--delete-branch を付けたので、役目を終えた feature/filter-tabs ブランチは、GitHub 側 (origin/feature/filter-tabs) が自動で削除されます。機能ごとにブランチを切って、マージしたら捨てる。第9回で触れたブランチの使い捨ての感覚が、PR でも同じように働きます。

ここで、ひとつ引っかかりがありました。念のため手元のブランチも消そうと思って git branch -d を試すと、エラーになったのです。

git branch -d feature/filter-tabs
# error: branch 'feature/filter-tabs' not found.

理由は単純で、gh pr merge --delete-branchリモートだけでなく手元のブランチも消していたからです。すでに無いものを消そうとしたので「見つからない」と言われた、という話で、実害はありません。こういう「一見エラーだけど実は問題ない」場面は、メッセージをそのまま AI に貼れば「これは --delete-branch がローカルも消したためで、問題ありません」と読み解いてくれます。

最後に、手元の main を最新にします。マージは GitHub 側で起きたので、手元の main はまだフィルタ機能の入る前のままです。

git pull

これで、手元の main も GitHub と同じ 6865a3a になりました。ブラウザでアプリを開いて、タスクを 3 件入れて 1 件を完了にし、フィルタを切り替えると、「すべて」で 3 件、「未完了」で 2 件、「完了」で 1 件と、ちゃんと絞り込めています。

AI に頼むときの言い方

今日も、指定した値と AI が勝手に決めた値に分かれていました。

種別こちらが指定した値指定しないと AI が勝手に決める値
機能フィルタボタン (すべて / 未完了 / 完了)・PR の流れでブランチ名・UI の位置と色・実装方式 (display 切り替え)
PR取り込み先は main・マージ後にブランチ削除PR タイトルの文言・squash か merge か

「PR の流れで」「マージしたらブランチ削除」という型だけ私が指定すれば、gh pr create のオプションや squash の選択は AI が埋めます。逆に、ブランチ名や PR タイトルのような「あとで自分が読むラベル」は、こだわりがあるなら最初に指定しておくと、後から探しやすくなります。

まとめ

第 11 回として、フィルタ機能を題材に、本物のプルリクエストを通しました。

  • フィルタ機能を feature/filter-tabs で実装した。AI は既存機能に影響しない display 切り替えを選んだ。
  • 手元の git merge ではなく、gh pr create でプルリクエストを作り、差分をレビューしてから取り込んだ。1 人でも、差分が記録に残り、main が守られる。
  • gh pr merge --squash --delete-branch で、細かいコミットを 1 つにまとめ、役目を終えたブランチを自動で消した。--delete-branch は手元のブランチも消すので、git branch -dnot found になっても問題ない。
  • マージは GitHub 側で起きるので、最後に git pull で手元の main を最新にする。

プルリクエストは、チームの作法であると同時に、1 人の開発でも「変更を記録に残し、main を守る」ための道具です。GitHub flow の型──ブランチ→ PR → squash マージ→ブランチ削除→ git pull ──を一度通しておけば、人が増えても同じやり方がそのまま通用します。次回は、ダークモードと並び替えという 2 つの機能を、git worktree同時並行に開発します。第7回で仕組みを見た worktree を、今度は AI を 2 体走らせる土台として使います。

なお、この記事で AI に渡したひと言は、すべて Claude(Sonnet) に実際に渡し、書いてあるとおりに動くことを確かめたものです。プルリクエストは実際に GitHub 上に #1 として作られ、squash マージまで通っています。モデルやその日の状態によって、AI が勝手に決める値 (ブランチ名や PR タイトルなど) は変わります。指定しなかった部分は変わりうる、と思って読んでください。