複数のリポジトリを結合し日付順に並び替える

## 仕様

- 複数のサブリポジトリは結合リポジトリ内のサブディレクトリに移動し、独立なものとして扱うこと
- サブリポジトリは追記されていくので、結合リポジトリには追加する形で(追加分だけをpushできるように)結合できること
- 結合後、サブリポジトリ全体のauthor date順にコミットが並び替えられ、かつcommit dateがauthor dateと同じ値になるように変更されていること
- 同名のサブリポジトリが削除されて再生成された場合も追記できること
- **サブリポジトリ名はハイフンで始まっている可能性がある**

## 答案(デモ)

git filter-branchとgit rebaseの複合。

```sh
#!/bin/bash
set -e
export FILTER_BRANCH_SQUELCH_WARNING=1

# apt-get needs to success
if ! which sponge >/dev/null; then
    echo 'need to install `sponge` to sort commits in-place, which is in `moreutils` package.'
    sudo apt-get -y install moreutils
fi

function commit () {
### headtime should be unix-int ###
local hashes=$(git -C "${dirroot}" log --pretty=format:%H remotes/"${sub}"/master)
# * 処理開始時点でのコミット時刻以前のコミットを取得する *
#local hashsynced=$(git -C "${dirroot}" log --pretty=format:%H --before="$headtime" remotes/"${sub}"/master | head -1)
local hashsynced=$(git -C "${dirroot}" log --pretty='format:%at %H' remotes/"${sub}"/master | awk '$1<='${headtime} | head -1 | cut '-d ' -f2)
# * hashsynced以前のコミット一覧を取得する *
local hashesalready=""
if [ -n "${hashsynced}" ]; then
    local hashesalready=$(git -C "${dirroot}" log --pretty=format:%H "${hashsynced}")
fi
local nhashes=$(<<<"${hashes}" sed '/^$/d'|wc -l)
local nhashesalready=$(<<<"${hashesalready}" sed '/^$/d'|wc -l)
local nhashesnew=$((${nhashes}-${nhashesalready}))
local hashesnew=$(<<<"${hashes}" sed '/^$/d'|head -n ${nhashesnew})

if [ "$nhashesnew" -eq 0 ]; then
    echo '[.] nothing new to import.'
    return
fi

# * 新たに結合するハッシュの最新 *
local hashesnewhead=$(<<<"${hashesnew}" head -n 1)
# * 新たに結合するハッシュの最古 *
local hashesnewtail=$(<<<"${hashesnew}" tail -n 1)

if [ "$nhashesalready" -eq 0 ]; then
    echo '[.] initial import.'
    git -C "${dirroot}" checkout __tmp/master
    git -C "${dirroot}" checkout -b tmpmaster
    # * 強制的にコミットを採用したいので、--allow-empty --allow-empty-message --keep-redundant-commitsとする *
    git -C "${dirroot}" cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits "${hashesnewtail}"
    if [ "${nhashesnew}" -ge 2 ]; then
        # * cherry-pickでA..Bと指定すると、「Aの直後からBまで」を順番にcherry-pickする意味になる *
        # * A^..Bとすれば「Aを含めてBまで」とできるが、Aがroot commitの場合は不可 *
        git -C "${dirroot}" cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits "${hashesnewtail}..${hashesnewhead}"
    fi
    # * ファイルをサブディレクトリに移動するが、committer dataはauthor dataとする *
    ### this env-filter quote must be single. ###
    git -C "${dirroot}" filter-branch -f --tree-filter "mkdir -- '${sub}' && git mv -k -- * .gitignore '${sub}'/" --env-filter '
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
' __tmp/master..tmpmaster
    git -C "${dirroot}" checkout master
    # * リポジトリが再生成された場合でも、--strategy-option=theirsとすればcherry-pickできる *
    git -C "${dirroot}" cherry-pick --strategy-option=theirs --allow-empty --allow-empty-message --keep-redundant-commits __tmp/master..tmpmaster
    git -C "${dirroot}" branch -D tmpmaster
elif [ "$nhashesalready" -eq 1 ]; then
    echo '[.] cascading import (depth 1).'
    # * 後述の理由により2個前のコミットが必要だが、既にcherry-pickされているコミットは1個のみである。この1個とは(当該リポジトリの)rootである。 *
    # * __tmp/masterの下にこれをつなげることで、「2個前のコミット」が存在している状態にできる。 *
    git -C "${dirroot}" checkout __tmp/master
    git -C "${dirroot}" checkout -b tmpmaster
    git -C "${dirroot}" rev-parse ${hashesnewtail}^ > /dev/null # test existence
    git -C "${dirroot}" cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits ${hashesnewtail}^
    git -C "${dirroot}" cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits ${hashesnewtail}^..${hashesnewhead}
    # * ファイルをサブディレクトリに移動するが、committer dataはauthor dataとする *
    ### this env-filter quote must be single. ###
    git -C "${dirroot}" filter-branch -f --tree-filter "mkdir -- '${sub}' && git mv -k -- * .gitignore '${sub}'/" --env-filter '
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
' __tmp/master..tmpmaster
    git -C "${dirroot}" checkout master
    # * __tmp/masterの2つ下以降をchery-pickする *
    local derived_hashes=$(git -C "${dirroot}" log --reverse --pretty=format:%H --ancestry-path __tmp/master..tmpmaster)
    local cherrypick_root_excluding=$(<<<${derived_hashes} head -1)
    git -C "${dirroot}" cherry-pick --strategy-option=theirs --allow-empty --allow-empty-message --keep-redundant-commits ${cherrypick_root_excluding}..tmpmaster
    git -C "${dirroot}" branch -D tmpmaster
else
    echo '[.] cascading import.'
    git -C "${dirroot}" checkout remotes/"${sub}"/master
    git -C "${dirroot}" checkout -b tmpmaster
    # * ファイルをサブディレクトリに移動するが、committer dataはauthor dataとする *
    # * hashesnewtailを含めて、hashesnewtailから先頭までをcherry-pickしたい *
    # * が、hashesnewtailにrenameコミットが入っていると、 *
    # * ファイルの内容によってはdelete/addコミットになってしまい、cherry-pickに失敗してしまう。 *
    # * さらに1個前からfilter-branchしなければならない。 *
    # * あるhashの次という指定が必要なため、1個前を指定するには「2個前(の次)」という指定が必要である。 *
    ### this quote must be single. ###
    git -C "${dirroot}" filter-branch -f --tree-filter "mkdir -- '${sub}' && git mv -k -- * .gitignore '${sub}'/" --env-filter '
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
' ${hashesnewtail}^^..tmpmaster
    # * この時点でhashesnewtailの1つ前以降が書き換わっているので、hashesnewtailの2つ前の次の次からcherry-pickすれば良い *
    # * hashesnewtail自体はtmpmasterブランチに存在しないことに注意 *
    git -C "${dirroot}" checkout master
    local derived_hashes=$(git -C "${dirroot}" log --reverse --pretty=format:%H --ancestry-path ${hashesnewtail}^^..tmpmaster)
    local cherrypick_root_excluding=$(<<<${derived_hashes} head -1)
    git -C "${dirroot}" cherry-pick --strategy-option=theirs --allow-empty --allow-empty-message --keep-redundant-commits ${cherrypick_root_excluding}..tmpmaster
    git -C "${dirroot}" branch -D tmpmaster
fi
}

function sortByAuthorDate () {
# * git rebaseで表示される内容をauthor date(int)とする *
git -C "${dirroot}" config rebase.instructionFormat '%at %H'
# * rebase -iのエディタはGIT_SEQUENCE_EDITORで指定できる。 *
# * 第一引数で示されるテキストファイルを編集し再保存するという仕様である。 *
# * これはsort -s -n -k3とspongeコマンドで実現できる(安定ソートが必要なため-sは必須)。 *
# * 結合前のheadより後に対し処理を行うようにする。 *
GIT_SEQUENCE_EDITOR='sort -s -n -k3 $1|sponge $1' git -C "${dirroot}" rebase -i ${head}
# * 結合前のheadより後が日付順に並び替えられたが、commiter dataが書き換わってしまったため、author dataで再度上書きする。 *
git -C "${dirroot}" filter-branch -f --env-filter '
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
' ${head}..master
}

### fixture
# * headを設定したいので、root commitを古い日付で作成する *
dirroot="dirroot"
if [ ! -d "${dirroot}" ]; then
    mkdir "${dirroot}"
    git -C "${dirroot}" init
    touch "${dirroot}"/.root
    git -C "${dirroot}" add .root
    GIT_COMMITTER_DATE='2000-01-01 00:00:00' git -C "${dirroot}" commit --date='2000-01-01 00:00:00' -m 'root initial'
fi

mkdir dirsub1
git -C dirsub1 init
echo yaaaa > dirsub1/readme.txt
git -C dirsub1 add readme.txt
git -C dirsub1 commit --date='2003-01-01 00:00:00' -m 'sub1 initial'
touch dirsub1/readme2.txt
git -C dirsub1 add readme2.txt
git -C dirsub1 commit --date='2003-01-02 00:00:00' -m 'sub1 add 2'

mkdir dirsub2
git -C dirsub2 init
echo yaaaa > dirsub2/readme.txt
git -C dirsub2 add readme.txt
git -C dirsub2 commit --date='2002-01-01 00:00:00' -m 'sub2 initial'

### case1: creating
# * サブリポジトリを直接編集することはできないので、一旦メインリポジトリの勝手ブランチにcherry-pickする。その土台(__tmp/master)は、適当な古い日付のリポジトリを作成し、それをremote addすることで得られる。 *
if ! git -C "${dirroot}" remote show | grep __tmp > /dev/null; then
    ### need __tmp root to craft some commits.
    mkdir dirtmp
    git -C dirtmp init
    touch dirtmp/.tmp
    git -C dirtmp add .tmp
    GIT_COMMITTER_DATE='2000-01-01 00:00:00' git -C dirtmp commit --date='2000-01-01 00:00:00' -m 'tmp initial'
    git -C "${dirroot}" remote add __tmp ../dirtmp || true
    git -C "${dirroot}" fetch __tmp
    rm -rf dirtmp
fi

set +e
git -C "${dirroot}" remote add sub1 ../dirsub1 || true
git -C "${dirroot}" remote add sub2 ../dirsub2 || true
git -C "${dirroot}" fetch sub1
git -C "${dirroot}" fetch sub2
set -e

head=$(git -C "${dirroot}" rev-parse master)
headtime=$(git -C "${dirroot}" log --pretty=format:%ct ${head}|head -1|cut '-d ' -f1,2)
sub="sub1"
commit
sub="sub2"
commit
sortByAuthorDate

### fixture2
echo hello > dirsub1/readme.txt
git -C dirsub1 add readme.txt
git -C dirsub1 commit --date='2005-01-01 00:00:00' -m 'edit sub1'
echo world > dirsub2/readme.txt
git -C dirsub2 add readme.txt
git -C dirsub2 commit --date='2004-01-01 00:00:00' -m 'edit sub2'

### case2: adding
set +e
git -C "${dirroot}" remote add sub1 ../dirsub1 || true
git -C "${dirroot}" remote add sub2 ../dirsub2 || true
git -C "${dirroot}" fetch sub1
git -C "${dirroot}" fetch sub2
set -e

head=$(git -C "${dirroot}" rev-parse master)
headtime=$(git -C "${dirroot}" log --pretty=format:%ct ${head}|head -1|cut '-d ' -f1,2)
sub="sub1"
commit
sub="sub2"
commit
sortByAuthorDate

### fixture3
rm -rf dirsub1
mkdir dirsub1
git -C dirsub1 init
touch dirsub1/readme.txt
echo helloworld > dirsub1/readme.txt
git -C dirsub1 add readme.txt
git -C dirsub1 commit --date='2006-01-01 00:00:00' -m 'reinit'

### case3: reinit
set +e
git -C "${dirroot}" remote add sub1 ../dirsub1 || true
git -C "${dirroot}" remote add sub2 ../dirsub2 || true
git -C "${dirroot}" fetch sub1
git -C "${dirroot}" fetch sub2
set -e

head=$(git -C "${dirroot}" rev-parse master)
headtime=$(git -C "${dirroot}" log --pretty=format:%ct ${head}|head -1|cut '-d ' -f1,2)
sub="sub1"
commit
sortByAuthorDate
```

