リモートGitリポジトリの特定リビジョンのファイル一覧を得る方法

# はじめに

これは「Git リポジトリのあるリビジョンのファイル全部を取得する方法があるんだから、ファイル一覧だけを効率よく取得する方法もどこかにあるのでは？」と思った人間が「結局通常のケースでは ssh して bare リポジトリを直接見るか、ローカルに shallow clone するのが最適解じゃねーか！」と気づくまでの記録です。

# 結論 (TL;DR)

* ファイル一覧を得たいユーザーがそもそもリモートホストに ssh ログインできる場合
    * `ssh <user>@<host> git --git-dir <repo> ls-tree -r <revision>`
* ファイル一覧を得たいユーザーがリモートホストに ssh ログインできない場合（リモートが GitHub の場合など）
    * `git clone --bare --depth 1 --branch <revision> <remote-repo> && git --git-dir <repo> ls-tree -r <revision>` (shallow clone)
    * もしくは `git archive --remote <remote-repo> <revision> | tar -tf -`
* **ファイル一覧を得たいユーザーは ssh ログインできないが、リモート側・クライアント側双方で Git v2.22 以上を使っていて、リモートリポジトリに対して `git config` する権限のあるアカウントがある場合**
    * リモート側で `git config uploadpack.allowfilter true && git config uploadpack.allowanysha1inwant true` する
        * `uploadpack.allowfilter`: v2.22 より前のバージョンには `--filter=sparse:path=<path>` の脆弱性があるため注意
        * `uploadpack.allowanysha1inwant`: リモートリポジトリの任意の object をクライアント側から取得できるようにするつよいオプションなので扱いには注意
    * クライアント側で `git clone --bare --depth 1 --branch <revision> --filter blob:none <remote-repo> && git --git-dir <repo> ls-tree -r <revision>`

なお、「ssh ログインできる」とは「ssh 先のリモート bare リポジトリに対してある程度自由に操作を行える（シェルが `nologin` だったりミドルウェアによる制限がかかっていたりしない）」ことを指します。

# 下準備

