ここ最近,SubversionからGitへの移行を考えています.TortoiseGitは,すでに複数のWindows PCに入れました.研究室の学生に使わせるかどうかは,分かりません.むしろ,後期の演習科目で,「svnかgitを使ってファイルを管理すること」を要請しようと考えていて*1,最低限のコマンド(svn,gitに与える引数)とその効果について,自習しないといけないなと思っていたのでした.
動作確認の方法として,コマンドを順に並べて,当雑記で解説するのが手っ取り早いのですが,受講生がそのエントリしか見ない(バージョン管理の意義を学ばずに,使い方だけを知る)というのはよろしくないので,何か工夫をしたいなあと考えていたところに,RubyのTest::Unit*2を思い出しました.
Test::Unitは,通常,テスト対象のRubyスクリプトがあって(もしくはtest-driven developmentだと,コードを付け加えていきながら),それに対するテストを書きます.なのですが,Rubyでsvnやgitのコマンドに相当するアクセスをしたいという意図ではなく*3,関心があるのはsvnやgitのコマンドのほうなので,テスト対象のRubyスクリプトを作らず,テストのコードだけを書きました.
論よりcodeです:
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # svn-test.rb require "test/unit" require "fileutils" class SvnTest < Test::Unit::TestCase #### setup & teardown #### def setup ENV["LC_ALL"] = "C" @option_fullpath = false @option_print_command_only = false if @option_fullpath @command_svn = "/usr/bin/svn" @command_svnadmin = "/usr/bin/svnadmin" else @command_svn = "svn" @command_svnadmin = "svnadmin" end @dir = { :work => "svn-work", # :work2 => "svn-work2", :repo => "svn-repository" } @url = "file://" + File.expand_path(@dir[:repo]) clear_directory end def teardown return if $DEBUG clear_directory end #### auxiliary methods #### def rm_r(dir) method = nil case method when 1 print_prompt if FileUtils.methods.include?(:remove_entry_secure) print_prompt puts "rm -Rf #{dir}" unless @option_print_command_only FileUtils.remove_entry_secure(dir) else FileUtils.rm_r(dir, :verbose => true) end when 2 my_system "rm -Rf #{dir}" else print_prompt FileUtils.rm_r(dir, :verbose => true) end end def clear_directory @dir.each_value do |d| if test(?d, d) rm_r(d) end end end def print_prompt print "> " end def my_system(command) if /^svnadmin / =~ command command["svnadmin"] = @command_svnadmin elsif /^svn / =~ command command["svn"] = @command_svn end print_prompt puts command if @option_print_command_only command += ">/dev/null 2>&1" end system command end def my_exec(command) if /^svnadmin / =~ command command["svnadmin"] = @command_svnadmin elsif /^svn / =~ command command["svn"] = @command_svn end print_prompt puts command result = `#{command}` puts result if !result.empty? && !@option_print_command_only result end def create_workfile(filename, num = -1) open(filename, "w") do |f_out| 1.upto(10) do |i| if i >= num f_out.puts "No. #{i}" else f_out.puts "#{i}行目" end end end end #### test methods #### def test_svn1 txt = "test.txt" # create my_system "svnadmin create #{@dir[:repo]}" assert(test(?d, @dir[:repo])) assert(test(?f, File.join(@dir[:repo], "README.txt"))) # checkout my_system "svn checkout #{@url} #{@dir[:work]}" assert(test(?d, @dir[:work])) assert(test(?d, File.join(@dir[:work], ".svn"))) print_prompt FileUtils.cd(@dir[:work], :verbose => true) do # add file create_workfile(txt) my_system "cat #{txt}" result = my_exec("svn status") assert(/^\?\s*#{txt}/ =~ result) assert(/^A\s*#{txt}/ !~ result) my_system "svn add #{txt}" result = my_exec("svn status") assert(/^A\s*#{txt}/ =~ result) assert(/^\?\s*#{txt}/ !~ result) # first commit my_system "svn commit -m \"first commit\"" result = my_exec("svn status") assert(result.index(txt) == nil) # modify file repeatedly 1.upto(10) do |i| create_workfile(txt, i) my_system "cat #{txt}" if i == 1 result = my_exec("svn status") assert(result.empty?) next end if i == 5 result = my_exec("svn status") assert(/^M\s*#{txt}/ =~ result) end my_system "svn commit -m \"#{txt}: a bit changed\"" end # diff (working copy) result = my_exec("svn diff -r 3:5 #{txt}") assert(/^-.*revision 3/ =~ result) assert(/^\+.*revision 5/ =~ result) assert(result.index("-No. 3") != nil) assert(result.index("+4行目") != nil) assert(result.index("+5行目") == nil) # diff (repository) result2 = my_exec("svn diff -r 3:5 #{@url}/#{txt}") assert(result2 == result) print_prompt end end end
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # git-test.rb require "test/unit" require "fileutils" class GitTest < Test::Unit::TestCase #### setup & teardown #### def setup ENV["LC_ALL"] = "C" @option_fullpath = false @option_print_command_only = false if @option_fullpath @command_git = "/usr/bin/git" else @command_git = "git" end @dir = { :work => "git-work", :work2 => "git-work2", :repo => "git-repository.git" } @url = "file://" + File.expand_path(@dir[:repo]) clear_directory end def teardown return if $DEBUG clear_directory end #### auxiliary methods #### def rm_r(dir) method = 2 case method when 1 print_prompt if FileUtils.methods.include?(:remove_entry_secure) print_prompt puts "rm -Rf #{dir}" unless @option_print_command_only FileUtils.remove_entry_secure(dir) else FileUtils.rm_r(dir, :verbose => true) end when 2 my_system "rm -Rf #{dir}" else print_prompt FileUtils.rm_r(dir, :verbose => true) end end def clear_directory @dir.each_value do |d| if test(?d, d) rm_r(d) end end end def print_prompt print "> " end def my_system(command) if /^git / =~ command command["git"] = @command_git end print_prompt puts command if @option_print_command_only command += ">/dev/null 2>&1" end system command end def my_exec(command) if /^git / =~ command command["git"] = @command_git end print_prompt puts command result = `#{command}` puts result if !result.empty? && !@option_print_command_only result end def create_workfile(filename, num = -1) open(filename, "w") do |f_out| 1.upto(10) do |i| if i >= num f_out.puts "No. #{i}" else f_out.puts "#{i}行目" end end end end #### test methods #### def test_git1 txt = "test.txt" # create my_system "git init #{@dir[:work]}" assert(test(?d, @dir[:work])) assert(test(?d, File.join(@dir[:work], ".git"))) print_prompt FileUtils.cd(@dir[:work], :verbose => true) do # add file create_workfile(txt) my_system "cat #{txt}" result = my_exec("git status") assert(/^\#\s*#{txt}/ =~ result) my_system "git add #{txt}" result = my_exec("git status") assert(/^\#\s*new file:\s*#{txt}/ =~ result) # first commit my_system "git commit -m \"first commit\"" result = my_exec("git status") assert(result.index(txt) == nil) # modify file repeatedly commit3 = "" commit5 = "" 1.upto(10) do |i| create_workfile(txt, i) my_system "cat #{txt}" if i == 1 result = my_exec("git status") assert(result.index(txt) == nil) next end my_system "git commit -a -m \"#{txt}: a bit changed\"" if i == 3 result = my_exec("git log -1 | head -n 1") commit3 = result.strip.sub(/^commit /, "") elsif i == 5 result = my_exec("git log -1 | head -n 1") commit5 = result.strip.sub(/^commit /, "") end end # diff result = my_exec("git diff #{commit3}..#{commit5}") assert(result.index("-No. 3") != nil) assert(result.index("+4行目") != nil) assert(result.index("+5行目") == nil) result2 = my_exec("git diff #{commit3[0, 7]}..#{commit5[0, 7]}") assert(result2 == result) print_prompt end end def test_git2 txt = "test.txt" # create my_system "git init --bare #{@dir[:repo]}" assert(test(?d, @dir[:repo])) assert(test(?f, File.join(@dir[:repo], "config"))) assert(!test(?d, File.join(@dir[:repo], ".git"))) # clone my_system "git clone #{@url} #{@dir[:work]}" assert(test(?d, @dir[:work])) assert(test(?d, File.join(@dir[:work], ".git"))) commit3 = "" commit5 = "" diff35 = "" print_prompt FileUtils.cd(@dir[:work], :verbose => true) do # add file create_workfile(txt) my_system "cat #{txt}" result = my_exec("git status") assert(/^\#\s*#{txt}/ =~ result) my_system "git add #{txt}" result = my_exec("git status") assert(/^\#\s*new file:\s*#{txt}/ =~ result) # first commit my_system "git commit -m \"first commit\"" result = my_exec("git status") assert(result.index(txt) == nil) # modify file repeatedly 1.upto(10) do |i| create_workfile(txt, i) my_system "cat #{txt}" if i == 1 result = my_exec("git status") assert(result.index(txt) == nil) next end my_system "git commit -a -m \"#{txt}: a bit changed\"" if i == 3 result = my_exec("git log -1 | head -n 1") commit3 = result.strip.sub(/^commit /, "") elsif i == 5 result = my_exec("git log -1 | head -n 1") commit5 = result.strip.sub(/^commit /, "") end end # push my_system("git push origin master") # diff diff35 = my_exec("git diff #{commit3}..#{commit5}") assert(diff35.index("-No. 3") != nil) assert(diff35.index("+4行目") != nil) assert(diff35.index("+5行目") == nil) result = my_exec("git diff #{commit3[0, 7]}..#{commit5[0, 7]}") assert(result == diff35) print_prompt end # clone one more my_system "git clone #{@url} #{@dir[:work2]}" assert(test(?d, @dir[:work2])) assert(test(?d, File.join(@dir[:work2], ".git"))) print_prompt FileUtils.cd(@dir[:work2], :verbose => true) do result = my_exec("git diff #{commit3[0, 7]}..#{commit5[0, 7]}") assert(result == diff35) print_prompt end end end
ruby svn-test.rbそれからruby git-test.rbで動きます.前者は1本の,後者は2本のテストをします.「1本のテスト」というのは,リポジトリを作って,作業コピー(作業ツリー)を作って,その中にtest.txtを作ってコミットして,その中身を何回書き換えてその都度コミットして,gitの2本目のテストではpushをして,コミットした中で特定の2つの時点についてその差分を求める,という流れです.どのテストでも,リポジトリと作業コピー/ツリーはカレントディレクトリの直下に作られ,テスト終了時には消されます*4.git-test.rbの2本のテストのうち,test_git1はローカルなリポジトリ(git init),test_git2は公開リポジトリ(git init --bare)*5に対するテストです.
ディレクトリ削除の方法として,FileUtils.#rm_rではセキュリティ上の問題があるということで*6,FileUtils.#remove_entry_secureを試してみると,このメソッドには:verbose => trueをつけられないのに気づいて少々げんなりです.まあコードを残しています.
以下の値を変更すれば,動作が少し変わるようになっています.
- setupの中の@option_fullpathをfalseからtrueに変更すれば,コマンドはフルパスで実行します.フルパスは,直後の@command_svn, @command_svnadmin, @command_gitで任意に変更可能です.
- setupの中の@option_print_command_onlyをfalseからtrueに変更すれば,実行するコマンドだけを出力する(コマンドの実行結果は表示しない)ようにします.
- setupの中で,@urlの値を変更すれば,リポジトリURLも変わります.SSHでのアクセスも可能に…なればいいのですが,通信のたびにパスワードを要求されないよう,事前に認証の設定をしておく必要があるほか,各テストで,リポジトリ初期化のコードを実行しないよう,コードに手を加えないといけませんね.
- rm_rの中のローカル変数methodは,どの方法でディレクトリを削除するかを決めます.svn-test.rbとgit-test.rbでデフォルトの値が違うのは,git-test.rbをCygwinで実行したときに苦労させられたからです.
動作はCygwinとLinux(Vine, Ubuntu)のコマンドラインで確認しました.Cygwinではしばしば,git-work, git-work2のディレクトリ削除に失敗します.またCygwinでは,毎回のコマンド実行で時間がかかり,面白みがないのですが,Linuxで実行し比べると,gitのテストのほうが高速に処理できていることが見てとれまして,ほんの小さなテストながらも,毎回リポジトリに送らないことの時間的なメリットを体感できます.
*1:コマンドが使えるのは,確認しています.バージョン管理システムを使わない場合に,どのように減点するかについては,まだ検討中です.
*3:gritで,各メソッドに何かオプションを与えれば,gitでのコマンドが出力される,というのなら,積極的に使用したいのですが.
*4:rubyに-dオプションをつけて実行すると,$DEBUGが真になり,リポジトリと作業コピー/ツリーが消されないようになります.ただし,git-test.rbの1本目のテストで作ったディレクトリは,2本目のテストの開始時に削除されます.1本目のテストを実施し,ディレクトリを残したいというときは,ruby -d git-test.rb --name=test_git1を実行します.
*5:Subversionからの移行も想定しています.すなわち,リポジトリと作業ツリーを別ディレクトリで管理するということです.