古いコードのgitコンバート

松山でOffice/メールワイズを開発している竹治です。 古いMicrosoft Visual SourceSafe(以下VSS)時代のソースをgitに変換したときのことを紹介します。

背景

VSSで運用していたサイボウズOffice Ver1~Ver7までの古いソースコード。 開発はgitおよびgithubをメインに使っていますが、Ver1~Ver7までのコードは変換されていませんでした。

また、バージョン毎にフォルダが分かれていたため(VSSの頃はバージョン毎にフォルダを分ける運用を良く行っていました)履歴を辿るのが大変。 そこで、gitにコンバートしてかつ1つの履歴としてつなげてみることにしました。

Subversionからのクローン

VSSのソースはSubversionにはコンバート済みでした。 「Gitとその他のシステムの連携 - Git への移行」 を参考にしてまずgitリポジトリに変換しました。

作業はWindows上でGit Bashを開いて行いました。

クローン用のスクリプトを組んで変換しました。 ほとんどが「Gitとその他のシステムの連携 - Git への移行」で紹介されている内容ですが、今回、私が対象としたリポジトリ固有の処理も入っています。 こういった作業は試行錯誤を繰り返しながら何度も行うため、最初からスクリプトを組んで作業を行った方が効率が良いです。

$ vi git-svn
#!/bin/bash
repo=$1
layout=$2
svn log --xml SVNのURL/$repo/ | grep "^<author" | sort -u | perl -pe 's/<author>(.*)<\/author>/$1 = $1 <$1\@cybozu.co.jp>/' > $repo-users.txt
echo "(no author) = no_author <no_author@no_auther>" >> $repo-users.txt
vi $repo-users.txt
git svn clone $layout --prefix=svn/ --no-metadata --authors-file=$repo-users.txt SVNのURL/$repo $repo

また、対象が複数ある場合も、スクリプトにしておくと効率的です。 今回の場合はvss、AG、officeという3個のSubversionリポジトリを変換しました。

$ git-svn vss --trunk=trunk
$ git-svn AG -s
$ git-svn office -s

ここまでではまだ各フォルダの履歴はつながっていませんが、gitになっただけでも高速で便利なため、そのままgithubにリポジトリを用意してpushしました。

$ git -C vss gc --aggressive --prune=now
$ git -C vss remote add origin githubのURL/svn-vss
$ git -C vss push -u origin master
$ git -C AG gc --aggressive --prune=now
$ git -C AG remote add origin githubのURL/svn-ag
$ git -C AG push -u origin master
$ git -C office gc --aggressive --prune=now
$ git -C office remote add origin githubのURL/svn-office
$ git -C office push -u origin master

-C オプションは各リポジトリに cd せずに実行するときに便利なオプションです。

リポジトリの分割

gitに変換したリポジトリは、1つの中に複数のバージョンが混ざっていました。

vss/
  +- .git
  +- Office/
  +- Office2/
  +- Office3/
  +- Office4/
      :
AG/
  +- .git
  +- Office5/
  +- Office6/
      :

このままではマージしづらいため、いったん各フォルダ毎に別リポジトリに分割しました。 分割は「Git でリポジトリ内のサブディレクトリ同士をマージする」を参考にしました。

git filter-branchを使います。 filter-branchは各コミットに対して様々な処理を行える強力なコマンドです。 --subdirectory-filter という組み込みのフィルターで特定のフォルダを別リポジトリに分割することができます。

例によってスクリプトを作成して作業しました。

$ vi git-subdir
#!/bin/bash
repo=$1
subdir=$2
git clone $repo $repo-$subdir
cd $repo-$subdir
git filter-branch --subdirectory-filter $subdir HEAD
cd ..

作成したgit-subdirを使って別リポジトリに分割しました。

$ git-subdir vss Office
$ git-subdir vss Office2
$ git-subdir vss Office3
$ git-subdir vss Office4
      :

分割したリポジトリのマージ

最後にいよいよ分割した各リポジトリの歴史をつなげる作業です。 これにはいくつかの戦略が考えられます。

まず最初に試したのが、古いリポジトリへ新しいリポジトリを順にそのままマージしてみる方法です。 先程と同じ「Git でリポジトリ内のサブディレクトリ同士をマージする」を参考にしました。 しかしこれはうまく行きませんでした。 VSSの頃は、新しいバージョンに移るときに気分一新、フォルダ構成を大きく変えたり、リファクタしてから初期追加したりしていたためです。

次に試したのが、マージする前に一度全削除したコミットを入れる方法です。 幸いgitは非常に強力な履歴追跡を行うので、いったん削除しようがファイル名が変わろうが履歴としてつながる場合が多いです。 しかしこちらも失敗しました(理由は失念しました><)。 マージのstrategyを色々変えて試したのですが、ことごとく失敗しました。

