🌲

git subtreeの仕組み

2024/02/10に公開

git subtree

git-subtreeは、複数のリポジトリを一つのリポジトリに纏めるためのツールで、gitのcontribに含まれている。ここではgit-subtreeの仕組みについて説明する。

前提となるgitのデータ構造についてはPro Git 10.2 Gitの内側 - Gitオブジェクトを参照のこと。

事前準備

取り込み元リポジトリの内容を参照するため、 git remote add でリモートリポジトリを登録し、git fetch して対象のコミットを参照できるようにしておく。

git subtree add

git subtree add は、取り込み元の内容を指定したサブディレクトリ以下に取り込む。

雰囲気で説明すると、取り込み元のディレクトリツリーをサブディレクトリ以下に mv したものを、 git merge --allow-unrelated-histories でマージして取り込んでいる感じ。

具体例で説明しよう。事前準備で登録したリモートリポジトリの履歴に「取り込み元commit」が含まれているとする。

ここで git subtree add –prefix=sub_dir 取り込み元commit すると、次の図のようにツリーオブジェクトとマージコミットが作られ、HEADが更新される。

「マージcommit」では、「取り込み先commit」から見ると指定したサブディレクトリ以下にファイルが追加されており、「取り込み元commit」から見るとファイルが指定したサブディレクトリ以下に移動されている。

$ git diff --name-status 取り込み先commit..マージcommit
A       sub_dir/file_g
A       sub_dir/file_r
A       sub_dir/file_y
$ git diff --name-status 取り込み元commit..マージcommit
A       file_b
R100    file_g  sub_dir/file_g
R100    file_r  sub_dir/file_r
R100    file_y  sub_dir/file_y

git subtree add したときに実行されている内容は以下の通り。

  1. git read-tree --prefix=sub_dir 取り込み元commit で取り込み元のディレクトリツリーをsub_dir以下に移動しつつ、インデックスに取り込み
  2. git checkout -- sub_dir でsub_dir以下をインデックスから作業ツリーに反映
  3. git write-tree でトップレベルのツリーオブジェクトを生成
  4. git commit-tree 生成したツリーオブジェクト -p 取り込み先commit -p 取り込み元commit で親を2つ指定してマージコミットを生成
  5. git reset 生成したマージコミット で HEAD を更新

マージコミットにより「取り込み元commit」に連なるコミットグラフをそのまま取り込むため、git log で表示されるコミットハッシュは取り込み元リポジトリのものと同じになる[1]

また、git diff --name-statusでR100と表示されていた通り一致度100%でリネームとして判定される[2]ので、git blame ではマージコミットより手前の取り込み元リポジトリ側の履歴も辿ってくれる。

git subtree merge

git subtree merge --prefix=sub_dir 取り込み元commit でsubtree add済みのsub_dirにさらなる取り込みを行える。

処理としては git merge --no-ff -Xsubtree=sub_dir 取り込み元commit でディレクトリがずれていることを踏まえてマージしている。これにより、既にsubtree addあるいはsubtree mergeで取り込み済みのコミット履歴よりも先の履歴がいい感じに取り込まれる。

試してみる

サンプルリポジトリを作成して実際に試してみよう。

取り込み元リポジトリ作成

/tmp$ mkdir remote
/tmp$ cd $_
/tmp/remote$ git init
Initialized empty Git repository in /private/tmp/remote/.git/
/tmp/remote$ for c in y r g; do echo ${c} > file_${c}; done
/tmp/remote$ git add .
/tmp/remote$ git commit -m "initialize target repository"
[main (root-commit) 7705f16] initialize target repository
 3 files changed, 3 insertions(+)
 create mode 100644 file_g
 create mode 100644 file_r
 create mode 100644 file_y
/tmp/remote$ COMMIT_1=$(git rev-parse HEAD)
/tmp/remote$ echo yyy >> file_y
/tmp/remote$ git add .
/tmp/remote$ git commit -m "update file_y in target repository"
[main c932ae2] update file_y in target repository
 1 file changed, 1 insertion(+)
/tmp/remote$ COMMIT_2=$(git rev-parse HEAD)
/tmp/remote$ REMOTE_URL=$(pwd)
/tmp/remote$ cd -
/tmp

取り込み先リポジトリ作成

/tmp$ mkdir local
/tmp$ cd $_
/tmp/local$ git init
Initialized empty Git repository in /private/tmp/local/.git/
/tmp/local$ echo b > file_b
/tmp/local$ git add .
/tmp/local$ git commit -m "initialize local repository"
[main (root-commit) 6196cdb] initialize local repository
 1 file changed, 1 insertion(+)
 create mode 100644 file_b
/tmp/local$ COMMIT_3=$(git rev-parse HEAD)

リモート設定

/tmp/local$ git remote add target ${REMOTE_URL}
/tmp/local$ git fetch target
remote: Enumerating objects: 8, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 8 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (8/8), 522 bytes | 130.00 KiB/s, done.
From /tmp/remote
 * [new branch]      main       -> target/main

git subtree add

/tmp/local$ ls
file_b
/tmp/local$ git log --oneline --graph
* 6196cdb (HEAD -> main) initialize local repository
/tmp/local$ git log --oneline --graph ${COMMIT_1}
* 7705f16 initialize target repository
/tmp/local$ git subtree add --prefix=sub_dir ${COMMIT_1}
Added dir 'sub_dir'
/tmp/local$ git log --oneline --graph
*   3e9d760 (HEAD -> main) Add 'sub_dir/' from commit '7705f165ca49a966f8d9278ecb96552d10e76359'
|\
| * 7705f16 initialize target repository
* 6196cdb initialize local repository
/tmp/local$ ls *
file_b

sub_dir:
file_g	file_r	file_y
/tmp/local$ git diff --name-status ${COMMIT_3}..HEAD
A       sub_dir/file_g
A       sub_dir/file_r
A       sub_dir/file_y
/tmp/local$ git diff --name-status ${COMMIT_1}..HEAD
A       file_b
R100    file_g  sub_dir/file_g
R100    file_r  sub_dir/file_r
R100    file_y  sub_dir/file_y

git subtree merge

/tmp/local$ git log --oneline --graph
*   3e9d760 (HEAD -> main) Add 'sub_dir/' from commit '7705f165ca49a966f8d9278ecb96552d10e76359'
|\
| * 7705f16 initialize target repository
* 6196cdb initialize local repository
/tmp/local$ git log --oneline --graph ${COMMIT_2}
* c932ae2 (target/main) update file_y in target repository
* 7705f16 initialize target repository
/tmp/local$ git subtree merge --prefix=sub_dir ${COMMIT_2}
Merge made by the 'ort' strategy.
 sub_dir/file_y | 1 +
 1 file changed, 1 insertion(+)
/tmp/local$ git log --oneline --graph
*   29f8850 (HEAD -> main) Merge commit 'c932ae27c75bebd66a1844e463a655c4ce823638'
|\
| * c932ae2 (target/main) update file_y in target repository
* | 3e9d760 Add 'sub_dir/' from commit '7705f165ca49a966f8d9278ecb96552d10e76359'
|\|
| * 7705f16 initialize target repository
* 6196cdb initialize local repository
/tmp/local$ git diff --name-status HEAD^1..HEAD
M       sub_dir/file_y
脚注
  1. オプション --squash を指定しなかった場合 ↩︎

  2. Gitはファイルのリネームをどう扱うか ↩︎

Discussion