巨大なファイルを含んだリポジトリの履歴を改変して GitHub にインポートする方法

# はじめに

GitHub.com は巨大なファイルを含んだコミットに対して Push 制限が設けられています。[Working with large files](https://help.github.com/articles/working-with-large-files)
具体的には、次のようなメッセージが表示されることがあります。

- コミットに 50MB を超えるファイルが含まれている場合は Warning が表示されます
  - warning は表示されるものの、リモートへの転送自体は成功します。（ただし非推奨）

```
remote: warning: Large files detected.
remote: warning: File big_file is 55.00 MB; this is larger than GitHub's recommended maximum file size of 50 MB
```

- コミットに 100MB を超えるファイルが含まれている場合は Error が表示されます
  - リモートへの転送に失敗します
 
```
remote: warning: Large files detected.
remote: error: File giant_file is 123.00 MB; this exceeds GitHub's file size limit of 100 MB
```

上記の問題は、以下のいずれかの対応をすることで解決できます。

1. 対象ファイルが必要な場合は、Git-LFS を利用する [Versioning large files](https://help.github.com/articles/versioning-large-files)
2. 対象ファイルが不要な場合は、リポジトリから取り除く [Removing files from a repository's history](https://help.github.com/articles/removing-files-from-a-repository-s-history)

この記事では、2. の巨大なファイルをリポジトリから取り除く手順を 2 通り紹介します。

# 具体例

プロジェクトで利用するために、外部で構成管理されているリポジトリを GitHub.com にインポートするケースについて考えてみます。

例えば Android オープンソースプロジェクト（以下 AOSP）は [android.googlesource.com](https://android.googlesource.com) で公開されていて 1,000 を超える Git リポジトリから構成されています。これらのリポジトリの中には、「巨大なファイルが含まれている、または過去に含まれていた」リポジトリがあり、GitHub.com でそのままインポートすることができません。

- 実際、見つかった巨大なファイルの多くは、通常のソースコードではなくビルド済みバイナリであったりテストデータであったりします。

次のステップでは、実在する外部管理のリポジトリを GitHub.com にインポートする処理を行います。


## インポート時に git-push が失敗することの確認

巨大なファイルが含まれていることがあらかじめ判明している AOSP の [platform/system/core.git](https://android.googlesource.com/platform/system/core.git) を題材として、GitHub.com へのインポートに失敗することを確認します。

まずは以下の手順でインポートを試みます。

```
$ git clone --mirror https://android.googlesource.com/platform/system/core
$ git remote add github $YOUR_GITHUB_URL
$ git push --mirror github

remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
remote: error: Trace: be6e0a48b32da493f6a767241ba85e9b
remote: error: See http://git.io/iEPt8g for more information.
remote: error: File libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so is 219.06 MB; this exceeds GitHub's file size limit of 100.00 MB

 ! [remote rejected] android-9.0.0_r16 -> android-9.0.0_r16 (pre-receive hook declined)
```

ここで上記のエラーが出ました。エラー内容から 200MB を超える共有ライブラリ `libartd.so` がコミットされていたため push に失敗したことがわかります。

次のステップでは、巨大なファイルの正体を特定します。

## 1. 対象ファイルの特定する

200MB を超える共有ライブラリがコミットが含まれていたため、 GitHub.com への push に失敗しました。ところが実際は、この共有ライブラリは指定のパスには既に `libartd.so` は存在しません。

```
$ test -e libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so
```

次に、既に削除済みファイルであることを確認します

```
$ git log --diff-filter=D --summary -- libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so

delete mode 100644 libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so
```

つまり、例え削除済みであっても、巨大ファイルを含んだコミットがあったことには変わりないため、push に失敗したことがわかりました。このように push に失敗する前に、リポジトリの中に含まれた巨大なファイルをあらかじめ確認するためには、Atlassian の [Reduce repository size](https://confluence.atlassian.com/bitbucket/reduce-repository-size-321848262.html) で紹介されている `git_find_big.sh` を活用すると良いことが知られています。このスクリプトでは Git 内部の [パックファイル](https://git-scm.com/book/en/v2/Git-Internals-Packfiles) を走査して、巨大なファイルのサイズ順に表示してくれます。今回扱う [platform/system/core.git](https://android.googlesource.com/platform/system/core.git) リポジトリを対象に `git_find_big.sh` を実行すると次の結果が得られます。

```
All sizes are in kB's. The pack column is the size of the object, compressed, inside the pack file.
size    pack   SHA                                       location
224312  60015  70352dbb7a51e338096bb1305ea63641a39200cd  libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so
28051   7497   e72e673ad74ff69060291ddab1a3989a7c6a8c07  libunwindstack/tests/files/offline/jit_debug_x86_32/libarttestd.so
10380   3225   92ed9915d4f9ea96a4accfcad82c657ff72f17fd  libunwindstack/tests/files/offline/jit_debug_x86/libartd.so
8127    3048   ef1a6a1857bc2613a9ae162dd56f2cb67858eb3a  libbacktrace/testdata/arm64/libskia.so
5968    2422   05278936be5fda0f62b5bd37b6951bcdbb5e8cd2  libunwindstack/tests/files/offline/jit_debug_arm/libart.so
5952    2401   09ba49532205981ff638c6935436cbb09d678aba  libunwindstack/tests/files/offline/art_quick_osr_stub_arm/libart.so
5400    2175   bed8e3595aa8cab7f381cfcb7aa05d10f010e687  libbacktrace/testdata/arm/libart.so
5294    2610   855905655e4c822794b6d66657c657b5307f17c9  libunwindstack/tests/files/offline/jit_debug_arm/libartd.so
5076    1392   092fc3aec90445662e23c2fdca8207e031602793  libunwindstack/tests/files/offline/straddle_arm64/libunwindstack_test
4134    1369   871f6dc89e9fb88b8758f5ef2deb2224295f8411  libbacktrace/testdata/arm/libGLESv2_adreno.so
```

上記の結果から、 50MB を超える巨大なファイルは `libartd.so` だけであることが明らかになりました。

したがって、
1. 過去に管理されていた巨大なファイルが参照できなくても問題ないこと
2. 対象コミット以降のすべてのコミットハッシュ（SHA-1）が変わっても問題ないこと

上記のポイントを理解したうえで、それでも問題がなければ、次のステップで、対象コミットを削除するためにコミット履歴を書き換えます。

## 2. コミット履歴の書き換え

### 2a. コミット履歴の書き換え: `git-filter-branch` を用いた場合

[git-filter-branch](https://git-scm.com/docs/git-filter-branch) コマンドを用いることで、コミット履歴を書き換えることができます。
すべてのブランチ・タグから対象の巨大なファイルを rm する場合は、次のコマンドで実現できます

```
$ time git filter-branch --index-filter 'git rm --ignore-unmatch libunwindstack/tests/files/offline/jit_debug_x86_32/libartd.so' --tag-name-filter 'cat' -- --all
```

オプションの説明:

- `--index-filter <command>`
  - 各コミットハッシュに対して `<command>` を実行します
- `--tag-name-filter <command>`
  - コミット履歴の書き換えに際し、新しいコミットハッシュに対してタグを上書きするオプションです
  - `<command>` の規則にしたがってタグ名を上書きします
    - 元のタグ名のままタグを上書きする場合  
		`--tag-name-filter 'cat'`
    - すべて大文字でタグを上書きする場合  
		`--tag-name-filter 'cat | tr [:lower:] [:upper:]'`
  - タグを上書きしても元のメッセージやタイムスタンプ、Tagger は変化しませんが、 タグにつけられた GPG 署名はストリップされます
- `-- --all`
  - `git-filter-branch` の対象とするコミットハッシュを取得する際の `git-rev-list` に渡すオプションです
  - 今回の目的はすべてのブランチ・タグから巨大なファイルを削除したいので `--all` とします

**ファイル削除の確認手順**

上記 `git-filter-branch` を実行後、 `libartd.so` の削除が正しく行われたことを確認します。

```
$ git for-each-ref --format="%(refname)" refs/original/ | xargs -n 1 git update-ref -d
```
- `git-filter-branch` によって退避された `refs/original` のすべての参照を [git-update-ref](https://git-scm.com/docs/git-update-ref) で削除します

```
$ git reflog expire --expire=now --all
```
- アクセスできないすべての無効な reflog エントリを削除します

```
$ git gc --prune=now
```
- [git-gc](https://git-scm.com/docs/git-gc) で GC を実行し、古いオブジェクトを削除して、パックファイルを更新します

その後 `git_find_big.sh` を再実行します

```
All sizes are in kB's. The pack column is the size of the object, compressed, inside the pack file.
size   pack  SHA                                       location
28051  7497  e72e673ad74ff69060291ddab1a3989a7c6a8c07  libunwindstack/tests/files/offline/jit_debug_x86_32/libarttestd.so
10380  3200  92ed9915d4f9ea96a4accfcad82c657ff72f17fd  libunwindstack/tests/files/offline/jit_debug_x86/libartd.so
8127   3048  ef1a6a1857bc2613a9ae162dd56f2cb67858eb3a  libbacktrace/testdata/arm64/libskia.so
5968   2417  05278936be5fda0f62b5bd37b6951bcdbb5e8cd2  libunwindstack/tests/files/offline/jit_debug_arm/libart.so
5952   2401  09ba49532205981ff638c6935436cbb09d678aba  libunwindstack/tests/files/offline/art_quick_osr_stub_arm/libart.so
5400   2170  bed8e3595aa8cab7f381cfcb7aa05d10f010e687  libbacktrace/testdata/arm/libart.so
5294   2607  855905655e4c822794b6d66657c657b5307f17c9  libunwindstack/tests/files/offline/jit_debug_arm/libartd.so
5076   1391  092fc3aec90445662e23c2fdca8207e031602793  libunwindstack/tests/files/offline/straddle_arm64/libunwindstack_test
4134   1362  871f6dc89e9fb88b8758f5ef2deb2224295f8411  libbacktrace/testdata/arm/libGLESv2_adreno.so
3695   1604  7a30bfa440efc24d651b544163e5574d6b027a63  libunwindstack/tests/files/offline/offset_arm/libunwindstack_test
```

上記のとおりようやく 50MB を超える `libartd.so` が存在しなくなっていることが確認できました。

ところがここで一つ課題があります。

今回の作業は Azure D2V2 (Core=8, RAM=28GB, SSD=400GB) インスタンスで行いましたが、実行時間は `real 15m48.335s` と 15 分以上要しました。実行時間をより短くするために、次のステップでは、 [BFG](https://rtyley.github.io/bfg-repo-cleaner) という機能を絞ったシンプルかつ高速なツールを紹介します。

### 2b. コミット履歴の書き換え: `BFG` を用いた場合

[BFG](https://rtyley.github.io/bfg-repo-cleaner) は Scala 製でシンプルかつ高速なツールです。 `git-filter-branch` ほど複雑な処理はできませんが、今回の目的に最も適した `--strip-blobs-bigger-than <MB>` という便利なオプションがあります。再度取得し直した [platform/system/core.git](https://android.googlesource.com/platform/system/core.git) にこのツールを適用してみます。

```
$ java -jar bfg.jar --strip-blobs-bigger-than 50M platform/system/core.git
```

オプションの説明:

- `strip-blobs-bigger-than 50M`
  - 50MB より大きなすべての blob を削除します

**実行結果**

```
Scanning packfile for large blobs: 140503
Scanning packfile for large blobs completed in 1,009 ms.
Found 1 blob ids for large blobs - biggest=229696508 smallest=229696508
Total size (unpacked)=229696508
Found 1897 objects to protect
Found 687 tag-pointing refs : refs/tags/afw-test-harness-1.5, refs/tags/afw-test-harness-2.1, refs/tags/android-1.6_r1, ...
Found 190 commit-pointing refs : HEAD, refs/heads/master, refs/remotes/origin/HEAD, ...

Protected commits
-----------------

These are your protected commits, and so their contents will NOT be altered:

 * commit f80c326d (protected by 'HEAD')

Cleaning
--------

Found 45661 commits
Cleaning commits:       100% (45661/45661)
Cleaning commits completed in 4,047 ms.

Updating 89 Refs
----------------

	Ref                                                     Before     After   
	---------------------------------------------------------------------------
	refs/heads/master                                     | f80c326d | 9a90afc4
	refs/remotes/origin/master                            | f80c326d | 9a90afc4
	refs/remotes/origin/master-cuttlefish-testing-release | fd088948 | 9c55624d
	refs/remotes/origin/nougat-iot-release                | 044e0276 | 52cd619d
	refs/remotes/origin/o-mr1-iot-preview-7               | 2b49abe4 | 8ff6ed0b
	refs/remotes/origin/o-mr1-iot-preview-8               | 22dc27b9 | 219cb8c9
	refs/remotes/origin/oreo-mr1-1.2-iot-release          | c53a0e91 | 8e1c9575
	refs/remotes/origin/oreo-mr1-iot-release              | f307efcf | 9ee5bb5f
	refs/remotes/origin/pie-cts-dev                       | 2ef5c2ef | 1be06911
	refs/remotes/origin/pie-cts-release                   | bfe37429 | 0710a778
	refs/remotes/origin/pie-cuttlefish-testing            | 17b9e8e0 | 7135e773
	refs/remotes/origin/pie-dev                           | 820ef150 | c9998b31
	refs/remotes/origin/pie-dr1-dev                       | 93d837f3 | bfe27d17
	refs/remotes/origin/pie-dr1-release                   | 47e38865 | 92fcac25
	refs/remotes/origin/pie-gsi                           | afc32531 | 788ecf39
	...

Updating references:    100% (89/89)
...Ref update completed in 55 ms.

Commit Tree-Dirt History
------------------------

	Earliest                                              Latest
	|                                                          |
	.....................................................DDmmmmm

	D = dirty commits (file tree fixed)
	m = modified commits (commit message or parents changed)
	. = clean commits (no changes to file tree)

	                        Before     After   
	-------------------------------------------
	First modified commit | 150db124 | ecbcdafc
	Last dirty commit     | e242a97d | cd89e6b1

Deleted files
-------------

	Filename     Git id             
	--------------------------------
	libartd.so | 70352dbb (219.1 MB)

BFG run is complete! When ready, run: git reflog expire --expire=now --all && git gc --prune=now --aggressive
```

上記のような実行レポートのサマリーが得られます。

- `Deleted files` から何が削除されたかも明らかになりました。

より詳細な実行レポートはリポジトリ脇のディレクトリ配下に次の形式で残されています。

- `cache-stats.txt`
- `deleted-files.txt`
- `object-id-map.old-new.txt`

そして実行結果はわずか `real 0m9.403s` で、 `git-filter-branch` よりも 100 倍前後高速に結果が得られました。また `git_find_big.sh` の内容も [Step2a](#step2a-コミット履歴の書き換え-git-filter-branch-を用いた場合) と同一でした。

## 3. インポート成功確認

最後に GitHub.com にインポートできることを確認します。

```
$ git push --mirror github
```

今度は無事成功しました。

上記のどちらの手法を用いても巨大なファイルは削除され、 `git-push` できるようになったことが確認できました。

# 削除できない巨大なファイルが含まれている場合は

今回の例では、見つかった巨大なファイルが不要なため削除できるケースでしたが、削除できない場合はどうでしょうか。まずは [Git LFS](https://help.github.com/en/articles/configuring-git-large-file-storage) を利用するのが最も素直な解になります。それでもプロジェクトの都合で Git LFS が利用できない場合は巨大なファイルを分割したり、圧縮したりする回避策もあるかもしれません。

**管理方法**

```
$ md5sum $LARGEFILE > $LARGEFILE.md5
$ split $LARGEFILE -b 10M $LARGEFILE-
```
- 元ファイルのチェックサムと数 MB ごとに分割したファイルとして管理します

**復元方法**

```
$ cat $LARGEFILE-* > $LARGEFILE
$ md5sum -c $LARGEFILE.md5
```
- [githooks](https://git-scm.com/docs/githooks) の `post-merge` やビルドシステムなどで fetch 後に復元処理と元ファイルとの完全性を確認します

ただし [what-is-my-disk-quota](https://help.github.com/en/articles/what-is-my-disk-quota) にもあるように GitHub ではリポジトリのサイズは 1GB 以下を推奨されているため、分割管理をしたとしてもサイズの総和の制約は回避できません。

# まとめ

この記事では、不要かつ巨大なファイルが含まれたリポジトリを GitHub.com にインポートする手順についてまとめました。`git-filter-branch` でも `BFG` でも不要なファイルを扱ったコミットを除去できることが確認できました。単純に巨大なファイルを削除するだけが目的なら `BFG` を用いて、タグを一意の命名規則に沿って上書きしたり、ブランチ別に処理を分ける等、より複雑な操作をするときは `git-filter-branch` を用いると良いと思います。