今回はLAN内のリモートホスト `example.local` に [git/git - GitHub](https://github.com/git/git) を `git clone --bare` して置いてあります。

```shell-session
$ git clone --bare https://github.com/git/git  # (in git@example.local)
```

この bare リポジトリ全体のファイルサイズは 131 MB です。

```
$ du -sh git.git
131M    git.git
```

このリポジトリの `v2.23.0` タグ時点でのファイル一覧を取得したい、という状況を考えます。

# そもそもリモートホストに ssh ログインできる場合

bare リポジトリの特定リビジョンのファイル一覧を見るには **`git ls-tree`** を使います。`-r` は tree を再帰的に処理するオプションです。また、`--git-dir` オプション（または `GIT_DIR` 環境変数）を指定することで `cd` することなく操作対象の Git リポジトリを選択できます。

```
$ ssh <user>@<host> git --git-dir <repo> ls-tree -r <revision>
```

実行例は以下の通り。

```
$ time sh -c 'ssh git@example.local git --git-dir ./git.git ls-tree -r v2.23.0 | tail'
100644 blob a4285ac0eb2840fb3d128b81ab4e19ca3fa673ae    xdiff/xinclude.h
100644 blob 2809a28ca960147c285bc5a224ed377a0964663a    xdiff/xmacros.h
100644 blob 1659edb45393a6b57ca70654e67784e5d0025c65    xdiff/xmerge.c
100644 blob 3c5601b602a24cb6cd6e063466369b1a90a14f9f    xdiff/xpatience.c
100644 blob abeb8fb84e6d73086d612b831963a227e35743b8    xdiff/xprepare.c
100644 blob 947d9fc1bb8cf95719284de6563227485907988f    xdiff/xprepare.h
100644 blob 8442bd436efeab81afc25db9d89da082638fcca4    xdiff/xtypes.h
100644 blob cfa6e2220ffd0461ce3293911c86366f6ccb1b05    xdiff/xutils.c
100644 blob fba7bae03c7855ca90aff3f238321581a91a6676    xdiff/xutils.h
100644 blob d594cba3fc9d82d94b9277e886f2bee265e552f6    zlib.c

real    0m0.237s
user    0m0.024s
sys     0m0.003s
```

処理時間は 0.2 秒でした。流石にこれが一番高速ですね……。

# リモートホストに ssh ログインできない場合

## shallow clone する

自由に bare リポジトリを触る権限がない人は素直に `--depth 1` を付けて shallow clone しましょう。`--branch` で対象リビジョンを指定することができます。

```
$ git clone --bare --depth 1 --branch <revision> <remote-repo> && git --git-dir <repo> ls-tree -r <revision>
```

実行例は以下の通り。

```
$ time sh -c 'git clone --bare --depth 1 --branch v2.23.0 git@example.local:git.git && git --git-dir git.git ls-tree -r v2.23.0 | tail'
Cloning into bare repository 'git.git'...
remote: Enumerating objects: 3719, done.
remote: Counting objects: 100% (3719/3719), done.
remote: Compressing objects: 100% (3330/3330), done.
remote: Total 3719 (delta 282), reused 1889 (delta 235)
Receiving objects: 100% (3719/3719), 8.54 MiB | 5.74 MiB/s, done.
Resolving deltas: 100% (282/282), done.
100644 blob a4285ac0eb2840fb3d128b81ab4e19ca3fa673ae    xdiff/xinclude.h
100644 blob 2809a28ca960147c285bc5a224ed377a0964663a    xdiff/xmacros.h
100644 blob 1659edb45393a6b57ca70654e67784e5d0025c65    xdiff/xmerge.c
100644 blob 3c5601b602a24cb6cd6e063466369b1a90a14f9f    xdiff/xpatience.c
100644 blob abeb8fb84e6d73086d612b831963a227e35743b8    xdiff/xprepare.c
100644 blob 947d9fc1bb8cf95719284de6563227485907988f    xdiff/xprepare.h
100644 blob 8442bd436efeab81afc25db9d89da082638fcca4    xdiff/xtypes.h
100644 blob cfa6e2220ffd0461ce3293911c86366f6ccb1b05    xdiff/xutils.c
100644 blob fba7bae03c7855ca90aff3f238321581a91a6676    xdiff/xutils.h
100644 blob d594cba3fc9d82d94b9277e886f2bee265e552f6    zlib.c

real    0m2.708s
user    0m0.593s
sys     0m0.161s
```

処理時間は 2.7 秒、展開後のリポジトリのサイズは 8.8 MB でした。

```
$ du -sh git.git
8.8M    git.git
```

ちなみに `--bare` でなく `--no-checkout` オプションを使ってもだいたい同じような速度になります。こちらでは bare でない普通のリポジトリとして clone されるので時と場合に応じて使い分けましょう。

## `git archive` を使う

`git archive` の `--remote` オプションと `tar` コマンドを組み合わせることでもファイル一覧を得られます。

```
$ time sh -c 'git archive --remote git@example.local:git.git v2.23.0 | tar -tf - | tail'
xdiff/xinclude.h
xdiff/xmacros.h
xdiff/xmerge.c
xdiff/xpatience.c
xdiff/xprepare.c
xdiff/xprepare.h
xdiff/xtypes.h
xdiff/xutils.c
xdiff/xutils.h
zlib.c

real    0m3.538s
user    0m0.307s
sys     0m0.279s
```

処理時間は 3.5 秒でした。今回は shallow clone した方が少し高速だったようです。

# Git v2.22 以上を使っていてリモートリポジトリに対して `git config` する権限のあるアカウントがある場合

これは**「自分はリモートリポジトリの `git config` 権限を持っている」「ssh 権限がない人でも高速にファイル一覧を取得できるようにしたい」**というようなケースを想定しています。また、サーバー側に v2.22 以上の Git が入っている必要があります。実はもう少し古いバージョンでも可能なのですが脆弱性（後述）が存在するため注意が必要です。

あらかじめリモート側で以下の作業をしておきます。

```
$ cd git.git  # (in git@example.local)
$ git config uploadpack.allowfilter true
$ git config uploadpack.allowanysha1inwant true
```

`git.git/config` はこんな感じになります。

```conf
[core]
        repositoryformatversion = 0
        filemode = true
        bare = true
[remote "origin"]
        url = https://github.com/git/git
[uploadpack]
        allowfilter = true
        allowanysha1inwant = true
```

そして、クライアントで shallow clone する際に **`--filter=blob:none`** オプションを付けます。これにより blob （ファイルの中身）を除外して一覧のみをダウンロードすることができます。

```
$ git clone --bare --depth 1 --branch <revision> --filter blob:none <remote-repo> && git --git-dir <repo> ls-tree -r <revision>
```

実行例は以下の通り。

```
$ time sh -c 'git clone --bare --depth 1 --branch v2.23.0 --filter blob:none git@example.com:git.git && git --git-dir git.git ls-tree -r v2.23.0 | tail'
Cloning into bare repository 'git.git'...
remote: Enumerating objects: 194, done.
remote: Counting objects: 100% (194/194), done.
remote: Compressing objects: 100% (152/152), done.                                      
remote: Total 194 (delta 1), reused 132 (delta 1)
Receiving objects: 100% (194/194), 111.81 KiB | 11.18 MiB/s, done.
Resolving deltas: 100% (1/1), done.
100644 blob a4285ac0eb2840fb3d128b81ab4e19ca3fa673ae    xdiff/xinclude.h
100644 blob 2809a28ca960147c285bc5a224ed377a0964663a    xdiff/xmacros.h
100644 blob 1659edb45393a6b57ca70654e67784e5d0025c65    xdiff/xmerge.c
100644 blob 3c5601b602a24cb6cd6e063466369b1a90a14f9f    xdiff/xpatience.c
100644 blob abeb8fb84e6d73086d612b831963a227e35743b8    xdiff/xprepare.c
100644 blob 947d9fc1bb8cf95719284de6563227485907988f    xdiff/xprepare.h
100644 blob 8442bd436efeab81afc25db9d89da082638fcca4    xdiff/xtypes.h
100644 blob cfa6e2220ffd0461ce3293911c86366f6ccb1b05    xdiff/xutils.c
100644 blob fba7bae03c7855ca90aff3f238321581a91a6676    xdiff/xutils.h
100644 blob d594cba3fc9d82d94b9277e886f2bee265e552f6    zlib.c

real    0m0.515s
user    0m0.058s
sys     0m0.017s
```

**処理時間は 0.5 秒、展開後のリポジトリのサイズは 196 KB でした！** ファイルの中身を取得しない分高速に処理できています。

```
$ du -sh git.git
196K    git.git
```

なお、blob を取得していないファイルに対して `git cat-file -p` しようとするとこのタイミングで `fetch` が発生します。

```
$ git --git-dir git.git cat-file -p v2.23.0:zlib.c | head
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (1/1), 1.62 KiB | 1.62 MiB/s, done.
/*
 * zlib wrappers to make sure we don't silently miss errors
 * at init time.
 */
#include "cache.h"

static const char *zerr_to_string(int status)
{
        switch (status) {
        case Z_MEM_ERROR:
```

## `--filter` について

`--filter` に `blob:none` を指定すると「blob を一切ダウンロードしない」という挙動になりますが、「特定のサイズ以上のファイルを fetch しない (`blob:limit=<n>[kmg]`)」「特定の path 下にあるファイルのみ fetch する (`sparse:oid=<blob-ish>`)」といった使い方も可能です。これについては GitLab のドキュメントの以下のページが詳しいです。
 
* [Partial Clone for Large Repositories | GitLab](https://docs.gitlab.com/ee/topics/git/partial_clone.html)

## `uploadpack.allowfilter`, `uploadpack.allowanysha1inwant` とは

`uploadpack.allowfilter`, `uploadpack.allowanysha1inwant` はいずれも `git upload-pack` （簡単に言うと `git fetch` における通信を司るやつ）の挙動を変更するオプションで、`uploadpack.allowfilter` はその名の通り `--filter` を利用することを許可するオプション、`uploadpack.allowanysha1inwant` は任意の object (tree, blob, commit, tag) を want (fetch) できるようにするオプションです。

* [Git - git-config Documentation #uploadpack.allowFilter](https://git-scm.com/docs/git-config#Documentation/git-config.txt-uploadpackallowFilter)
* [Git - git-config Documentation #uploadpack.allowAnySHA1InWant](https://git-scm.com/docs/git-config#Documentation/git-config.txt-uploadpackallowAnySHA1InWant)

「これなんでオプトイン（デフォルトで `false`）なの？」という疑問が生じますが、おそらくセキュリティ上の都合でしょう（[gitnamespaces](https://git-scm.com/docs/gitnamespaces) や [#uploadpack.allowReachableSHA1InWant](https://git-scm.com/docs/git-config#Documentation/git-config.txt-uploadpackallowReachableSHA1InWant) の説明を参照）。「任意の object を fetch できる」というのはもちろん強力なオプションであり、object へのフルアクセスを誰からでも許可しているリポジトリのみこれらのオプションを有効にしましょう。まあそもそもセキュリティ上ヤバいファイルはリポジトリごと分離しておけと上記の公式の説明にも書いてあるのですが……。

また、Git v2.22 以前のバージョンでは `uploadpack.allowfilter` を有効にすることで `--filter=sparse:path=<path>` というフィルタを使えますが、この処理にはファイルシステムの任意のパスにアクセスできてしまうという問題があるため注意が必要です。

* [list-objects-filter: disable 'sparse:path' filters · git/git@e693237](https://github.com/git/git/commit/e693237e2ba27b6129e8af7f6a794f5c2fbd26f3)
* [Arbitrary read access via `git clone --filter=sparse:path=<path>` (#1578) · Issues · GitLab.org / gitaly · GitLab](https://gitlab.com/gitlab-org/gitaly/issues/1578)

# おわりに

というわけで、**「結局通常のケースでは ssh して bare リポジトリを直接見るか、ローカルに shallow clone するのが最適解じゃねーか！」**ということでした。なんて遠い廻り道………。

でもこれを調べている段階で Git の pack 周りの処理や知られざる Git の機能に触れることができたのでプラスにはなった、たぶん……。

# 参考

* [What is the git clone --filter option's syntax? - Stack Overflow](https://stackoverflow.com/questions/49917616/what-is-the-git-clone-filter-options-syntax)
* [git fetch の裏側では何が起こっているか - 詩と創作・思索のひろば](https://motemen.hatenablog.com/entry/2016/03/git-pack-protocol-explained#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99)