Rust+git2で特定のパスをステージングするときに引っかかったポイント
わたすけです。今年から月報を書くことにしました。2025年の振り返りが意外と長くなって、それでも書き漏らした点が沢山あるような気がしてしまったからです。Atomフィードも作りました。
今までも日報(日記)・週報(1週間の振り返り)をプライベートでやっていたのですが、さらに粒度の荒い月報は年報(1年間の振り返り)と同様に公開しても良いかなと思ったので、公開することにしました。粒度が細かくなればなるほど個人的な感情が多く含まれるようになって公開しづらいんですよね。まあ正直言って週報も別に公開して構わないのですが、週単位だと数が多すぎて面倒な気もするのでひとまず現状維持とします。とりあえず、2026年1月の月報をよろしくおねがいします:
月報:2026/01 - わたすけのへやhttps://watasuke.net/blog/monthly/2026/01/論文再実装、作曲、COJT成果発表会。見たアニメの話。
さて、これを実現するために、もちろん自作CMSにも手を加える必要がありました。この際なので、以前からあったバグである「変更された記事をGitでステージングする際、指定した記事に対応するディレクトリ以外の変更もステージングされてしまう」という問題も修正しました。大した問題ではなかったのですが、いくつかの要因が組み合わさって起こったバグだったことがわかってちょっと面白かったため、なぜこの問題が発生してしまったのかを書き記しておこうと思います。
問題のコード
修正前のコードは以下の通りです:
pub fn stage(&self, path: &Path) -> anyhow::Result<&Self> {
ensure!(self.repo.head()?.is_branch(), "Reference is not a branch");
ensure!(self.is_clean(), "Repository is dirty");
let mut index = self.repo.index()?;
let repo_path = self.repo.path().parent().unwrap();
index.add_all(
std::path::absolute(path)?.strip_prefix(repo_path),
IndexAddOption::CHECK_PATHSPEC,
None,
)?;
index.write()?;
Ok(self)
}
self は git2::Repository を持っています。stage()関数は、self から indexとリポジトリへのpathを取得して、関数に与えられたパスを絶対パスに変換したうえで、strip_prefix()を用いてリポジトリのpathを削除すると、リポジトリのルートからみて相対的なパスが取り出せるので、そのディレクトリをステージングする、という処理を行います。
さて、この stage() に引数として与えられる path は、config.tomlの content_path フィールドで指定されるパスに、ステージング対象を連結させたものとなっています1。この記事を例にすると、2026年に初めて(0番目に)公開される記事で、slugは rust-git2-add-specific-directory となっているため、"../../contents_directory/articles/2026/00_contents_directory" というPathが与えられるでしょう。一方、 git2::Repository の path() は絶対パスを返します。
まずここで第1の罠があります。個人的に std::path::absolute() は .. のような相対パスも解決して絶対パスを指してくれると思っていたのですが、そんなことはありませんでした。つまり、std::path::absolute("../../contents_directory") は、 /tmp/contents_directory とはならず、 /tmp/watasuke.net/cms/../../contents_directory のような形になってしまうわけです。
このようなPathに対して .strip_prefix("/tmp/contents_directory") を呼ぶとどうなるでしょうか?まあ当たり前ですが失敗しますよね。.strip_prefix() の戻り値はResult型であるため、Err() が返ってくるでしょう。ここで先ほどのコードを改めてみてください。strip_prefix() に ? を書き忘れています。Err() どころかOk()すらResultとして add_all() 関数に渡されてしまうわけです。
では、この add_all() の第1引数は何を受け取るのでしょうか?ドキュメントから引用します:
pub fn add_all<T, I>(
&mut self,
pathspecs: I,
flag: IndexAddOption,
cb: Option<&mut IndexMatchedPath<'_>>,
) -> Result<(), Error>
where
T: IntoCString,
I: IntoIterator<Item = T>,
第1引数は pathspecs です。型は I で、それは IntoIterator を実装しているものです。はい、std::result::ResultはIntoIteratorを実装しています。知らなかった……。Errの場合は.next()が即座にNoneを返すようなイテレータとなるようです。
ところで、Rustのgit2クレートは、Cのライブラリであるlibgit2のラッパーとなっています。Index::add_all() に対応するのは git_index_add_all() なのですが、これのパスとして空の配列を渡すと、すべてのエントリが対象となるようです。
libgit2のソースコードを踏まえた説明
Index::add_all() では、IntoIterator な型である pathspecs を受け取り、 crate::util::iter2cstrs_paths(pathspecs) という形で raw_strarray を作成し、これを raw::git_index_add_all() の第2引数として渡して呼び出します。というわけで、libgit2の git_index_add_all() を見てみます。
git_index_add_all() の第2引数は git_strarray *paths です。これは index_apply_to_wd_diff(index, INDEX_ACTION_ADDALL, paths, ...); という形で使われています。
index では、受け取った paths を git_pathspec__init() に渡すことで git_pathspec ps に変換します。次に、 struct foreach_diff_data data に data.pathspec = &ps という形で pathspec を入れて、 git_diff_foreach(diff, apply_each_file, NULL, NULL, NULL, &data) と呼び出します。
git_diff_foreach() は、リポジトリにある各diffに対して何らかの処理を行うというものでしょう。apply_each_file が渡されている第2引数は git_diff_file_cb で、ファイルに対する変更に対して呼ばれるコールバックとなっていることが伺えます。他にも git_diff_binary_cb などを受け取りますが、それらはNULLとなっています。
apply_each_file() では、まず第3引数の void *payload を struct foreach_diff_data *data に格納します。git_diff_foreach() の最後の引数として渡したものと同じでしょう。そして、data->pathspec->pathspec を第1引数として git_pathspec__match() を呼び出してdiffが(git_index_add_all() に渡された)pathspecとマッチするかどうか確認し、マッチしなければreturnする、という処理をしています。
さて、git_pathspec__match() が最も重要なポイントです。第1引数として渡された data->pathspec->pathspec は git_vector *vspec として受け取られます。この関数の冒頭あたりで、 if (!vspec || !vspec->length) return true; という処理があります。つまり、vspec がNULLであったり、vspec の要素数が0であったりするならば、マッチしたものとして扱うという処理をしているわけです。
そういうわけなので、Index::add_all()、あるいは git_index_add_all() に空配列(に相当するもの)を渡すと、全てのdiffがpathspecにマッチするとみなされてステージングされる、という感じらしいです。
というわけで、
std::path::absolute()は..のような相対指定を解決しない- そのせいで
strip_prefix()がErr()を返していたが、このエラーハンドリングを忘れていた - git2の
Index::add_all()はIntoIteratorな型であれば何でも受け入れるため、Resultを渡しても型エラーが起こらなかった Err()はinto_iter()で None となるため、libgit2 のgit_index_add_all()における pathspec に空配列を渡すことになってしまい、これはすべての変更をステージングの対象にするよう指定するのと同じ意味を持つ
といった要因が重なり、指定していないパスもステージング対象になってしまっていたわけでした。
修正
このコミットで以下のように修正しました:
index.add_all(
- std::path::absolute(path)?.strip_prefix(repo_path),
+ [path.canonicalize()?.strip_prefix(repo_path)?].into_iter(),
IndexAddOption::CHECK_PATHSPEC,
None,
)?;
canonicalize() で .. が解決されます。あと std::path::Path も IntoIterator を実装しており、例えば Path("a/b/c").into_iter() は ["a", "b", "c"] のようになってしまうため、ちゃんと配列に包んで渡してやる必要があります(1敗)。
余談
余談1ですが、git2のリポジトリにはちゃんと examples/ ディレクトリがあって、add.rs を見ると、index.add_all(args.arg_spec.iter(), ...) と、イテレータを渡している事がわかります。ちゃんと確認しておくべきだったかもしれません。初めにこのgit2を利用するコードを書いたときは、ドキュメントだけだと使い方がいまいちわからず、libgit2のサンプルコードを読みながら、Rust側で該当しそうなAPIを探して翻訳する、というような形で書いていたのを思い出しました。examples/ いつもありがとう!新しくライブラリを使いたくなったときはまず確認すべきだと学びました。
余談2です。このバグ修正と併せて、以前から抱えていたタスクである「バックエンドで利用しているフレームワークの乗り換え」も決行しました。当サイトのバックエンドはRustで作られており、フレームワークとしてRocketを用いていました。しかし、Rocketの最新バージョンであるv0.5.1のリリースは2024-05-24です。コミットはちょくちょく行われているらしいのですが、ここまでリリースが停滞していると流石に先行きが不安になってしまいます。
そういうわけで、Rocketにそこまで不満があったわけでもないのですが、Axumに乗り換えることにしました。そもそもRocketを選んだ理由は、まずバックエンドでGraphQLを使いたいというモチベーションがあり2、Rustでそれを可能にするJuniperがサポートしているフレームワークのそれぞれについてexampleを読んで、Rocketのそれが最も記述が短かったから、というものでした。また、ルーティングを derive macro で書けて、ハンドラの定義とルーティングの記述が近くて嬉しいから、という理由もあったと記憶しています。
今回Axumを選んだのは、書籍「RustによるWebアプリケーション開発」で知られるyukiさんの記事「Rustのバックエンド開発の最近の動向を追う」で、「axumは実質Rustの代表的なバックエンド開発用のクレートとみなされているように見受けられます」と言及されていたからです。
使ってみた感想としては、意外と簡潔に処理を書くことができて嬉しいです。tokioチームによるフレームワークなだけあって、tokioのポテンシャルをちゃんと活用したコードを書いているような気がします(以前のコードがasyncを使ってなさすぎたという面はありそう)。しかし、ログ出力は圧倒的にRocketのほうが読みやすかったなあと思ってしまいました。
Footnotes
-
今思ったけど変なことせずにリポジトリのルートから見た相対パスを与えたら良いのでは……?いつかやります ↩
-
GraphQLを使いたかった理由は、Gatsbyの利用経験で親しみがあったこと、過去の開発で用いたGraphQLによるスキーマ駆動開発の経験が良かったことが理由です。Juniperはスキーマ駆動ではなくコードファースト(バックエンドのコードからスキーマを生成する)というアプローチで、使う前はちょっと微妙だと思っていたのですが、実際に使ってみるとバックエンドのコード生成が不要である等の点でこちらのほうが嬉しいと感じています。スキーマ駆動開発をやりたいモチベーションとして最も大きいものは「フロントとバックでAPIの乖離が起きないようにしたいから」というものであり、バックエンドのコードからスキーマが自動生成され、それを用いてフロント側のコードを自動生成する、というアプローチは個人的なニーズとしては十分でした。 ↩
Comments
Powered by Giscus