はじめに
僕はSVN脳患者である。SVN脳とは、SubversionのポリシーでGitを理解しようとしたり、使おうとしたりする病気で、中年プログラマに発症例が多い(気がする)。それまでSubversionを使ったことがない人がGitを使う場合には問題にならなかったことが、SVN脳患者がGitを使おうとすると問題になることが多い。特に、SVN脳を発症したプログラマは、そうでない人に比べてGit学習コストが爆発的に増大する。最初からGitに触れた人は、なぜSVN脳患者がGitを理解できないのかを理解できないだろう。
これは、SVN脳患者である僕1が、なぜGitを長いこと理解できなかったかをつらつら書くポエムである。病人の書いたポエムであるからして、所謂マサカリの類はほどほどにしていただきたい。
以下、「SVN脳患者」という大きな主語を多用するが、要するにこれは僕のことであり、言うまでもなくSubversion歴の長い人全員を指すわけではないことに注意されたい。
なお、筆者のGit環境では以下のようなエイリアスが設定されている2。
alias.st=status -s
alias.ci=commit -a
alias.co=checkout
背景
病気の原因や対処をするのに患者のバックグラウンドを理解しておくのは重要であろう。僕はこんなプログラマである。
- 中年プログラマ。チーム開発の経験もあるが、一人での開発が多い。
- CVSを使い始めたのが2000年頃だと思われる。
- Subversionに乗り換えたのはおそらく2004年くらい。
- Gitを使い始めたのが2009年くらい。だが、本格的に使い始めたのは2012年だと思われる。
今では、Subversionで個人のソースを管理、開発はGitと使い分けている。例えばGitで開発して、ある程度まとまったらsvnに突っ込んだり、svnに入ってるソースをgit-svnで取り出して開発したりといった感じ。なので、Subversion使用歴は10年以上、Git使用歴は5年程度である。
SVN脳
SubversionとGitの違いとしてよくあげられるのは「分散型かどうか」ということだが、実はSVN脳患者にとって分散リポジトリの概念の習得は難しくない。とりあえず「ローカルにもリモートにもリポジトリがあり、必要に応じてローカルの修正をリモートにコミットできる」3と認識し、それで当面問題は発生しない。
しかしJoelが言うように、SubversionとGitの最大の違いは分散型かどうかではなく、管理する対象が「スナップショット」か、それとも「チェンジセット」かということにある。Subversionは管理する対象はスナップショットである。
この違いが本質的であると認識するのは難しい。特に、Subversionを使ったことが無い人にとって、「コミットはスナップショットを保存する行為である」と思っていることが、Gitの学習にどのような弊害を及ぼすか想像が難しいと思われる。以下、いくつか例を挙げる。
ブランチの削除が怖い
最初に、SVN脳患者はコミットをどう思っているか書いておきたい。ちょっと古いタイプの研究室のウェブサイトに、「定期的にソースのバックアップを取りましょう」みたいなことが書いてあったりする。で、その例として、こんなコマンドが書いてあったりする。
$ tar cvzf `date +%Y%m%d`.tar.gz src
これはsrc
ディレクトリを日付.tar.gzという名前で固めて保存するものである。これは、コマンドを実行した時のソースツリーのスナップショットを名前をつけて保存するものであるから、その名前をリビジョン番号にすれば、Subversionのコミットそのものとなる。つまり、SVN脳患者にとってリポジトリへのコミットとは、こういう「バックアップ」のイメージに近い。
さて、Subversionにとってブランチはディレクトリにすぎない。したがって、SVN脳患者にとって、「ブランチを切る」のと「ディレクトリを掘る」のは等価である。さて、「ディレクトリを掘る」というのはファイル操作であるから、バージョン管理対象となる。あとでディレクトリを削除した場合も、その履歴が残る。したがって、SVN脳患者はマージしたかどうかにかかわらず、気軽にブランチを消す。後で問題があれば復活させれば良いからだ。
しかし、Gitはブランチはディレクトリではなく、コミットの別名である。したがって、ブランチの作成、削除はバージョン管理対象ではない。また、Gitではコミットとは修正パッチであり、ブランチとは、ある目的のために蓄積された修正パッチの集合体である。なので、マージされていないブランチを削除するのは、その目的のための一連のパッチを使わないことを決断した、ということになる。以上から、Gitはマージされていないブランチを削除すると、それに連なる一連のコミットは「どこからも参照されていない修正パッチ」であるから、ガベージコレクトの対象となる。
SVN脳患者が初めてブランチを切ってみて、その後masterに戻ってブランチを削除しようとした時、「マージされていないブランチを消そうとしているよ。本当に消したいなら-Dつけて無理やり消しな」と言われて驚く。その後いろいろ調べてみて「マージされていないブランチを削除すると、関連するコミットがガーベジコレクトの対象となる」ということを知る。
「コミットが永遠に失われるだって!なんてこった!」
無論、相当長いこと使わなければガベージコレクションは走らないだろうし、なんか問題があってもgit reflog
でなんとかなる。それはわかっている。わかっているのではあるが、SVN脳患者にとってコミットとはバックアップであり、コミットが永遠に失われる可能性があるというのは相当な恐怖なのである。
マージを面倒臭がる
原則としてSVN脳患者はマージしない。多くの場合マージは面倒で、うまくいかず、コンフリクトしまくるからだ。SVN脳患者はブランチを切ってそこで作業しても、その修正をtrunkに取り込むのは手動でやる。Subversionにとって、そしてSVN脳患者にとってマージとはスナップショット同士の比較であり、2つのディレクトリのdiffをとって、そのdiffをもとにブランチで行った修正を抽出して取り込む作業であり、それを自動で一度にやると問題が多いから、手動で少しずつやるほうが安全だと思ってる。
Gitにとってコミットとはパッチ作成であり、マージとはたまったパッチをまとめて適用する作業である。これはSubversionのマージに比べてコンフリクトが圧倒的に少ない。この、「Subversionに比べてGitはマージが簡単」というのが一番わかり易くでるのは、「ファイル名の変更を追えるかどうか」だと思う。
以下のようなことを考える。
-
master
(もしくはtrunk
)でtest.txtを作成する - 新たなブランチ、
branch
を切る。 -
branch
でtest.txtを修正する -
master
でtest.txtをtest2.txtにリネームする。 -
master
からbranch
をマージする。
上記の作業をSubversionでやってみよう。
svnadmin create myrep
export REPO=file://`pwd`/myrep
svn checkout $REPO merge_svn
cd merge_svn
mkdir trunk;
svn add trunk
cd trunk
echo "Foo" > test.txt
svn add test.txt
svn ci -m "initial commit"
cd ..
svn copy trunk branch
svn ci -m "adds branch"
cd branch
echo "Bar" >> test.txt
svn ci -m "adds Bar"
cd ../trunk
svn mv test.txt test2.txt
svn ci -m "renames test.txt to test2.txt"
途中で$REPO
にリポジトリへのパスを設定している。さて、ここまでの状態で、master
ではファイルのリネームが、branch
ではファイルの修正が行われた状態になっている。この状態でtrunk
からbranch
をマージしてみよう。以下のようなエラーがおきる。
$ svn merge $REPO/branch
svn: E195020: Cannot merge into mixed-revision working copy [1:4]; try updating first
Subversionでは、リビジョン番号にソースツリーがまるまる関連付けられているが、作業コピーは、部分的に別々のリビジョン番号になっている。これは、リビジョン番号がソースツリー全体に振られていることに関連している。Gitにおいて、あるブランチにおけるコミットは別のブランチでは全く関係ないが、Subversionではどのブランチであろうと、どこかで何か修正があった場合には、修正がなかったブランチのファイルのリビジョン番号も全て上がる。しかし、コミットのたびに全ファイルのリビジョン番号を上げるのは無駄なので、必要なときに全体を揃える。この「全体のリビジョン番号を揃える」のがsvn update
である。やってみよう。
$ svn update
Updating '.':
At revision 4.
最新のリビジョンであるリビジョン4になった。この状態で改めてマージを試みる。
$ svn merge --dry-run $REPO/branch
--- Merging r2 through r4 into '.':
C test.txt
Summary of conflicts:
Tree conflicts: 1
Subversionにおけるマージは、単に異なるディレクトリにあるファイルのdiffを取り、可能ならマージするというものである。したがってbranch/test.txt
とtrunk/test2.txt
がもともと同じものであったことが認識できず、コンフリクトする。
また、--dry-run
は、実際にマージせず、マージしたら何がおきるかを見るためのオプションである。SVN脳患者にとってマージはおおごとなので、実行前に何がおきるかチェックする癖が付いている。
全く同様な作業をGitでやってみよう。
mkdir merge_git
cd merge_git
git init .
echo "Foo" > test.txt
git add test.txt
git ci -m "initial commit"
git co -b branch
echo "Bar" >> test.txt
git ci -m "adds Bar"
git co master
git mv test.txt test2.txt
git ci -m "renames test.txt to test2.txt"
これでSubversionにおけるマージ前の状態になった。Git使いは、ここでなんのためらいもなくmergeを叩く。
$ git merge branch -m "merges branch"
Auto-merging test2.txt
Merge made by the 'recursive' strategy.
test2.txt | 1 +
1 file changed, 1 insertion(+)
そして問題なくマージされる。
rebaseが理解できない
SVN脳患者はgit cherry-pick
が理解できない。SVN脳患者はコミットを「スナップショット」だと思っており、パッチは「異なるスナップショット間の差分」だと思っている。したがって、「変更」を指定するには2つのスナップショットを指定しなければならない。そういう認識なので、git cherry-pick
が一つのコミットを選べることに混乱する。
特定のコミットの修正を取り込む?なんだそりゃ?
同様な理由で、SVN脳患者はrebase
を理解することができない。Gitにおいてコミットとは修正パッチであり、rebaseは、その修正パッチをまとめたり、不要なパッチを無視したりすることなのだが、SVN脳患者にとってはコミットはスナップショットなので、「複数のコミットをまとめる」というセンテンスが意味をなさない。
それで「git rebase -i
におけるsquash
とは、途中の不要なコミットを単に削除することだ」と思ったりする。そうするとコミットの順番を並び替えたりできることの説明がつかず、結局「rebaseはわからん」と匙を投げることになる。
まとめ
SVN脳患者(=僕)にとって、なぜGitが理解しづらかったのかをまとめてみた。根本要因は、VCSが管理する対象が「スナップショット」なのか、それとも「チェンジセット」なのかに起因する。分散バージョン管理で間違いないって、ベイビーを読む限り、JoelもSVN脳患者であったと思われる4。
SVN脳患者にとって「Gitにおけるコミットはチェンジセットである」、もっと言えば「コミットがチェンジセットであることが本質である」と認識するのは難しい。僕自身が先のJoelの文章を読んだのは5年以上前だが、その時点では自分がSVN脳患者であることに気づかず、したがってこの文章の意味を理解できていなかった。
これはあくまで私見だが、SVN脳を発症した患者に
「GitやMercurialが管理するのはチェンジセットで、Subversionが管理するのはツリーで、全然違うものだ。Subversion公式FAQにもその違いについて触れてあるよ」
とか言っても無駄だと思う。Gitを使い慣れていないとその違いが決定的なものだということは気づかないからだ。
SVN脳を発症したことが無い人にとって、なぜSVN脳患者がGitを使えないのか、Gitに恐怖に近い感情を抱くのかは理解が難しいのだと思う。これは、その国で生まれ育った人がその国の文化をなんの疑問もなく受け入れられるのに対し、海外で生まれ育った人が途中から別の国に引っ越した時にカルチャーショックを受けるのに似ている。SVN脳の治療には時間がかかり、何より患者本人の自覚が必要である。身近に患者がいたら「この中年プログラマ使えねぇ」とか思わず、是非温かい目で見守ってほしい。
追記(2/1)
なんかわりとはてブがついたので、ちょっとフォローというか。
とりあえず「わかる」という人と「わからん」という人と両方いるみたいですね。特にちょっと古いタイプの人はコミットをバックアップ気分でやるので困る、という意見がちらほら。これ、どうやってSubversionに入ったかによるんだけど、昔、まだバージョン管理システムを使っていないときに、たとえば職場の先輩から
「バックアップのつもりでSubversion使おうね。tar cvzfだとローカルマシンのディスク飛んだらおしまいでしょ?でもsvnならコミットすればサーバにデータが全部残るから、より安全だよ。こっちでリポジトリ用意しておくから、後はここにコミットしてね」
みたいに言われて使い始めた場合、「コミット=バックアップ」という概念で固まっちゃうのかなぁ、とか思った。っていうか僕が後輩にそういう勧め方をしていました。すみません。
あと多かったのは「Subversionがスナップショット、Gitが差分を管理しているのは逆じゃね?」というもの。まず、Subversionがコミットを差分でやってるってのは、たぶん大体のSubversion使いが認識していることだと思う。っていうか、たぶん日本人のプログラマならsvn
を「差分」って呼ぶでしょ?svn ci
をたたくとき、心の中で「差分コミット!」って叫ぶよね?ひょっとして僕だけ?
まぁとりあえず、コミットが差分単位なのは良いとして、リビジョン番号にひも付けられたものはスナップショットだと思ってる。それはSubversion公式FAQにもあるとおり。
で、Gitがコミットオブジェクトとして実際何を管理しているかというと、とりあえずこういうものなんだけれど、これをたとえばgit cherry-pick
する際に「チェンジセット」としてとらえてよいと思ってたら、コメントで「Gitは状態を保存するのであって変更を保存するのではないからチェンジセットという言葉はGitで使うな」と公式に書いてあることを教えていただいた。もともとJoelが「チェンジセット」という言葉を使っていたのはMercurialを使っていたからで、そっちやBitkeeperは「チェンジセット」を保存するという気持ちになっているはず。そんなわけで、MercurialとGitをごっちゃにしてました。すみません。
もうひとつ、マージの問題だけど、僕がSubversionを複数人で使っていたのは、まだ後ろがBerkeley DBだったころで5、少なくともそのころはSubversionにおけるマージってのはとても大変な作業だったんですよ。今は違うんですかね?で、「Subversionではマージが大変」という認識の人に「Gitだとわりとうまくいくよ」ということを見せるのに一番手っ取り早い例が、先のファイル名の変更を追いかけるという奴なんだけど、まぁこれはSubversionとGitのマージの違いを見せる、という例としてはあまりよくないかなぁ、という気もしています。なんかほかに良い例とかないですかね?
SubversionとGitのどちらが良いとかどう違うかについては何もいうつもりはないんだけど、でもおそらく「Subversionを長く使っていると、Gitの習得が難しくなる」というのは多くの人の同意を得られるんじゃないかなぁ。で、僕の場合は「コミットをパッチとみなすことができる」という点が一番ひっかかりポイントだったよ、ということです。
あ、そうそうもうひとつ。Subversionではブランチ=タグ=ディレクトリなんだけど、これは本当にGitのブランチを理解する弊害になるよね・・・。ブランチを単にディレクトリだと思ってると、たとえばブランチの作成、削除がバージョン管理の対象とならなかったりとか、「ブランチをpushする」とかが理解できなくなる(Subversionではどこにどんなブランチがあろうが、それはリモートに全部保存されるから)。でVCSとして最初からGitから入って、普通にGitHubとかでプルリクをどうこうしたりしながら育った人と話した時、「なぜ僕がブランチを理解できていないか」が理解できなかったみたいなんですよ。で、今なら「なぜその人が僕を理解できなかったか」がわかる気がするんです。同じ「ブランチ」という言葉を使ってはいるんだけど、お互いまったく別のものをイメージしているから話がかみあわなかったんだな、って。
そんなわけで、このポエムを書いた趣旨は「SVNに染まってGitがなかなか理解できない or GitをSVNのように使ってしまう」ようなSVN脳患者(=俺)が職場にいたときには、どうかやさしくしてあげてください、と、まぁそういうことです。