AI に「ここ直して」「やっぱり戻して」と何度も頼んでいるうちに、コミットが「wip(work in progress=作業途中。コミットメッセージによく使われる略語です)」「やっぱり違う」「今度こそ」で散らかっていた。あるいは、いらないコミットを消そうとして、必要な作業まで巻き込んで消えた。Git を使い始めると、誰もが一度はこの瞬間に出会います。
第4回までで、毎日使うコマンドは鎖を前へ進めるだけだと確かめました。今回はその逆向き、巻き戻すと作り直すを扱います。怖がられがちな
resetreflogrebasesquash
の 4 つです。どれも、たった 2 つの見方で読み解けます。
- 「鎖のどこを、どこまで動かすか」
- 「コミットを作り直すとハッシュ (コミットを区別する 40 文字の文字列) が変わる」
この 2 つだけ押さえておけば、身構える必要はありません。
flowchart LR C["コミットの鎖<br/>HEAD→ブランチ→コミット"] --> U["巻き戻す<br/>reset / reflog<br/>鎖のどこまで戻すか"] C --> R["作り直す<br/>rebase / squash<br/>ハッシュが変わる"]
TL;DR
resetは「ブランチのしおりを巻き戻して、どの木まで揃えるか」を選ぶだけです。--soft/--mixed/--hardの違いは戻す範囲。--hardだけが作業を消します。reset --hardで消したコミットもreflogから戻せます。やらかしたら、まずreflog。これが「安心して失敗できる」最後の砦です。rebaseはコミットを作り直します=ハッシュが変わります。だから「他人と共有済みの歴史は rebase しない」が鉄則です。squashでまとめると別ハッシュの新しいコミットになるので、元のブランチは-d(安全削除) では消せず-D(強制削除) が要ります。ブランチが溜まる理由の半分はこれです。
巻き戻す:reset の 3 モードは「鎖のどこまで戻すか」
ブランチは、コミットを指す「しおり」のようなものでした (第3回で見た 40 文字のファイルです)。git reset がやることは、突き詰めると 1 つです。そのしおりを指定したコミットへ動かし、加えて「どの木まで一緒に揃えるか」を選ぶ。これだけです。
ここでいう 3 つの木は、第4回で見た作業ツリー・ステージング・リポジトリの 3 領域のことです (くわしくは前回を参照してください)。reset は「しおりをどこへ戻すか」に加えて、「この 3 つのうち、どこまでをその地点に合わせるか」をオプションで選びます。それが --soft / --mixed / --hard の正体です。
| モード | ブランチのしおり | ステージング | 作業ツリー | 使いどき |
|---|---|---|---|---|
--soft | 巻き戻す | そのまま | そのまま | コミットだけ取り消して、すぐ作り直したい |
--mixed(既定) | 巻き戻す | 巻き戻す | そのまま | コミットも add も取り消して、編集からやり直したい |
--hard | 巻き戻す | 巻き戻す | 巻き戻す | コミットも変更も完全に消したい (唯一、作業が消える) |
上から下へ、巻き戻す木の範囲が 1 段ずつ広がっていきます。--soft は変更がステージング (コミットに入れる物を選ぶ買い物カゴ) に残るので、コミットし直すのが簡単です。--mixed は変更が作業ツリーに残るので、編集から見直せます。--hard だけは作業ツリーまで巻き戻すので、手元の変更が消えます。
言葉だけだと怖いので、使い捨てのリポジトリで実際に動かして、戻る範囲が広がるのを見てみます。第3回で作った git-lab(無ければ mkdir ~/Desktop/git-lab && cd ~/Desktop/git-lab && git init -b main で作れます) で続けます。
以下で出てくる git commit -am は、第4回の git add と git commit -m を 1 つにまとめた省略形です。-a が「追跡中のファイルの変更を自動でステージングに載せる」、-m が「メッセージを付ける」で、その合体だと思ってください(新規ファイルには -a が効かないので、その場合は従来どおり git add が要ります)。
# 実験用に1コミット積む echo soft >> README.md && git commit -am "wip-soft" git reset --soft HEAD~1 && git status # →変更は「ステージング」に残る git commit -m "redo" # すぐコミットし直せる echo mixed >> README.md && git commit -am "wip-mixed" git reset --mixed HEAD~1 && git status # →変更は「作業ツリー」に残る(未ステージ) git add -A && git commit -m "redo2" echo hard >> README.md && git commit -am "wip-hard" git reset --hard HEAD~1 && git status # →変更ごと消える(working tree clean)
AI に頼むなら、戻したい範囲を言葉にするだけです。--soft などのオプション名は覚えなくても、「変更を残すか、どこに残すか」を言えば、AI が 3 つのモードを選び分けます。
直前のコミットだけ取り消して、変更はステージングに残したまま
が --soft(すぐコミットし直したいとき)。
直前のコミットを取り消して、変更は add も取り消した作業ツリーに戻して
が --mixed(編集からやり直したいとき)。
直前のコミットも変更内容も完全に捨てて、ひとつ前にきれいに戻して
が --hard(丸ごと捨てたいとき) です。実際にこの 3 つを使い捨てリポで AI に渡すと、それぞれ --soft / --mixed / --hard を正しく選び分けました。
同じ reset でも、--soft → --mixed → --hard の順に、戻す木が 1 段ずつ深くなります。--hard だけが作業ツリーまで消す、唯一データを失うモードです。
取り消し系には、似た名前のコマンドが 3 つあります。reset はブランチのしおりを動かす (歴史を巻き戻す)。restore は特定のファイルの中身だけ戻す (例 git restore file.txt)。revert は過去のコミットを打ち消す新しいコミットを足す(歴史を消さずに取り消しを記録する)。このうち revert だけは歴史を作り直さないので、すでに他人と共有したコミットを安全に取り消せる唯一の方法です。
--hard は唯一作業が消えるモードだと書きました。けれど、消したコミットも実はまだ生きています。次にその安全網を見ます。
やらかしても戻せる:reflog という安全網
reset でしおりを動かしても、コミットの実体 (オブジェクト) はその場では消えません。ただ「どのしおりからも指されなくなった」だけです。そして Git は、HEAD(今あなたがどのコミットにいるかを指す矢印) が過去にどこを指していたかの全履歴を reflog(reference log の略。参照の移動ログ) に記録しています。
だから、reset --hard で消してしまっても、移動ログをたどれば戻せます。これも git-lab で実際に蘇らせてみます。
git log --oneline # 今の先頭コミットを確認
git reset --hard HEAD~1 # わざと直前のコミットを消す
git log --oneline # 1つ減っている
git reflog # → HEAD@{1}: ... 消したはずのコミットが残っている!
git reset --hard HEAD@{1} # reflogに出た位置へ戻す=復活
git log --oneline # 戻った!AI に頼むなら
さっき reset で消してしまったコミットを、消える前の状態に戻して
この一言で、AI は git reflog で消える前の地点(HEAD@{1})を見つけ、そこへ戻します。「reflog」という言葉を知らなくても、「消える前に戻して」と言えば、AI が安全網の使い方を補ってくれます。
reset --hard してもオブジェクトは残っていて、reflog に HEAD の移動履歴があります。だから戻せます。
では、その記録はいつまで残るのでしょうか。reflog の移動記録は、既定で 90 日 保持されます。reset --hard などでどのしおりからも届かなくなったコミットの記録も、30 日 は残ります。つまり「やらかしてから数週間は、reflog をたどって戻せる猶予がある」ということです。記録から外れたコミットの実体のほうも、git gc(不要なオブジェクトを掃除する片付け処理。既定では 2 週間より古い、どこからも届かないオブジェクトが対象) が走るまでは残っています。
やらかしたら、まず git reflog。これが、Git が「安心して失敗できる道具」である最後の砦です。この一手を知っているかどうかで、巻き戻し系のコマンドへの恐怖はほとんど消えます。
編み直す:rebase は「コミットを作り直す」
git rebase は、あるブランチのコミット群を、別のコミットを土台 (base) にして作り直すコマンドです。結果として、枝分かれしていた歴史が一直線に整います。
rebase の前は、feature が main の途中 (C2) から枝分かれしています。
flowchart TD C1 --> C2 --> C3["C3<br/>main"] C2 --> C4 --> C5["C5<br/>feature"]
git rebase main を実行すると、feature の C4・C5 が main の先端 (C3) の上に作り直されます。中身は同じでも C4’・C5′ という別物のコミットになり、歴史が一直線に並びます。
flowchart TD C1 --> C2 --> C3["C3<br/>main"] --> C4b["C4'"] --> C5b["C5'<br/>feature"]
merge(第4回で見た合流) との違いは、歴史の見せ方の思想です。
| merge | rebase | |
|---|---|---|
| 歴史の形 | 分岐と合流がそのまま残る | 一直線に整形される |
| 思想 | 起きたことをありのまま記録 | 歴史は読む人のために整える |
| コミット | マージコミットが増える | 既存コミットを作り直す (ハッシュが変わる) |
ここで効いてくるのが 1 つの鉄則です。
共有済みの歴史は rebase してはいけない。
rebase はコミットを作り直す=ハッシュが変わります。すでに他人が持っているコミットを作り直すと、相手の歴史と食い違って大混乱になります。まだ自分しか持っていない手元の歴史を綺麗にする、これが安全な使い方です。直前のコミットを修正する
git commit --amendも中身はミニ rebase で、同じルールが効きます。
「作り直す=ハッシュが変わる」は、口で言うより見たほうが早いです。git-lab で続けて、rebase の前後でハッシュが変わるのを確かめます。
git switch -c topic printf 'topic\n' > topic.txt && git add -A && git commit -m "topic work" git rev-parse topic # ← rebase前のハッシュをメモ git switch main printf 'main advance\n' >> README.md && git commit -am "main advance" git switch topic git rebase main # topicのコミットを、進んだmainの上に作り直す git rev-parse topic # ←ハッシュが変わっている!(=別物のコミットになった) git log --oneline --graph --all
AI に頼むなら
topic のコミットを、1 つ先に進んだ main の先端の上に乗せ替えて、歴史を一直線にして
この一言で、AI は merge ではなく git rebase main を選びます。topic のコミットは作り直されてハッシュが変わり、枝分かれが消えて歴史が一直線に整います。「乗せ替えて一直線に」という言い方が、merge と rebase を分ける決め手です。
topic のコミットのハッシュが、rebase の前後で変わります。中身は同じでも、土台が変われば「作り直した別物のコミット」になる。これが「共有済みは危険」の理由そのものです。
作り直した歴史を push するとどうなるか
第3回・第4回でやったように、この git-lab を GitHub に上げている場面を考えます。すでに push したコミットを作り直してから、もう一度 push すると、どうなるでしょうか。実際に試せます。
git switch -c shared echo x >> README.md && git commit -am "shared work" git push -u origin shared # まず GitHub に上げる(初回は普通に送れる) git commit --amend -m "shared work (修正)" # 直前を作り直す=ハッシュが変わる git push # → ! [rejected] ... (non-fast-forward) で止まる
GitHub にある歴史と、手元で作り直した歴史が食い違うので、Git は push を拒否します。これが「共有済みの歴史は rebase しない」の正体です。リモートを力ずくで上書きして他の人の歴史を壊さないよう、Git が止めてくれているわけです。
それでも上げ直したいとき——たとえば、自分しか使っていないブランチを綺麗にしてから上げたいとき——だけ、安全装置つきの強制 push を使います。
git push --force-with-lease # 自分が見た時点から動いていなければ上書きする
--force-with-lease は、「自分が最後に確認した状態からリモートが進んでいたら中止する」という安全装置つきの強制 push です。むき出しの git push --force と違い、他人が先に上げた変更をうっかり消す事故を防げます。それでも、使うのは自分しか触っていないブランチに限るのが鉄則です。
AI に頼むなら
この shared ブランチは自分しか使っていない。さっき直前のコミットを修正したので、リモートが自分の見た時点から動いていなければ上書きする、という安全確認つきの強制 push でリモートを更新して
この一言で、AI はむき出しの git push --force ではなく git push --force-with-lease を選びます。「自分が見た時点から動いていなければ」という安全弁の条件を言葉にするのが、--force と --force-with-lease を分けるこつです。
まとめ方を選ぶ:squash と「ブランチを消す・消さない」
最後は、チームで合流するときの「main の歴史に何を残すか」という選択です。feature ブランチで「wip」「typo 修正」「やっと動いた」みたいな雑なコミットを 3 つ積んだとします。これを main に取り込むとき、残し方が 3 通りあります。同じ「A → B → C」でも、取り込み方で main に残る形がこれだけ変わります。
flowchart TD h1["① Merge commit"] --> a1["A"] --> b1["B"] --> c1["C"] --> m1["M<br/>合流点も全部残る"] h2["② Squash merge"] --> s2["S<br/>1コミットだけ残す"] h3["③ Rebase merge"] --> a3["A'"] --> b3["B'"] --> c3["C'<br/>合流点なしの一直線"]
squash(押し潰す、の意) は、複数のコミットを 1 個にまとめる方式です。wip が 20 個あっても、main には「ログイン機能を追加」という意味のある 1 コミットだけを残せます。git log が機能単位で読め、取り消しも 1 コミットの revert で済みます。代償は、個々のコミットの粒度が消えることです。
ここで、よく聞かれる困りごとが説明できます。「マージしたのに『まだマージされていない』と言われてブランチが消せない」。
本当にそんなことが起きるのか、と思うかもしれません。けれど、普通のマージ (マージコミットを作る取り込み) では起きません。起きるのは、GitHub の「Squash and merge」や「Rebase and merge」で取り込んだときだけです。多くのチームがこの取り込み方を既定にしているので、出会う人は珍しくありません。
理由は、第3回で見た「ブランチは 40 文字のしおりにすぎない」に戻ると分かります。安全削除の git branch -d は、「このブランチの先端は、今の歴史の祖先になっているか (=マージ済みか)」をハッシュの祖先関係で判定します。
ところが squash や rebase は、コミットを作り直して別ハッシュにします。元のコミットのハッシュは main の祖先のどこにも存在しません。だから Git は「まだマージされていない」と判定し、-d を拒否します。中身は取り込み済みなのに、です。
百聞は一見にしかず、git-lab でその瞬間をそのまま再現できます。
# feature を切って2コミット git switch -c feat-x echo a > a.txt && git add -A && git commit -m "a" echo b > b.txt && git add -A && git commit -m "b" # main に squash で取り込む(GitHub の「Squash and merge」と同じこと) git switch main git merge --squash feat-x git commit -m "feat-x をまとめて取り込み" ls # → a.txt も b.txt も入っている(中身は取り込み済み) git branch -d feat-x # → error: the branch 'feat-x' is not fully merged git branch -D feat-x # → 強制削除なら消せる(Deleted branch feat-x)
中身は main に入っているのに、-d は「まだマージされていない」と止めます。これも「作り直すとハッシュが変わる」という 2 つめのレンズの、そのままの帰結です。捨てると決めているなら、強制削除の git branch -D(大文字) で消せます。なお、リモートで消えたブランチに対応する手元の古い目印は、git fetch --prune で掃除できます。
では、ブランチを消すとき -d と -D のどちらを使えばいいのでしょうか。消す場面は大きく 3 つに分かれます。-d(小文字) は「本当にマージ済みか」を確かめてから消す安全装置だと捉えると、使い分けは単純になります。
- 普通にマージして用済み →
-dで安全に消えます。 - AI に作らせたが不採用で、マージせず丸ごと捨てる →まだマージしていないので
-dは「マージされていない」と止めます (実際そうなので正しい挙動です)。捨てると決めているなら、強制削除の-Dを使います。 - squash や rebase でマージ済み →中身は入っているのに別ハッシュで未マージ判定→これも
-D。
要するに、-d は安全装置で、自分で「捨てる」と決めているときは -D です。AI に新機能を試させて、気に入らなければブランチごと捨てる。この使い方なら、迷わず git branch -D <ブランチ名> で大丈夫です。
AI に頼むなら
ばらばらのコミットを 1 つにまとめて取り込むなら、こう言います。
feat-x でやった作業を、ばらばらのコミットのままではなく、1 つのコミットにまとめて main に取り込んで
この一言で git merge --squash(squash マージ) が走り、main には個々の wip ではなく、まとめた 1 コミットだけが乗ります。
そうして未マージ判定で残ったブランチを捨てるなら、こう続けます。
feat-x はマージしていないけど、もう要らない。確認は要らないので、強制的に削除して
「確認は要らない」「強制的に」を添えると、AI は安全削除の -d ではなく git branch -D を選びます。中身は取り込み済みでも -d が「マージされていない」と止める場面で、捨てると決めているなら、この一言で迷いません。
「消す・消さない」はプロジェクトの方針
ここまではコマンドの話でした。けれど一歩引くと、「マージしたブランチを消すか、残すか」自体も、プロジェクトやチームによって考え方が分かれます。どちらが正しいというより、方針の違いです。
消す側は、ブランチを「使い捨ての作業場所」と捉えます。普通にマージすれば、feature のコミットはすでに main の歴史に取り込まれています。だから、ブランチ (40 文字のしおり) を消しても、データは 1 バイトも失われません。消えるのは、ごく小さなしおりだけです。
ブランチ一覧は「今動いている作業」だけを映していてほしい。そう考えるチームは、マージ後に出る「Delete branch」ボタンを押す、あるいはリポジトリの自動削除設定 (「Automatically delete head branches」) を有効にして、終わったブランチをすぐ片付ける運用を選びます。個人開発で AI に試させて、ダメなら捨てる、という回し方もこちら側です。
残す側には、いくつかの事情があります。
- 道具の安全側の設計: エディタ (VS Code など) は、利用者が自分で消すまでブランチを勝手には消しません。参照を勝手に壊さない、という保守的な思想です。
- そもそも自動では消えない: GitHub では、マージしてもリモートのブランチは勝手には消えません。マージ後に出る「Delete branch」ボタンを押すか、リポジトリの自動削除設定を有効にしたときだけ消えます。しかも、たとえリモートを消しても、手元 (ローカル) のブランチと、リモートを指す目印 (
origin/feature) は自分で消すまで残ります。「消えていないブランチ」の正体は、多くがこの消し忘れです。 - squash や rebase での
-d拒否: 前述のとおり別ハッシュで未マージ判定になり、面倒で放置されて溜まります。
方針の違いをまとめると、次のようになります。
| 方針 | 背後の考え方 | ひとことで |
|---|---|---|
| ブランチを消す | ブランチは使い捨ての作業場所 | 「終わったしおりは捨てる」 |
| ブランチを残す | 道具の安全側+手元/リモートの掃除漏れ+squash で -d 拒否 | 「消していないのではなく、消し損ねている」 |
これは、マージの 3 スタイルで見た「歴史は事実の記録か、読者のための編集物か」と同じ対立が、ブランチの扱いに表れたものです。小さな変更を素早く回すプロジェクトなら「squash + 即削除」、大きな機能や監査を重視するプロジェクトなら「マージコミットを残して、ブランチ単位を後から追えるように」へ寄りがちです。自分やチームがどちらの価値観で進めたいかを先に決めておくと、ブランチが残っていてもうろたえずに済みます。
AI に頼むときの言い方
巻き戻すと作り直すが分かると、AI への頼み方が一段具体的になります。コマンドは AI に打たせるとしても、何をどこまで動かしてほしいかを言葉にできると、結果がまっすぐ返ってきます。曖昧な一言で試行錯誤させるより、ひと言の的確な指示です。
| ✗曖昧な頼み方 | ◯的確な頼み方 | 裏で動く Git 操作 |
|---|---|---|
| 「なんかコミットがぐちゃぐちゃ、直して」 | 「main に出すときは wip のコミットを 1 つにまとめて」 | squash (git merge --squash / squash マージ) |
| 「さっきのやつ取り消して」 | 「直前のコミットは取り消して、変更内容はステージングに残して」 | git reset --soft HEAD~1 |
| 「消えた、どうしよう」 | 「reflog で 1 つ前の HEAD に戻して」 | git reflog → git reset --hard HEAD@{n} |
| 「この実験ブランチ、もう要らない」 | 「マージしてない実験ブランチだから、強制削除で捨てて」 | git branch -D <ブランチ名> |
どれも、戻す範囲 (どの木まで) や、作り直すのか打ち消すのかを言葉にしているだけです。この語彙があると、AI は迷いません。
まとめ
第 5 回として、怖がられがちな 4 つの操作を 2 つのレンズで読み解きました。
- 巻き戻す=reset は「鎖のどこまで戻すか」。
--soft/--mixed/--hardの差は戻す木の範囲で、--hardだけが作業を消す。 - 戻せる=reset –hard で消しても reflog に HEAD の移動履歴が残る。やらかしたら、まず reflog。
- 作り直す=rebase はコミットを作り直すのでハッシュが変わる。だから共有済みの歴史は rebase しない。
- まとめる=squash は別ハッシュの 1 コミットを作る。だから元ブランチは
-dで消せず-Dが要る。ブランチが溜まる理由はここ。
巻き戻す・作り直すは、どれも鎖のどこかを動かしているだけで、しかも reflog という安全網があります。仕組みが見えれば、もう身構えなくて済みます。
次回は、ここまで「手元」と「個人」で扱ってきた Git を、チームに広げます。いきなり main を変えずに人の目を通すプルリクエスト、歴史に入れてはいけないものを宣言する .gitignore、そしてチーム共通の進め方 (GitHub flow)。第4回で「第6回で扱う」と送りにした、協働の作法です。
パイソンエンジニア部

