Git で差分ファイルを抽出する時にパスにスペースがあるとエラーになる

Visual Studio の My Project フォルダーの中にあるファイルを抽出しようとしてエラーになりましたので、調べてみました。

問題

Visual Studio で開発をしていまして、まだ Subversion を使っていたのですが、Visual Studio が Git をサポートしてからしばらく経過していることもあり、ようやく最近 Git に移行してみました。

コミットと同期だけするぶんには Visual Studio のチームエクスプローラーからステージしてコミットしたり(一部のファイルだけコミットするステージを覚えました)、同期したりしています。

それから、Subversion を使っていた時によく差分ファイルを抽出していて、同じことができないかと調べていたら、丁寧なブログの記事がありまして、毎回ここからコピペしています。

$ git archive HEAD `git diff --name-only HEAD~1 HEAD --diff-filter=ACMR` -o archive.zip

Gitで差分ファイルを抽出+zipファイル化する方法 | 株式会社グランフェアズ

ですが、My Project フォルダーのところで fatal: pathspec 'My' did not match any files のエラーになりました。 My Project のスペースで分割されて 2 つのファイルと認識されてしまっているようです。

これに関して、同じような問題を持った人もいるようです。

が、あまり解決したように見えないです。 対処として、スペースを含むファイルを抽出しないようにしているようですが、それはちょっと採用したくないです。

パスをダブルクォーテーションで囲んでもエラーになります。

ちなみにダブルクォーテーションで囲む方法も試したのですが、ダメでした。

$ git archive --format=zip --prefix=root/ HEAD `git diff --name-only d0b642e 17945a1 | sed 's/\(^.*$\)/"\1"/g'` -o archive.zip
fatal: path not found: "404/index.html"

Gitの差分ファイル抽出時にスペースを含んだファイル名でエラー - Qiita

スペースをバックスラッシュでエスケープしてもエラーになります。

スペースの前にバックスラッシュが必要なのか?
というわけでsedしてみる

$ git diff --name-only HEAD..HEAD~ | sed -e "s/ /\\\\ /g"
My\ Project/hoge

いいんじゃないかな…

$ git archive --format=zip master `git diff --name-only HEAD..HEAD~ | sed -e "s/ /\\\\ /g"` -o diff.zip
fatal: pathspec 'My' did not match any files

何故じゃ!!
分かる人がいれば教えてください。

パスに空白を含むファイルをgit archiveする - foohogehoge's blog

ただ、ファイルのパスをとる時にバッククォートを使わないで、直接ファイルのパスを指定する場合はスペースがあってもダブルクォーテーショーンで囲めば抽出できるようです。

$ git archive HEAD "Project/My Project/file.ext" -o archive.zip

スペースをバックスラッシュでエスケープすることもできるようです。

$ git archive HEAD Project/My\ Project/file.ext -o archive.zip

バッククォートが怪しそうです。。

対処

調べていたら、こちらがヒントになりました。

git diff --name-status commit1 commit2 | awk '{ if ($1 != "D") print $2 }' | xargs git archive -o output.zip HEAD

Git. How to create archive with files, that have been changed? - Stack Overflow

抽出対象のファイルのパスをバッククォートで入れ子のコマンドから取得しようとしているのがエラーになる原因のようでしたので、パイプを使って抽出対象のファイルのパスを git archive コマンドに渡せば良いんじゃないかということです。

前半の git diff --name-status commit1 commit2 | awk '{ if ($1 != "D") print $2 }' の部分は git diff --name-only commit1 commit2 --diff-filter=ACMR とやりたいことは同じだと解釈しました。 削除したファイル以外の差分のファイルのパスだけをとってきたいのだと思います。 git diff だけですむ後者の方が awk を使うより良いかなと思います。

次のパイプからは xargs がポイントです。

xargs はしばしばたくさんのシェルのバッククォート機能と同じ機能を持っている。しかし、より柔軟で、入力に空白や特殊文字を含む場合にはしばしばより安全でもある。find、locate や grep のような長いリストを出力するコマンドとともによく使われる。

xargs - Wikipedia

xargs はバッククォート機能と同じ機能を持っているようです。

xargsは、改行等で区切られた標準入力を読み込み、空白で区切られた1行の文字列へ加工し、それを引数として指定したコマンドへ渡して実行させる。ただし、この1行の文字列がシステムで許容される長さを超える場合は、xargsはその文字列を許容の長さになるよう最少の複数に分割し、コマンドを複数回に分けて実行させる。これにより、コマンドが許容の長さを超える引数のリストを受け付けない問題[1]を回避できる。

xargs - Wikipedia

git archive コマンドをファイルの数だけ繰り返し実行することになり、非効率的なのかな?とも思いましたが、そうではなく、xargs は “その文字列を許容の長さになるよう最少の複数に分割し、コマンドを複数回に分けて実行させる” ようなので、git archive コマンドを 1 回で実行できるだけのファイル数であれば 1 回で実行してくれるようでした。

結果

調べた結果、このコマンドになりました。

$ git diff --name-only HEAD~1 HEAD --diff-filter=ACMR | sed -e 's/ /\\\\ /g' | xargs git archive HEAD -o output.zip

まず、git diff で差分ファイルのパスを取得して、sed でスペースをバックスラッシュでエスケープして、git archive で抽出しています。

終わり

パスにスペースが含まれることは、まあ、少ないですよね。 自分でファイルを作成する場合はスペースを含めないと思いますし。 Visual Studio で作成したファイルが My Project フォルダーの下にできたので、エラーになっただけのことですから。。