取り消しは revert か reset か― push 済みかどうかで決める、AI とのやり直し

第9回では、TODO アプリに完了チェックと削除を feature ブランチで足し、コンフリクトを AI と解決しました。今日はまず、ブラウザを閉じてもタスクが消えない保存機能を入れます。そのうえで、開発につきものの「やっぱり戻したい」を実戦で扱います。

戻し方には種類があります。revertresetreflog。仕組みは第5回で見たとおりですが、本物のアプリで使うと、判断がついて回ります。いちばん大事なのは「そのコミットは、もう GitHub に push したか」です。push 済みかどうかで、安全な戻し方が変わります。そして第9回のコンフリクトと同じで、この判断も AI に丸投げせず、選択肢を説明させてから自分で決めます。

TL;DR

  • 第9回のアプリに、localStorage でタスクを保存する機能を feature ブランチで足します。リロードしてもタスクが残ります。
  • push 済みのコミットを取り消すときは、revert(打ち消すコミットを足す・安全) か reset(履歴を巻き戻す・force が必要で危険) かを、AI に選択肢と適否を説明させてから決めます。
  • reset の 3 モード (--soft / --mixed / --hard) の違いは、git status の表示を見れば直感でわかります。
  • --hard で消しても reflog があるので、約 90 日は救出できます。散らかったコミットは reset --soft で 1 つにまとめ、書き換えた履歴は --force-with-lease で安全に上げ直します。

まず AI に localStorage 保存を作らせる

今のアプリは、リロードするとタスクが消えます。第8回で AI が「保存はしない」と判断した部分です。ここに保存機能を足します。

タスクがページを再読み込みしても消えないようにして。ブラウザのローカルに保存する形で。
新しいブランチで作業して、できたら main に取り込んで、GitHub にも反映して。

AI は feature/localstorage-persist ブランチを切り、保存と復元の処理を足して --no-ff でマージし、GitHub まで反映しました。中心になるのは、この 2 つの処理です。保存する側は、画面のタスクを配列にして localStorage に書き込みます。

// タスクの状態を localStorage に保存する
function saveTodos() { const items = Array.from(list.querySelectorAll('li')).map(function (li) { return { text: li.querySelector('span').textContent, done: li.classList.contains('done') }; }); localStorage.setItem('todos', JSON.stringify(items));
}

復元する側は、ページを開いたときに localStorage から読み戻して、画面に並べ直します。

// ページ読み込み時に保存済みタスクを復元
const saved = localStorage.getItem('todos');
if (saved) { JSON.parse(saved).forEach(function (item) { list.appendChild(createListItem(item.text, item.done)); });
}

ここでも「指定した値」と「AI が勝手に決めた値」が分かれます。私が伝えたのは「リロードしても消えないように」「ローカルに保存」だけ。次は AI が補いました。

  • localStorage のキー名を todos にした
  • データの形を {text, done} の配列にして、完了したかどうかまで保存した (リロードで完了が外れない)
  • 追加・削除・チェック変更の各操作のたびに、すぐ保存するようにした

完了状態まで保存したのは、地味ですが効く判断です。「リロードしても消えない」とだけ頼んだのに、AI は「完了のチェックも残るべきだろう」と読み取りました。実機で 3 件入れて 1 件完了させ、ページを再読み込みすると、件数も打ち消し線もそのまま残っていました。

取り消したいとき、revert か reset か

ここからが本題です。保存のついでに、キー名を todos から todo-app-data に変える小さな変更を 1 つコミットして、GitHub に push しました。そのうえで「やっぱり元に戻したい」となった、という場面を作ります。

戻し方を AI に丸投げると、勝手に決めます。第9回のコンフリクトと同じ失敗です。なので、こう頼みました。

やっぱり戻したい(履歴は残してよい)。取り消し方の選択肢と、push 済みでの適否を
説明してからやって。

AI は実行する前に、3 つの選択肢を、push 済みでの安全性つきで並べました。

方法仕組みpush 済みで安全か
git revert <hash>取り消す内容の新しいコミットを足す安全 (推奨)
git reset <hash>履歴を巻き戻して、コミットを無かったことにする危険 (force が必要・他人の作業を消しうる)
手動で書き戻して新コミットrevert と実質同じ安全だが手間