最終的に採用したのは次の方式です。

  1. 一番古いリポジトリにcherry-pickで重ねていく
  2. 次のリポジトリでフォルダ構成が大きく変わっている場合、手動でフォルダ構成を合わせるコミットを作成
  3. 不要なファイルが残らないように次のリポジトリで最終的に消えているファイルは予め消すコミットを作成

この方法には欠点があって、別リポジトリを重ねる前に全ファイルを削除はしないために、不正確なマージ結果になる可能性が少しだけあります。 この点は制限として許容して、作業を進めました。

次の様なチェリーピック用のスクリプトを作成しました。

$ vi git-pick
#!/bin/bash
repo=$1
find . -type d -name ".git" -prune -o -type f -exec bash -c "if [ ! -f ../$repo/{} ]; then rm -v {}; fi" \;
git commit -a -m "delete"
git clean -f -d
git remote add $repo ../$repo
git fetch $repo
git branch $repo $repo/master
git cherry-pick -X theirs --keep-redundant-commits --allow-empty --allow-empty-message ..$repo

findで始まる行が、次のリポジトリで消えているファイルを予め消す処理です。 全てのファイルについて、次のリポジトリに存在するかを確認して、無ければrmで消しています。

git cherry-pickはいくつかのオプションを指定しています。

オプション 意味
-X theirs マージストラテジの指定。コンフリクトした場合にtheirsを採用。つまり、新しいリポジトリの方を優先します。
--keep-redundant-commits 空のコミットを残す
--allow-empty 空のコミットを許可する
--allow-empty-message 空のコミットメッセージを許可する。VSSの頃はコミットメッセージが空のことも良くありました。

一番古いリポジトリはvss-Officeですが、ここにそのままマージすると再試行できないため、まずworkにコピーしました。

$ git clone vss-Office work

続いて先程作成したスクリプトでチェリーピックしていきます。

$ cd work
$ ../git-pick vss-Office2
$ ../git-pick vss-Office3
$ ../git-pick vss-Office4
      :

作業を進めていくと、途中で次の様なエラーが発生しました。

Changes not staged for commit:
        :
no changes added to commit
The previous cherry-pick is now empty, possibly due to conflict resolution.

次の様なメッセージの場合もありました。

error: Your local changes to the following files would be overwritten by merge:
        :
Please, commit your changes or stash them before you can merge.
Aborting

ググってみたところ、VSSではファイル名の大文字・小文字の違いが無視されましたが、gitではうまく扱えないのが原因のようでした。 「Gitでファイル名&ディレクトリ名の大文字/小文字を変更方法」などを参考にして修正しました。

次の様なスクリプトを用意して、

$ vi ../git-rename
#!/bin/bash
git mv "$1/$2" "$1/_$2"
git commit -m "rename"
git mv "$1/_$2" "$1/$3"
git commit -a -m "modify"

次の様にリネームして、cherry-pickの--continueで継続させました。

$ ../git-rename src/script setup.rul Setup.rul
$ git cherry-pick --allow-empty --allow-empty-message --continue

変換作業は以上で終わりです。 若干不正確な履歴になる可能性があるため、現在使っているリポジトリとの統合までは行いませんでした。

TortoiseGitで履歴を追う

最後に、履歴をTortoiseGitを使って辿る場合のTipsです。

ログで名前の変更を超えてさかのぼる

初期値では名前変更したところで履歴が止まります。 「ツリーの探索(H)」ボタンの中の「名前の変更を超えてさかのぼる(F)」を選択します。

名前の変更を超えてさかのぼる

注釈履歴(blame)でログで名前の変更を超えてさかのぼる場合は、TortoiseGitBlameで「表示(V)-名前の変更を超えてさかのぼる(F)」を選択します。 コマンドラインでは、--followオプションを指定します。

移動や複製を無視して注釈履歴(blame)を行う

リファクタなどで関数の位置をファイル内で移動させただけとか、ファイル間で移動や複製した操作があると、そこが注釈履歴で表示されて見たい部分が追えない場合があります。 TortoiseGitBlameで「移動/複製された行の検知 >>」を指定することで、移動や複製される前の履歴を辿ることができます。

コマンドラインでは、移動は-C、複製は-C -C(-Cを2個)を指定します。 移動は同一コミットの中から探しますが、複製は複製元がそのコミットの情報に無いため全ファイルから探す必要があり、計算量が大きくなります。

まとめ

古いリポジトリをうまくgitに変換するのは一筋縄では行きませんが、いざという時には大変役に立ちます。 チャレンジしてみる価値はあると思います。

本稿を書くにあたり、「入門Git」で少し勉強しました。 gitメンテナーである濱野氏による入門書だけあって、非常に参考になります。 通しで読むのが大変な場合は、第2章のgitの基本概念だけでも読んでみてください。 gitの理解が深まります。