2020年6月18日木曜日

[GIT] 「ファイルのタイムスタンプをコミット日時に合わせる」を爆速にした

svn には use-commit-times がある。
git だとないが git ls-files と git log の組み合わせで対応できる。
そしてそのスクリプトも git-set-file-times としてある。
ここまでは既出な情報である。


私も過去の記事を見て知った口だ。

さて、ここで今この記事を読んでいる読者は、私と同じように何らかの理由があってタイムスタンプを変更したい方が多いのではないか、と思います。
私がタイムスタンプを変更したい理由は、とあるビルドシステムがファイルのタイムスタンプを使って差分ビルドの「ビルドする・しないを決める」、かつ、同じワークスペースでブランチを行き来するため pull したときのタイムスタンプが、そのブランチにおけるファイルの変更と一致しないため、差分ビルドしたときに入ってほしいものが入ってない、入ってほしくなものが入ってしまった。という事象の解決のためでした。

なんか、パイプラインの問題でもある気がするけど、
git の変更履歴とファイルのタイムスタンプが時系列的にあえば、ビルドシステム的に解決。
しかもその方法はすでにあってコマンド1つで実現するのであれば、利用しちゃいますよね。
で、試したところ・・・

時間がかかりすぎてハンパない!!

個人開発のリポジトリで試したときは処理時間が気にはならなかったのですが、
実際それがほしいリポジトリは、超巨大・非テキスト・長履歴で、全部のファイルのタイムスタンプを設定するのに 15 分強 (1,000s くらい)かかってました。。

結論

紆余曲折があるのだが、先に結論だけ書く。
冒頭の Qiita で紹介されていた git-set-file-times に 1 つだけオプションを追加することで、10 倍速、約1分半(100s くらい)で処理できるようになりました!
修正した git-set-file-times はこちら。

#!/usr/bin/perl
use strict;
use warnings;
# sets mtime and atime of files to the latest commit time in git
#
# This is useful for serving static content (managed by git)
# from a cluster of identically configured HTTP servers. HTTP
# clients and content delivery networks can get consistent
# Last-Modified headers no matter which HTTP server in the
# cluster they hit. This should improve caching behavior.
#
# This does not take into account merges, but if you're updating
# every machine in the cluster from the same commit (A) to the
# same commit (B), the mtimes will be _consistent_ across all
# machines if not necessarily accurate.
#
# THIS IS NOT INTENDED TO OPTIMIZE BUILD SYSTEMS SUCH AS 'make'
# YOU HAVE BEEN WARNED!
# e.g.
# * git set-file-times
# * git set-file-times --since 2020/06/17
my %ls = ();
my $commit_time;
my $prefix = @ARGV && $ARGV[0] =~ s/^--prefix=// ? shift : '';
if ($ENV{GIT_DIR}) {
chdir($ENV{GIT_DIR}) or die $!;
chdir("../") or die $!;
}
$/ = "\0";
open FH, 'git ls-files -z|' or die $!;
while (<FH>) {
chomp;
$ls{$_} = $_;
}
close FH;
$/ = "\n";
open FH, "git -c diff.renames=false log -m -r --name-only --no-color --pretty=raw -z @ARGV |" or die $!;
while (<FH>) {
chomp;
if (/^committer .*? (\d+) (?:[\-\+]\d+)$/) {
$commit_time = $1;
} elsif (s/\0\0commit [a-f0-9]{40}( \(from [a-f0-9]{40}\))?$// or s/\0$//) {
my @files = delete @ls{split(/\0/, $_)};
@files = grep { defined $_ } @files;
next unless @files;
map { s/^/$prefix/ } @files;
utime $commit_time, $commit_time, @files;
}
last unless %ls;
}
close FH;
my $c = scalar keys %ls;
if ($c > 0) {
print $c, ": Warning: The final commit log for the file was not found.\n";
utime $commit_time, $commit_time, keys %ls;
}
追加したオプションは -c diff.renames=false です。
ファイルが更新されたことさえ知れればよく、Add なのか Move なのかは重要ではないので、 false にしても問題ありません。
git の diff は diff.renameLimit の一致度?(デフォルト 50%)で rename 判断をしてるみたいなので、おそらくファイルごとに一致度を調べてたから遅かったと思われます。