そのうえで AI は、revert を選んだ理由をこう説明しました。問題のコミットはすでに push 済みで、GitHub 上で公開されている。reset で履歴を巻き戻すと、その公開済みの歴史が変わってしまい、上げ直すには force(強制上書き) が要る。もし他の人がその歴史をもとに作業していたら、その作業を壊しかねない。だから、履歴を残したまま打ち消す revert が安全だ、と。

この判断の絵を 1 枚にすると、こうなります。

flowchart TD C3["取り消したいコミット<br/>(すでに GitHub に push 済み)"] --> RV["revert:打ち消す新コミットを足す<br/>履歴が残る→ push 済みでも安全"] C3 --> RS["reset:コミットを無かったことにする<br/>履歴が変わる→ force 必須・公開済みは危険"]

説明に納得してから、実行を任せます。

git revert 319183c --no-edit
git push

git revert は、319183c(キー名を変えたコミット) の中身を打ち消す新しいコミットを作ります。履歴は消えません。「変えた」記録と「戻した」記録が、両方とも残ります。push 済みでも、新しいコミットを足すだけなので、force は要りません。普通の git push で送れます。

「push 済みかどうかで revertreset かを決める」。この判断軸さえ持てば、戻し方で迷いません。判断は私がして、手は AI に動かしてもらう。第9回からの分担は、ここでも同じです。

reset 3 モードを git status で見る

reset は公開済みの履歴には危険ですが、まだ push していない手元のコミットになら、安全で便利です。第5回で見た 3 つのモードの違いを、実際に git status の表示で確かめます。

小さなコミットを 2 つ作ってから、「直前のコミットを取り消す」のを 3 パターンで試すよう頼みました。

小さなコミットを2つ作って。そのあと「直前を取り消す」を、(A) 変更は作業場に残す /
(B) ステージに残す / (C) 変更ごと完全に消す、の3パターンで実演して。

AI が 3 つを順に実演し、それぞれ直後の git status がどう変わったかが、違いをそのまま物語ります。

指示コマンド直後の git status変更の居場所
(A) 作業場に残すgit reset HEAD~1(=--mixed)Changes not staged for commit作業ツリー
(B) ステージに残すgit reset --soft HEAD~1Changes to be committedステージング
(C) 完全に消すgit reset --hard HEAD~1nothing to commit, working tree clean消えたように見える

第4回で見た「3 つの領域」(作業ツリー→ステージング→リポジトリ) を思い出すと、すっきりします。reset はコミットを取り消す操作ですが、取り消した変更をどの領域まで戻すかが 3 モードの違いです。--soft はステージングまで、--mixed(既定) は作業ツリーまで戻し、--hard はどこにも残さず捨てます。

(C) の --hard だけは、変更が消えます。ここで初心者は身構えます。でも、次の節で見るように、消えたように見えるだけです。

消したコミットを reflog で救出する

--hard で消したコミットは、本当に取り返せないのか。AI に聞いてみます。

さっき (C) で消したコミット、復活できる? できるなら復活して。

AI は git reflog を使いました。reflog は、第5回で「やらかしてからも数週間は戻せる猶予」として紹介した安全網です。HEAD が動いたすべての記録が残っているので、--hard で消したつもりのコミットも、ここにハッシュが残っています。

git reflog
# …消したはずのコミット 1b2493f が HEAD@{1} などとして残っている
git cherry-pick 1b2493f

git cherry-pick で、そのコミットを今のブランチに改めて取り込めば、復活します。--hardHEAD というしおりを動かしただけで、コミットの実体 (第3回で見たオブジェクト) は消えていなかったのです。到達できなくなったオブジェクトも、約 90 日は reflog から拾えます。

--hard でも reflog がある」。これがわかると、reset が怖くなくなります。取り消しを思い切って試せるようになることが、Git に慣れる入口です。

散らかったコミットを squash でまとめる

ここまでの実演で、小さなコミットが何個か散らかりました。これを 1 つにまとめます。

散らかった小コミットを1つにまとめて。

AI が使ったのは、第5回でも触れた最もシンプルなまとめ方です。

git reset --soft HEAD~2
git commit -m "..."

git reset --soft HEAD~2 で、直近 2 つ分のコミットを取り消します。さきほどの表のとおり、--soft なので変更はステージングに残ったままです。あとはそれを 1 回コミットし直せば、2 つが 1 つにまとまります。難しい操作なしで、履歴をきれいにできます。

書き換えた履歴は --force-with-lease で上げ直す

