Gitリポジトリのディレクトリ構成を変更する

話の発端

例えば、以下のような構成のリポジトリがあったとしよう。

+ project
    - .git
    - build.xml
    - Main.java

projectディレクトリをルートとし、その直下にソースコードが配置されている。

さて、ある日このプロジェクトにテストコードを追加することになった。
しかしアプリケーションコードとテストコードを一つのディレクトリに混在させるのは好ましくない。
そこで、リポジトリの構成を以下のように変更したい。

+ project
    - .git
    + app
        - build.xml
        - Main.java
    + test
        - build.xml
        - MainTest.java

さて、どうしたらいいだろうか?


単にお好みの構成になるようにファイルを移動して、変更をコミットすればいいだけだ、と考えるかもしれない。
たしかにその通りなのだが、できれば始めからappサブディレクトリ内で開発を行なっていたかのように履歴を書き換えることはできないだろうか?

git filter-branchを使えばそれができる。

git filter-branch

以下で行う操作は基本的に取り消しができないので、作業用に専用のローカルリポジトリをcloneして行うべきである。

まずは現在の状況を見て欲しい。

# find . | grep -v .git/
.
./.git
./build.xml
./Main.java

# git log --name-status --pretty=oneline
d44f40c4f0a7033586fdb9af795ea2edcec79c33 commit2
M       Main.java
c5785fbde8a4ae3d5b0ed192c2690922b318f06e commit1
A       Main.java
A       build.xml

現在コミットは二つある。
最初のコミットでbuild.xmlとMain.javaを追加し、二つめのコミットでjavaファイルを修正している。

ではこれらのファイルを履歴ごと、appサブディレクトリに移動させてみよう。
今回の問題を解決するもっとも単純なコマンドは、こうなる。

git filter-branch --tree-filter 'mkdir app; mv build.xml app; mv Main.java app;' HEAD

tree-filterを使うと、全てのコミットの作業ツリーに対して、指定したコマンドを実行した上でコミットをやり直すことができる。

では実行してみよう。

# git filter-branch --tree-filter 'mkdir app; mv build.xml app; mv Main.java app;' HEAD
# find . | grep -v .git/
.
./.git
./app
./app/build.xml
./app/Main.java

# git log --name-status --pretty=oneline
1d6302d9588a996b00e8d49b85139aa84c71be86 commit2
M       app/Main.java
1189fe63f58932fef1d2f249ca84c6271fc6e5af commit1
A       app/Main.java
A       app/build.xml

コミット履歴も含めて、サブディレクトリに移動しているのが分かる。


ここでいくつか注意点を述べる。

  • 上記コマンドは、現在のブランチの履歴のみを書き換える。
  • リポジトリ内に複数のブランチが存在していた場合、他のブランチとの整合性は失われる。(もちろんoriginとの整合性も失われる。)もはや古いブランチと履歴ツリーを共有することはできない。
  • 正確に言うと、他のブランチが持つ書き換え前のコミットと、現在のブランチが持つ書き換え後のコミットは完全に別々のコミットとして扱われる。コミットのハッシュ値が以前と変わっていることに注目してほしい。
  • -allオプションをつければ、リポジトリ内の全てのブランチに対して変更を適用することができる。
  • -allオプションによって、同リポジトリ内のブランチの整合性は保たれる。しかしこの方法でも、外部のリポジトリ(要するにorigin)との整合性を失うことは避けられない。
  • 要するにこの操作は、リポジトリ自体を新しく作り直して、古いリポジトリは破棄する方法であると考えるべきである。


試しにoriginと同期してみれば、実際に何が起こっているのか、よりよく理解できるだろう。

# git pull
# find . | grep -v .git/
.
./.git
./app
./app/build.xml
./app/Main.java
./build.xml
./Main.java

# git log --name-status --pretty=oneline
2ec94728f52e07f43ea1b2245e72b20c59091732 Merge branch 'master' of /home/as/user-dev-git/subdirectory.doc/project-origin
1d6302d9588a996b00e8d49b85139aa84c71be86 commit2
M       app/Main.java
d44f40c4f0a7033586fdb9af795ea2edcec79c33 commit2
M       Main.java
1189fe63f58932fef1d2f249ca84c6271fc6e5af commit1
A       app/Main.java
A       app/build.xml
c5785fbde8a4ae3d5b0ed192c2690922b318f06e commit1
A       Main.java
A       build.xml

移動前のツリーと移動後のツリーがマージされてカオスなことになってしまう。
両者はもはや混ぜるな危険、なのである

コマンドの改良

さて、とりあえずこれで当初の目的は達成したわけだが、実際のプロジェクトに適用する前に、まだ注意すべきことがある。
それは、tree-filterに渡すコマンドについてだ。

今回実行したコマンドをもう一度見てみよう。

filter-branch --tree-filter 'mkdir app; mv build.xml app; mv Main.java app;' HEAD
  • このコマンドは、履歴の中に、build.xmlやMain.javaを含まないコミットが一つでもあると、失敗する。なぜなら、その作業ツリー内に存在しないファイルを移動させようとするからである。
  • また、履歴の中に既にappというファイル/ディレクトリを持つコミットが一つでもあると、このコマンドは意図しない動作を引き起こす。

前者については、コマンド内で現在のファイルを列挙するようにすれば解決する。
後者については、作業ツリー内のファイルと重複しないランダムな名前の一時ディレクトリを作って、一旦そこに全てのファイルを移動させてから希望の名前にリネームするという方法がいい。
この処理をワンライナーで書くのはさすがに厳しすぎるので、スクリプトの力を借りるべきだろう。

rubyで書いてみた。

require "rake"

def main
    target = ARGV.shift

    #カレントディレクトリのファイルを列挙
    fs = `ls -a`.split.reject{|d| %w{ . .. .git .gitignore }.include?(d) }

    if(target && fs )
        temp_dir = rand_name(1000){|n| ! File.exists?(n) }

        mkdir temp_dir
        fs.each{|f| mv f,temp_dir }

        mv temp_dir,target
    end
end


# 与えられたブロックがtrueを返すランダムな名前を作成する
def rand_name(max_count)
    a = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
    max_count.times{|i|
        name = Array.new(16){a[rand(a.size)]}.join
        return name if yield name
    }
    raise "too many loop"
end

main


呼び出してみる。リポジトリ外のファイルは絶対パスでないと参照できないので注意。

git filter-branch --tree-filter 'ruby ~/user-script/tree-filter-mv-dir.rb app' HEAD

まとめ

  • git filter-branch --tree-filterを使用することで、コミット履歴を再構築できる。
  • 再構築されたブランチは、もはや古いブランチやリポジトリとは履歴を共有しない。うっかり混ぜないように注意。
  • tree-filterに渡すコマンドは、履歴上のどの時点の作業ツリーに適用しても、問題なく動作するものでなければならない。
  • 特にファイル名を直接指定しているところは要注意である。その時点では存在しないファイルにアクセスしようとしたり、逆に既存のファイルを意図せず上書きしてしまうかもしれない。
  • tree-filterを使ってあまり複雑なことをしようとは考えないほうがいい。