## 結果

```diff
commit 1fa1071562b10be47302c527bcaa813c30f25c58
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Sun Jan 1 00:00:00 2006 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Sun Jan 1 00:00:00 2006 +0900

    reinit

diff --git a/sub1/readme.txt b/sub1/readme.txt
index ce01362..31e0fce 100644
--- a/sub1/readme.txt
+++ b/sub1/readme.txt
@@ -1 +1 @@
-hello
+helloworld

commit 29b25993afa23b8c46f08010b4a069c01caac890
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Sat Jan 1 00:00:00 2005 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Sat Jan 1 00:00:00 2005 +0900

    edit sub1

diff --git a/sub1/readme.txt b/sub1/readme.txt
index 423511b..ce01362 100644
--- a/sub1/readme.txt
+++ b/sub1/readme.txt
@@ -1 +1 @@
-yaaaa
+hello

commit 67d47636433b413cdd123ac5aafde1dc6890479f
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Thu Jan 1 00:00:00 2004 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Thu Jan 1 00:00:00 2004 +0900

    edit sub2

diff --git a/sub2/readme.txt b/sub2/readme.txt
index 423511b..cc628cc 100644
--- a/sub2/readme.txt
+++ b/sub2/readme.txt
@@ -1 +1 @@
-yaaaa
+world

commit 5e7945df13ecd1be4d99c098b5e7ba9af462b417
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Thu Jan 2 00:00:00 2003 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Thu Jan 2 00:00:00 2003 +0900

    sub1 add 2

diff --git a/sub1/readme2.txt b/sub1/readme2.txt
new file mode 100644
index 0000000..e69de29

commit 9d0c16f17a864917c0f9e18dd2773714b004b17a
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Wed Jan 1 00:00:00 2003 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Wed Jan 1 00:00:00 2003 +0900

    sub1 initial

diff --git a/sub1/readme.txt b/sub1/readme.txt
new file mode 100644
index 0000000..423511b
--- /dev/null
+++ b/sub1/readme.txt
@@ -0,0 +1 @@
+yaaaa

commit 17947f5206f8af9820dae0fa28bda5b245bd0232
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Tue Jan 1 00:00:00 2002 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Tue Jan 1 00:00:00 2002 +0900

    sub2 initial

diff --git a/sub2/readme.txt b/sub2/readme.txt
new file mode 100644
index 0000000..423511b
--- /dev/null
+++ b/sub2/readme.txt
@@ -0,0 +1 @@
+yaaaa

commit c512dfd1ddd96df514fb1c3acc572977acf6e038
Author:     cielavenir <cielartisan@gmail.com>
AuthorDate: Sat Jan 1 00:00:00 2000 +0900
Commit:     cielavenir <cielartisan@gmail.com>
CommitDate: Sat Jan 1 00:00:00 2000 +0900

    root initial

diff --git a/.root b/.root
new file mode 100644
index 0000000..e69de29
```

期待通りの出力が得られました。

## 履歴等

https://github.com/cielavenir/merge_repos_with_datesort からどうぞ。
なお旧版にはファイル変更の状況により結合に失敗する不具合があります。

## 感想(200611)

既存ファイル変更が一番面倒で、状況によってはdelete/addコミットになってしまいcherry-pickに失敗する。1個前のコミットでファイルを移動したことにすれば大丈夫っぽい。
[ファイル名変更とファイル編集のコミットは分けたほうが安全](https://stackoverflow.com/questions/433111/how-to-make-git-mark-a-deleted-and-a-new-file-as-a-file-move)ですが、今回いろいろ[調べてみて](https://qiita.com/kitsuyui/items/3390fda2c87aa699c75b)勉強になりました(今まで何度か趣味のリポジトリいじっててこの辺の問題にぶち当たっていましたが、正直煩わしいだけと思っていました…)。
なお、今回は仕様上コミットを分けることができないのでよくわからないハック満載です。