はじめに
別のリポジトリで管理しているライブラリを、自分のプロジェクトに取り込みたいことがあります。最初に候補へ挙がるのは git submodule です。ただし submodule は、clone した人が git submodule update --init を実行しないとディレクトリが空のままになります。
この追加操作をなくしたいときの選択肢が git subtree です。この記事では、git subtree の用途、コマンド、仕組み、submodule との違いをまとめます。
開発環境
- Windows 11
- Git 2.55.0
- VSCode
git subtree とは
他のGitリポジトリの内容を、自分のリポジトリのサブディレクトリへコピーするコマンドです。取り込んだ後は、そのディレクトリは普通のファイル群として扱えます。clone した人は追加の操作なしでファイルを取得できます。
用途は次の4つが中心です。
| やりたいこと | 使うコマンド |
|---|---|
| 社内共通ライブラリを複数プロジェクトで使う | git subtree add |
| 取り込み元の更新を反映する | git subtree pull |
| 取り込み先で直した内容を本家へ戻す | git subtree push |
| モノレポの1ディレクトリを独立リポジトリにする | git subtree split |
生まれた経緯
Git 本体には以前からサブツリーマージ戦略(git merge -s subtree)がありました。これを使えば別リポジトリの内容をサブディレクトリへ配置できます。ただし取り込み、更新、切り出しのそれぞれで手順が長く、正確に覚えて実行する必要がありました。
2009年に Avery Pennarun 氏が、この手順をラップする外部コマンド git-subtree を公開します。これが Git 1.7.11(2012年6月)から contrib/subtree として Git に同梱されました。
仕組み
add と split の2つが理解するうえでポイントになると思います。残りのコマンドは組み合わせで理解できます。
add がすること
- 取り込み元リポジトリの履歴を fetch する
- 取り込んだ内容を
--prefixで指定したディレクトリ配下へ配置するマージコミットを作る
--squash を付けると、取り込み元の全コミットを1つにまとめてから配置します。自分のリポジトリの git log に他人のコミットが並ぶのを防げます。
split がすること
--prefix 配下のファイルを変更したコミットだけを抜き出します。抜き出したコミットは、そのディレクトリをルートとするパスへ書き換えられます。結果は新しいブランチとして作られます。
git subtree push は、内部で split を実行してから push しています。
主要なコマンド
shared-utils というリポジトリを libs/shared-utils に取り込む例で書きます。
remote を登録する
毎回URLを書くと長いので、remote 名を付けておきます。
git remote add shared-utils https://github.com/example/shared-utils.git
add:取り込む
git subtree add --prefix=libs/shared-utils shared-utils main --squash
--prefix には、まだ存在しないディレクトリを指定します。
$ ls libs/shared-utils
README.md package.json src/
pull:取り込み元の更新を反映する
git subtree pull --prefix=libs/shared-utils shared-utils main --squash
push:変更を本家へ送る
libs/shared-utils 配下を直したコミットだけが、指定ブランチへ送られます。
git subtree push --prefix=libs/shared-utils shared-utils feature/fix-typo
送った先で Pull Request を作る流れになります。
split:ディレクトリを独立させる
git subtree split --prefix=libs/shared-utils -b shared-utils-only
shared-utils-only ブランチができます。元の libs/shared-utils/src/index.ts は、このブランチでは src/index.ts になります。
split は既存のブランチを書き換えません。新しいブランチを作るだけなので、試して失敗してもブランチを削除すれば元に戻ります。
submodule との比較
| 項目 | git subtree | git submodule |
|---|---|---|
| 取り込んだファイルの実体 | 自分のリポジトリに入る | 入らない(コミットIDの参照だけ) |
| clone 直後の状態 | ファイルが揃っている | 空ディレクトリ |
| 利用側に必要な追加操作 | なし | git submodule update --init |
| 設定ファイル | なし(毎回 --prefix を指定) |
.gitmodules |
| リポジトリのサイズ | 取り込み元の履歴分だけ増える | ほぼ増えない |
| 取り込み元へ変更を戻す | git subtree push |
サブモジュール側で commit / push |
| バージョンの指定 | 取り込んだ時点の内容で固定 | コミットIDで固定 |
メリット
- clone した人が subtree を知らなくても使えます。ファイルが最初から存在します
- CI やビルドツールの設定を変えずに済みます。ただのディレクトリとして見えます
- 取り込み元のリポジトリが消えても、ファイルは自分のリポジトリに残ります
- 取り込み先で直した内容を、
git subtree pushで本家へ送れます
チーム全員にGitの追加コマンドを説明しなくてよいのが、実務では一番ありがたいと思います。
注意点
| 注意点 | 内容 |
|---|---|
--squash の有無を統一する |
add で付けたなら pull でも毎回付けます。混ぜると pull でコンフリクトします |
--prefix を毎回書く |
設定ファイルがないため、パスを間違えると別の場所にファイルが展開されます |
| push に時間がかかる | split が全コミットを走査します。履歴が長いリポジトリでは数分かかります |
| 履歴が混ざる | 取り込み元のコミットが自分の git log に並びます。--squash で1件に減らせます |
| リポジトリが大きくなる | 取り込み元のファイルと履歴を自分のリポジトリが保持します |
| どこから取り込んだか記録が残らない | コミットメッセージにしか情報がありません。READMEへ prefix とURLを書いておきます |
最後の点は運用でカバーするしかないので、私は次のようなメモをリポジトリの README に置いています。
## 取り込んでいる外部リポジトリ
| prefix | 取り込み元 | ブランチ | squash |
|---|---|---|---|
| libs/shared-utils | https://github.com/example/shared-utils.git | main | あり |
選び方
| 状況 | 選ぶもの |
|---|---|
| npm や PyPI で配布されている | パッケージマネージャ |
| clone する人にGitの追加操作をさせたくない | subtree |
| OSSをフォークして自社の改造を当て続ける | subtree |
| 取り込み元のバージョンをコミットID単位で固定したい | submodule |
| リポジトリのサイズを増やしたくない | submodule |
| 取り込み元がプライベートで、閲覧権限を分けたい | submodule |
まとめ
この記事では git subtree について記載しました。他のリポジトリの内容を自分のリポジトリのサブディレクトリへコピーするコマンドで、clone した人に追加の操作をさせずに済みます。