git/GitHubを便利に使うHubを少し読む
git/GitHubを便利に使うRuby製ツールのコマンドラインツール「Hub」のソースコードを少し読んだ。コメントが非常に丁寧だし、小さなツールなので、全体を把握しやすい。いろいろ勉強になる。あれこれランダムにメモしてみる。
featuresの下にあるcucumberのテストケースがあまりにも英文でビビった。CLIツールでもうまく書けば、このぐらい分かりやすいテストが書けるのか。例えば、features/clone.feature にある例は、
Feature: hub clone Scenario: Clone a public repo When I successfully run `hub clone rtomayko/ronn` Then it should clone "git://github.com/rtomayko/ronn.git" And there should be no output Scenario: Clone a public repo with period in name When I successfully run `hub clone hookio/hook.js` Then it should clone "git://github.com/hookio/hook.js.git" And there should be no output Scenario: Clone a public repo that starts with a period When I successfully run `hub clone zhuangya/.vim` Then it should clone "git://github.com/zhuangya/.vim.git" And there should be no output Scenario: Clone a public repo with HTTPS Given HTTPS is preferred When I successfully run `hub clone rtomayko/ronn` Then it should clone "https://github.com/rtomayko/ronn.git" And there should be no output
という感じ。ちょっとすごすぎるな。HTTPS is preferred ってどうなってるんやとsteps.rbを見たら、
features/steps.rb: Given /^HTTPS is preferred$/ do run_silent %(git config --global hub.protocol https) end
となっている。なるほどなぁ。こういうツールでcucumberを使うのは少数派という印象も受けてるけど、こうやって取扱説明書のような感じでテストケースを書いていけるのはいいな。
hubは一般的なRubyのライブラリ同様、lib/hub.rbでモジュールを読み込む構成になっている。
├── [ 136] lib │ ├── [ 374] hub │ │ ├── [ 2856] args.rb │ │ ├── [ 34064] commands.rb │ │ ├── [ 12704] context.rb │ │ ├── [ 11883] github_api.rb │ │ ├── [ 3023] json.rb │ │ ├── [ 1937] runner.rb │ │ ├── [ 2320] ssh_config.rb │ │ ├── [ 1380] standalone.rb │ │ └── [ 46] version.rb
args.rbはARGVをラップして、使いやすくするモジュール。Hub::ArgsはArrayを継承していて、コマンドとして与えられている文字列が、フラグかコマンドか、実行可能コマンドかどうかなんかを判別するロジックを持っている。Hub::Args#before/afterというフックも用意してあって、これは@chainに保存してある。
lib/hub/commands.rbにあるHub::Commandsが、gitをラップする本体で、この冒頭に全体の流れが書いてある。
# 1. hub is invoked from the command line: # $ hub clone rtomayko/tilt # # 2. The Runner class is initialized: # >> Hub::Runner.new('clone', 'rtomayko/tilt') # # 3. The method representing the git subcommand is executed with the # full args: # >> Hub::Commands.clone(['clone', 'rtomayko/tilt']) # # 4. That method rewrites the args as it sees fit: # >> args[1] = "git://github.com/" + args[1] + ".git" # => "git://github.com/rtomayko/tilt.git" # # 5. The new args are used to run `git`: # >> exec "git", "clone", "git://github.com/rtomayko/tilt.git" # # An optional `after` callback can be set. If so, it is run after # step 5 (which then performs a `system` call rather than an # `exec`). See `Hub::Args` for more information on the `after` callback.
hub/bin/hub は、
#!/usr/bin/env ruby # hub(1) # alias git=hub require 'hub' Hub::Runner.execute(*ARGV)
で、Runnerは何をするかというと、Argsや用意してCommandモジュールにあるとおり、gitコマンドを実行する。
module Hub class Runner def initialize(*args) @args = Args.new(args) Commands.run(@args) end # Shortcut def self.execute(*args) new(*args).execute end
Rubyではクラスメソッドからインスタンスメソッドを呼ぶことができるので、Runner.executeのように、クラスをインスタンス化してそのまま同名のメソッドを起動するというのはイディオムのようだ。すると、@argsに必要なコマンドが初期化されて、Commands.runが呼ばれる。
Commandsはモジュールとして定義されていて、すべてのメソッドはクラスメソッドとして定義されている。
module Hub module Commands # We are a blank slate. instance_methods.each { |m| undef_method(m) unless m =~ /(^__|send|to\?$)/ } extend self def run(args) def pull_request(args) def clone(args) def submodule(args) def remote(args) def fetch(args) def checkout(args) def merge(args) def cherry_pick(args) def am(args) alias_method :apply, :am def init(args) def fork(args) def create(args) def push(args) def browse(args) : :
Commandsモジュールは単なるメソッドの置き場となるコンテキストの役割りを果たしているだけなので、Objectにある余計なメソッドを undef している。Rubyでは何でもオブジェクト。ModuleはObjectを継承したクラスなので、Commandsモジュールには、clone、dup、class、freezeなどのインスタンスメソッドが最初から含まれている。
こういうテクニックは「ブランクスレート」(何も書かれていない白板)と呼ばれ、イディオムになっているので、Ruby 1.9からはBasicObjectがそれを提供するのようになっている。最近は、1.9で導入された処理系の機能を使う場合、バージョン判定をしてネイティブがあればそれを、そうでなければその場で定義してしまうというコードを良く見る。BasicObjectを使えばいいのに。好みの問題なのだろうか。
実際にgitのサブコマンドとObjectのインスタンスメソッドは名前の衝突があるのだろうかと思って、
module Commands puts "#{instance_methods}" instance_methods.each do |m| unless m =~ /(^__|send|to\?$)/ puts "undef: #{m}" undef_method(m) end end end
と書いて実行してみて気が付いた。このコードは何も出力しない。そうだ、moduleなんだからインスタンスメソッドなんてないのだ。試しに1行目のmoduleをclassに変えると、たくさんメソッド定義が削除される様子が分かる。
あれ? ということは、module Commandsというのは、以前に class Commands だったのだろうか? ブランクスレートというのは消し忘れか何かか? と思って git の歴史を見てみたけど、そうでもない。試しに、blank slateの1行をコメントアウトしてみたら、ちゃんとテストが通る。むむむ? もしかして勘違い? Hub作者のdefunktさんって、GitHubの共同創業者のスターエンジニアだし、どっちかいうとぼくの勘違いの可能性が高そうだけど。
ともあれ、Commandsに定義されているコマンドは、例えば以下の通り。git cloneに相当するコマンドの定義は:
def clone(args) ssh = args.delete('-p') has_values = /^(--(upload-pack|template|depth|origin|branch|reference)|-[ubo])$/ idx = 1 while idx < args.length arg = args[idx] if arg.index('-') == 0 idx += 1 if arg =~ has_values else # $ hub clone rtomayko/tilt # $ hub clone tilt if arg =~ NAME_WITH_OWNER_RE and !File.directory?(arg) name, owner = arg, nil owner, name = name.split('/', 2) if name.index('/') project = github_project(name, owner || github_user) ssh ||= args[0] != 'submodule' && project.owner == github_user(project.host) { } args[idx] = project.git_url(:private => ssh, :https => https_protocol?) end break end idx += 1 end end
ほかのhubのコードに比べると、妙に泥臭いような感じもある。ssh = args.delete('-p') のような Array#delete 使い方がなるほどなと思った。
Commandsに定義されているコマンドのメソッドは、Commands.runから起動される。Argsオブジェクトに入ってるコマンド名や、コマンドのエイリアスを展開して、send(cmd, args) として対応コマンドを呼び出している。
def run(args) : cmd = args[0] if expanded_args = expand_alias(cmd) cmd = expanded_args[0] expanded_args.concat args[1..-1] end # git commands can have dashes cmd = cmd.gsub(/(\w)-/, '\1_') if method_defined?(cmd) and cmd != 'run' args.replace expanded_args if expanded_args send(cmd, args) end : : end
というのが初期化とコマンド実行の流れ。
うまいこと処理の流れを作ってコードをまとめてるなぁと思う。これぐらいの構造をスラスラ書けると楽しいだろうなと思うんだけど、それはそれでちょっと勘違いなのかも。gitでfirst commitを引っ張りだして眺めてみたら、Hubは最初はわずか60行のスクリプトからスタートしてるのだな。オーガニックにコードが育ってきたってことだろうか。今までやったことないけど、gitを使って、コミットを過去から現在に渡って再現してみようかな。