comparison lib/redmine/scm/adapters/mercurial_adapter.rb @ 3:7c48bad7d85d yuya

* Import Mercurial overhaul patches from Yuya Nishihara (see http://www.redmine.org/issues/4455)
author Chris Cannam
date Wed, 28 Jul 2010 12:40:01 +0100
parents 513646585e45
children ed5f37ea0d06
comparison
equal deleted inserted replaced
2:b940d200fbd2 3:7c48bad7d85d
14 # You should have received a copy of the GNU General Public License 14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software 15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 require 'redmine/scm/adapters/abstract_adapter' 18 require 'redmine/scm/adapters/abstract_adapter'
19 require 'rexml/document'
19 20
20 module Redmine 21 module Redmine
21 module Scm 22 module Scm
22 module Adapters 23 module Adapters
23 class MercurialAdapter < AbstractAdapter 24 class MercurialAdapter < AbstractAdapter
24 25
25 # Mercurial executable name 26 # Mercurial executable name
26 HG_BIN = "hg" 27 HG_BIN = "hg"
28 HG_HELPER_EXT = "#{RAILS_ROOT}/extra/mercurial/redminehelper.py"
27 TEMPLATES_DIR = File.dirname(__FILE__) + "/mercurial" 29 TEMPLATES_DIR = File.dirname(__FILE__) + "/mercurial"
28 TEMPLATE_NAME = "hg-template" 30 TEMPLATE_NAME = "hg-template"
29 TEMPLATE_EXTENSION = "tmpl" 31 TEMPLATE_EXTENSION = "tmpl"
30 32
33 # raised if hg command exited with error, e.g. unknown revision.
34 class HgCommandAborted < CommandFailed; end
35
31 class << self 36 class << self
32 def client_version 37 def client_version
33 @@client_version ||= (hgversion || []) 38 @client_version ||= hgversion
34 end 39 end
35 40
36 def hgversion 41 def hgversion
37 # The hg version is expressed either as a 42 # The hg version is expressed either as a
38 # release number (eg 0.9.5 or 1.0) or as a revision 43 # release number (eg 0.9.5 or 1.0) or as a revision
39 # id composed of 12 hexa characters. 44 # id composed of 12 hexa characters.
40 theversion = hgversion_from_command_line 45 hgversion_str.to_s.split('.').map { |e| e.to_i }
41 if theversion.match(/^\d+(\.\d+)+/) 46 end
42 theversion.split(".").collect(&:to_i) 47 private :hgversion
43 end 48
44 end 49 def hgversion_str
45 50 shellout("#{HG_BIN} --version") { |io| io.gets }.to_s[/\d+(\.\d+)+/]
46 def hgversion_from_command_line 51 end
47 %x{#{HG_BIN} --version}.match(/\(version (.*)\)/)[1] 52 private :hgversion_str
48 end
49 53
50 def template_path 54 def template_path
51 @@template_path ||= template_path_for(client_version) 55 template_path_for(client_version)
52 end 56 end
53 57
54 def template_path_for(version) 58 def template_path_for(version)
55 if ((version <=> [0,9,5]) > 0) || version.empty? 59 if ((version <=> [0,9,5]) > 0) || version.empty?
56 ver = "1.0" 60 ver = "1.0"
57 else 61 else
58 ver = "0.9.5" 62 ver = "0.9.5"
59 end 63 end
60 "#{TEMPLATES_DIR}/#{TEMPLATE_NAME}-#{ver}.#{TEMPLATE_EXTENSION}" 64 "#{TEMPLATES_DIR}/#{TEMPLATE_NAME}-#{ver}.#{TEMPLATE_EXTENSION}"
61 end 65 end
66 private :template_path_for
62 end 67 end
63 68
64 def info 69 def info
65 cmd = "#{HG_BIN} -R #{target('')} root" 70 tip = summary['tip'].first
66 root_url = nil 71 Info.new(:root_url => summary['root'].first['path'],
67 shellout(cmd) do |io| 72 :lastrev => Revision.new(:identifier => tip['rev'].to_i,
68 root_url = io.read 73 :revision => tip['rev'],
69 end 74 :scmid => tip['node']))
70 return nil if $? && $?.exitstatus != 0 75 end
71 info = Info.new({:root_url => root_url.chomp, 76
72 :lastrev => revisions(nil,nil,nil,{:limit => 1}).last 77 def tags
73 }) 78 summary['tags'].map { |e| e['name'] }
74 info 79 end
75 rescue CommandFailed 80
76 return nil 81 # Returns map of {'tag' => 'nodeid', ...}
77 end 82 def tagmap
78 83 alist = summary['tags'].map { |e| e.values_at('name', 'node') }
84 Hash[*alist.flatten]
85 end
86
87 def branches
88 summary['branches'].map { |e| e['name'] }
89 end
90
91 # Returns map of {'branch' => 'nodeid', ...}
92 def branchmap
93 alist = summary['branches'].map { |e| e.values_at('name', 'node') }
94 Hash[*alist.flatten]
95 end
96
97 # NOTE: DO NOT IMPLEMENT default_branch !!
98 # It's used as the default revision by RepositoriesController.
99
100 def summary
101 @summary ||= fetchg 'rhsummary'
102 end
103 private :summary
104
79 def entries(path=nil, identifier=nil) 105 def entries(path=nil, identifier=nil)
80 path ||= ''
81 entries = Entries.new 106 entries = Entries.new
82 cmd = "#{HG_BIN} -R #{target('')} --cwd #{target('')} locate" 107 fetched_entries = fetchg('rhentries', '-r', hgrev(identifier),
83 cmd << " -r " + (identifier ? identifier.to_s : "tip") 108 without_leading_slash(path.to_s))
84 cmd << " " + shell_quote("path:#{path}") unless path.empty? 109
85 shellout(cmd) do |io| 110 fetched_entries['dirs'].each do |e|
86 io.each_line do |line| 111 entries << Entry.new(:name => e['name'],
87 # HG uses antislashs as separator on Windows 112 :path => "#{with_trailling_slash(path)}#{e['name']}",
88 line = line.gsub(/\\/, "/") 113 :kind => 'dir')
89 if path.empty? or e = line.gsub!(%r{^#{with_trailling_slash(path)}},'') 114 end
90 e ||= line 115
91 e = e.chomp.split(%r{[\/\\]}) 116 fetched_entries['files'].each do |e|
92 entries << Entry.new({:name => e.first, 117 entries << Entry.new(:name => e['name'],
93 :path => (path.nil? or path.empty? ? e.first : "#{with_trailling_slash(path)}#{e.first}"), 118 :path => "#{with_trailling_slash(path)}#{e['name']}",
94 :kind => (e.size > 1 ? 'dir' : 'file'), 119 :kind => 'file',
95 :lastrev => Revision.new 120 :size => e['size'].to_i,
96 }) unless e.empty? || entries.detect{|entry| entry.name == e.first} 121 :lastrev => Revision.new(:identifier => e['rev'].to_i,
122 :time => Time.at(e['time'].to_i)))
123 end
124
125 entries
126 rescue HgCommandAborted
127 nil # means not found
128 end
129
130 # TODO: is this api necessary?
131 def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
132 revisions = Revisions.new
133 each_revision { |e| revisions << e }
134 revisions
135 end
136
137 # Iterates the revisions by using a template file that
138 # makes Mercurial produce a xml output.
139 def each_revision(path=nil, identifier_from=nil, identifier_to=nil, options={})
140 hg_args = ['log', '--debug', '-C', '--style', self.class.template_path]
141 hg_args << '-r' << "#{hgrev(identifier_from)}:#{hgrev(identifier_to)}"
142 hg_args << '--limit' << options[:limit] if options[:limit]
143 hg_args << without_leading_slash(path) unless path.blank?
144 doc = hg(*hg_args) { |io| REXML::Document.new(io.read) }
145 # TODO: ??? HG doesn't close the XML Document...
146
147 doc.each_element('log/logentry') do |le|
148 cpalist = le.get_elements('paths/path-copied').map do |e|
149 [e.text, e.attributes['copyfrom-path']]
150 end
151 cpmap = Hash[*cpalist.flatten]
152
153 paths = le.get_elements('paths/path').map do |e|
154 {:action => e.attributes['action'], :path => with_leading_slash(e.text),
155 :from_path => (cpmap.member?(e.text) ? with_leading_slash(cpmap[e.text]) : nil),
156 :from_revision => (cpmap.member?(e.text) ? le.attributes['revision'] : nil)}
157 end.sort { |a, b| a[:path] <=> b[:path] }
158
159 yield Revision.new(:identifier => le.attributes['revision'],
160 :revision => le.attributes['revision'],
161 :scmid => le.attributes['node'],
162 :author => (le.elements['author'].text rescue ''),
163 :time => Time.parse(le.elements['date'].text).localtime,
164 :message => le.elements['msg'].text,
165 :paths => paths)
166 end
167 self
168 end
169
170 # Returns list of nodes in the specified branch
171 def nodes_in_branch(branch, path=nil, identifier_from=nil, identifier_to=nil, options={})
172 hg_args = ['log', '--template', '{node|short}\n', '-b', branch]
173 hg_args << '-r' << "#{hgrev(identifier_from)}:#{hgrev(identifier_to)}"
174 hg_args << '--limit' << options[:limit] if options[:limit]
175 hg_args << without_leading_slash(path) unless path.blank?
176 hg(*hg_args) { |io| io.readlines.map { |e| e.chomp } }
177 end
178
179 def diff(path, identifier_from, identifier_to=nil)
180 hg_args = ['diff', '--nodates']
181 if identifier_to
182 hg_args << '-r' << hgrev(identifier_to) << '-r' << hgrev(identifier_from)
183 else
184 hg_args << '-c' << hgrev(identifier_from)
185 end
186 hg_args << without_leading_slash(path) unless path.blank?
187
188 hg *hg_args do |io|
189 io.collect
190 end
191 rescue HgCommandAborted
192 nil # means not found
193 end
194
195 def cat(path, identifier=nil)
196 hg 'cat', '-r', hgrev(identifier), without_leading_slash(path) do |io|
197 io.binmode
198 io.read
199 end
200 rescue HgCommandAborted
201 nil # means not found
202 end
203
204 def annotate(path, identifier=nil)
205 blame = Annotate.new
206 hg 'annotate', '-ncu', '-r', hgrev(identifier), without_leading_slash(path) do |io|
207 io.each do |line|
208 next unless line =~ %r{^([^:]+)\s(\d+)\s([0-9a-f]+):(.*)$}
209 r = Revision.new(:author => $1.strip, :revision => $2, :scmid => $3)
210 blame.add_line($4.rstrip, r)
211 end
212 end
213 blame
214 rescue HgCommandAborted
215 nil # means not found or cannot be annotated
216 end
217
218 # Runs 'hg' command with the given args
219 def hg(*args, &block)
220 full_args = [HG_BIN, '--cwd', url]
221 full_args << '--config' << "extensions.redminehelper=#{HG_HELPER_EXT}"
222 full_args += args
223 ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block)
224 if $? && $?.exitstatus != 0
225 raise HgCommandAborted, "hg exited with non-zero status: #{$?.exitstatus}"
226 end
227 ret
228 end
229 private :hg
230
231 # Runs 'hg' helper, then parses output to return
232 def fetchg(*args)
233 # command output example:
234 # :tip: rev node
235 # 100 abcdef012345
236 # :tags: rev node name
237 # 100 abcdef012345 tip
238 # ...
239 data = Hash.new { |h, k| h[k] = [] }
240 hg(*args) do |io|
241 key, attrs = nil, nil
242 io.each do |line|
243 next if line.chomp.empty?
244 if /^:(\w+): ([\w ]+)/ =~ line
245 key = $1
246 attrs = $2.split(/ /)
247 elsif key
248 alist = attrs.zip(line.chomp.split(/ /, attrs.size))
249 data[key] << Hash[*alist.flatten]
97 end 250 end
98 end 251 end
99 end 252 end
100 return nil if $? && $?.exitstatus != 0 253 data
101 entries.sort_by_name 254 end
102 end 255 private :fetchg
103 256
104 # Fetch the revisions by using a template file that 257 # Returns correct revision identifier
105 # makes Mercurial produce a xml output. 258 def hgrev(identifier)
106 def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={}) 259 identifier.blank? ? 'tip' : identifier.to_s
107 revisions = Revisions.new 260 end
108 cmd = "#{HG_BIN} --debug --encoding utf8 -R #{target('')} log -C --style #{shell_quote self.class.template_path}" 261 private :hgrev
109 if identifier_from && identifier_to
110 cmd << " -r #{identifier_from.to_i}:#{identifier_to.to_i}"
111 elsif identifier_from
112 cmd << " -r #{identifier_from.to_i}:"
113 end
114 cmd << " --limit #{options[:limit].to_i}" if options[:limit]
115 cmd << " #{path}" if path
116 shellout(cmd) do |io|
117 begin
118 # HG doesn't close the XML Document...
119 doc = REXML::Document.new(io.read << "</log>")
120 doc.elements.each("log/logentry") do |logentry|
121 paths = []
122 copies = logentry.get_elements('paths/path-copied')
123 logentry.elements.each("paths/path") do |path|
124 # Detect if the added file is a copy
125 if path.attributes['action'] == 'A' and c = copies.find{ |e| e.text == path.text }
126 from_path = c.attributes['copyfrom-path']
127 from_rev = logentry.attributes['revision']
128 end
129 paths << {:action => path.attributes['action'],
130 :path => "/#{path.text}",
131 :from_path => from_path ? "/#{from_path}" : nil,
132 :from_revision => from_rev ? from_rev : nil
133 }
134 end
135 paths.sort! { |x,y| x[:path] <=> y[:path] }
136
137 revisions << Revision.new({:identifier => logentry.attributes['revision'],
138 :scmid => logentry.attributes['node'],
139 :author => (logentry.elements['author'] ? logentry.elements['author'].text : ""),
140 :time => Time.parse(logentry.elements['date'].text).localtime,
141 :message => logentry.elements['msg'].text,
142 :paths => paths
143 })
144 end
145 rescue
146 logger.debug($!)
147 end
148 end
149 return nil if $? && $?.exitstatus != 0
150 revisions
151 end
152
153 def diff(path, identifier_from, identifier_to=nil)
154 path ||= ''
155 if identifier_to
156 identifier_to = identifier_to.to_i
157 else
158 identifier_to = identifier_from.to_i - 1
159 end
160 cmd = "#{HG_BIN} -R #{target('')} diff -r #{identifier_to} -r #{identifier_from} --nodates"
161 cmd << " -I #{target(path)}" unless path.empty?
162 diff = []
163 shellout(cmd) do |io|
164 io.each_line do |line|
165 diff << line
166 end
167 end
168 return nil if $? && $?.exitstatus != 0
169 diff
170 end
171
172 def cat(path, identifier=nil)
173 cmd = "#{HG_BIN} -R #{target('')} cat"
174 cmd << " -r " + (identifier ? identifier.to_s : "tip")
175 cmd << " #{target(path)}"
176 cat = nil
177 shellout(cmd) do |io|
178 io.binmode
179 cat = io.read
180 end
181 return nil if $? && $?.exitstatus != 0
182 cat
183 end
184
185 def annotate(path, identifier=nil)
186 path ||= ''
187 cmd = "#{HG_BIN} -R #{target('')}"
188 cmd << " annotate -n -u"
189 cmd << " -r " + (identifier ? identifier.to_s : "tip")
190 cmd << " -r #{identifier.to_i}" if identifier
191 cmd << " #{target(path)}"
192 blame = Annotate.new
193 shellout(cmd) do |io|
194 io.each_line do |line|
195 next unless line =~ %r{^([^:]+)\s(\d+):(.*)$}
196 blame.add_line($3.rstrip, Revision.new(:identifier => $2.to_i, :author => $1.strip))
197 end
198 end
199 return nil if $? && $?.exitstatus != 0
200 blame
201 end
202 end 262 end
203 end 263 end
204 end 264 end
205 end 265 end