reset で履歴を書き換えたので、GitHub に上げ直します。ただし、ここはさきほど「危険」と言った場面です。安全に、と頼みます。

歴史を書き換えたので GitHub に上げ直して。普通の push は断られるはずだから、安全な方法で。

書き換えた履歴は、GitHub 側の履歴と食い違います。普通に git push すると、non-fast-forward だと言って弾かれます。GitHub が「君の歴史は、こっちの歴史と枝分かれしている。このまま上書きしていいのか」と止めてくれているわけです。

ここで強制的に上書きする方法が 2 つあります。違いは安全装置の有無です。

オプション動作リスク
git push --force問答無用で上書きする自分が見ていない間に他人が push していたら、それを消す
git push --force-with-lease自分が最後に見た時点と GitHub 側が一致するときだけ上書きする他人の push があれば弾かれるので、消さずにすむ

--force-with-lease は、「自分が最後に確認したときから、向こうが動いていないことを確かめてから上書きする」という安全装置つきの強制 push です。第5回でも、作り直した歴史を上げるのに使いました。履歴を書き換えたあとの push は、いつも --force-with-lease。これを習慣にしておけば、自分の取り消しで他人の作業を吹き飛ばす事故を防げます。

git push --force-with-lease

ひとつ、正直な裏話があります。この実演の最初、普通の push弾かれませんでした。書き換えたはずのコミットが、まだ push されていない手元だけのものだったので、GitHub から見れば単なる早送りで済んだのです。AI はそれに気づいて、「これでは force-with-lease の出番が作れない」と、わざと履歴を食い違わせる状況を作り直してから、もう一度実演し直しました。狙った場面が出ないとき、AI が黙って成功したことにせず、条件を整え直して見せてくれたのは、地味ですが信頼できる動きでした。

AI に頼むときの言い方

今日の作業も、毎回「指定した値」と「AI が勝手に決めた値」に分かれていました。

種別こちらが指定した値指定しないと AI が勝手に決める値
localStorage「リロードしても消えないように」「ローカルに保存」キー名 todos・データの形 (完了状態まで保存)・保存のタイミング
取り消し「選択肢と適否を説明してからやって」push 済みかどうかの判断・revert を選ぶこと
reset「(A)(B)(C) の 3 パターンで実演」各モードのコマンド・git status の見せ方
まとめ「1 つにまとめて」reset --soft の件数・新しいコミットメッセージ
上げ直し「安全な方法で」--force-with-lease を選ぶこと (--force を使わない判断)

下 3 行のように、「3 パターンで」「安全な方法で」という方向だけ私が与えれば、具体的なコマンドは AI が正しく埋めます。逆に取り消しの行のように「説明してからやって」と頼めば、判断材料を出させてから自分で決められます。

まとめ

第 10 回として、保存機能を足しながら、取り消しとやり直しを実際のアプリで動かしました。

  • localStorage 保存を feature ブランチで追加した。AI は完了状態まで保存する判断をした。
  • push 済みのコミットは、revert(履歴を残して打ち消す・安全) で戻す。reset は公開済みの履歴を壊すので、手元のまだ push していないコミットにだけ使う。この判断を AI に説明させてから決めた。
  • reset の 3 モードは、取り消した変更をどの領域まで戻すかの違い。git status を見れば直感でわかる。--hard で消しても reflog から約 90 日は救出できる。
  • 散らかったコミットは reset --soft でまとめ、書き換えた履歴は --force-with-lease で安全に上げ直す。

取り消しの怖さの大半は「戻せないかもしれない」という不安です。reflog という安全網と、「push 済みかどうかで revertreset か」という判断軸を持てば、思い切って取り消せるようになります。次回は、このアプリに「すべて・未完了・完了」で絞り込むフィルタ機能を足し、それをプルリクエストの形で main に取り込みます。第6回で仕組みを見たプルリクエストを、今度は自分のアプリで回します。

なお、この記事で AI に渡したひと言は、すべて Claude(Sonnet) に実際に渡し、書いてあるとおりに動くことを確かめたものです。とくに取り消し系は、revert の選択・reset 3 モード・reflog 救出・--force-with-lease までを実機で順に再現しています。モデルやその日の状態によって、AI が勝手に決める値 (キー名やコミットメッセージなど) は変わります。指定しなかった部分は変わりうる、と思って読んでください。