あと、↑の git-set-file-times では見つからなかったファイルに対して、git log の最後のコミットのタイムスタンプで更新するようも修正してます。

これは git-set-file-times --since 2020/06/17 のように log コマンドのオプションを設定できるためです。(この場合 2020/6/17 以降の変更はコミットのタイムスタンプ、2020/06/17 以降に変更がなかったファイルは全部同じタイムスタンプが設定されます。)

紆余曲折
コミットを辿るんじゃなくファイルリストごとに git log すればいいだけでは?
まずはじめに考えたのは、git ls-files で取得したファイルリストから、分散してファイル1つ1つ最終コミットを取得してタイムスタンプを更新するものでした。

クソ遅い!

git の仕組みを少し理解したので今ならわかりますが、この方法はクソ遅いです。
なんとなくファイル単位で git log したときのオーダーは O(1) なイメージで、すぐ取ってこれると思っていたのですが、そんなことはなく、実は commit を辿って辿って目当てのファイルでフィルタリングしてるだけみたいです。

なので、git-set-file-times のやり方で問題ないです。
(ファイルリストが空になるまで log を辿る。)

git コマンドじゃなくて git そのものを扱えばいいじゃない
git log からコミットのタイムスタンプとファイルリストを取得しているが、git そのものを扱えば速いんじゃない?と思い↓を作成しました。

libgit2/git2go を使って Go でコマンドを作成してます。

で、これも遅かった。
どれくらい遅かったかというと、git-set-file-times の 10倍遅かった

Go 書くの初めてだったので、お前のコードが悪い or libgit2/git2go の使い方が悪いのかもしれない。
ただ、Go 側で diff のファイルを foreach するよりも、ToBuf で文字列化したものを受け取って Split したほうが若干速かったので、C の呼び出しは減らしたほうがいいのかな?と思っている。

悔しいので Go で git log コマンドコールしてパースするバージョンも書いてみたけど、速度は対して変わらず。
まぁ git-set-file-times でいいわ、--since とかできるし。って結論になった。

また時間があれば C++ で再実装したり、別の言語のバインディングを使ってみたいと思う。

完璧ではないがリミットを設けたらどうだろうか?
first commit 以来、一切変更されてないファイルがあるとログを全部辿ることになるのでそりゃ時間かかるよな。そんな極端でなくてもあまり更新されないファイルって存在すると思うので、結構な数のログを辿らないと終わらないケースは多々ありそう。

と、思いながら git-set-file-times 眺めてたら、git log コマンドで引数を受け付けてたので、ログ数制限できるなーと思ったのでやってみた。

git-set-file-times --since 2020/06/01

って感じ。

これは、思ったとおりすぐ終わった!

じゃ、あとは残ったファイルには一律同じタイムスタンプつけとけば ok だなってことで、現在の git-set-file-times のようになりました。

まぁ完璧ではないけど、求められているものにはなったんじゃない??
いやーめでたしめでたし。

だったんですが、ふと気づいたのです。
なんか警告出てるな、と。

diff.renameLimit の警告が我を救った
warning: only found copies from modified paths due to too many files.
warning: you may want to set your diff.renamelimit variable to at least XXXX and retry the command
たまたまこんな警告が出てたんで、警告はでないようにしたいなと思ったのがきっかけ。
まず、diff.renameLimit=0 にしてみたんですが、おそろしく処理時間がかかるようになりました。 15分どころか、数時間待っても終わらず kill しました。。

ここでようやく気づきます。
Rename かどうかは重要ではない

ここからゴールはもうすぐです。
rename かどうかの追跡は git config の diff.renames で設定できることを調べて知ります。
config を一時的に変えてコマンド実行したいなと思い調べると、サブコマンドの前のオプションで -c 使えばいいことがわかります。

つまり git -c diff.rename=false log こうです。
そして、完成!!

試しに実行したときにすぐに完了したので感動しましたね。

最後に

git 奥深い。。。

0 件のコメント:

コメントを投稿