📝 この記事は forge.workstyle.tech に掲載した記事の転載です。
複数のAIセッションが同じ作業ツリーを使い、ステージ済みの変更が残っていました。変更をブランチに退避してからgit reset --hardする手順は、退避に失敗しても先へ進む場合がありました。クォートしていない空のコマンド置換で引数が消え、git branchが成功することが原因です。
使い捨てのリポジトリで気づいた
実行する前に、セッションの1つが、この手順を使い捨てのリポジトリで一度走らせました。そこで、退避先を作る部分が失敗しても、git branch自体は成功する場合があると気づきました。
短くすると、再現は次のとおりです。
$ git branch orphan/x $(false) # exit=0 HEAD にブランチができる → && を通過
$ git branch orphan/x "$(false)" # fatal: Not a valid object name: '' → 止まる(exit 128)
$(false)の結果は空です。クォートしていない場合、その空の結果は単語分割で引数そのものが消えます。コマンドに渡るのはgit branch orphan/xです。
この形は、開始点を省略したgit branchとして成立します。HEADを指すブランチができ、終了コードは0になります。だから&&は先へ進み、後ろのreset --hardが実行されます。
退避先が作られていないのに、退避が成功したかのように見える。実際の手順では、変更を退避できていないまま作業ツリーの変更が消える寸前でした。
一方、"$(false)"とクォートすると、空の引数が残ります。Gitは空の開始点を受け取り、エラーで止まります。ここでは終了コードが128なので、&&の先へ進みません。
省略できる引数が、別の意味を持つ
危ないのは、値が消えたあとも、コマンドが「引数を省略した形」として成立してしまうことです。
今回のgit branchは、開始点を省略しても成功します。見た目には同じコマンドでも、意図した退避先のコミットを使う形とは違う動作になりました。
同じ種類の注意が必要な例として、git checkout -B、git reset、rm -rf $DIR/があります。引数を省略しても別の意味で成立するコマンドに、空になるかもしれない値をそのまま埋め込むと、失敗が終了コードに現れないことがあります。
&&は、前のコマンドが失敗したときに後ろへ進まないためのものです。ただし、失敗がコマンドの終了コードに現れなければ、止まるきっかけがありません。エラーが表示されるかどうかだけでは、手順が止まるかは分かりません。
なぜ見落としたのか
この手順を読んで承認したセッションは3つありました。全員が穴を見落としました。そのうちの1つは、「&&でつないであるから、失敗したら止まる」と書いて同意していました。
どのセッションも、コマンドの形を見て「退避が失敗すれば終了コードも失敗になる」と頭の中で補っていたのだと思います。けれど、実際にはコマンド置換の結果が空になり、引数が消えた後のgit branchが成功していました。読むだけでは、その変化を確かめられませんでした。
気づけたのは、実行前に使い捨てのリポジトリで一度走らせたからです。見た目のつながりだけで判断せず、失敗する入力を与えて、コマンドがどんな引数を受け取るか確かめる必要がありました。
まとめ
- コマンド置換はクォートする
- 空の値が引数として残ることを確認する
- 退避できたことを確かめてからリセットする
- 失敗する入力を使い捨てのリポジトリで実行し、後続処理まで進まないか確かめる
関連記事
- https://forge.workstyle.tech/blog/who-is-rolling-back-whom/
- https://forge.workstyle.tech/blog/parallel-claude-code-sessions-teamwork/
- https://forge.workstyle.tech/blog/root-cause-over-band-aid-debugging/