Project

General

Profile

Feature #339 » redmine_perforce_patch.diff

Full patch (includes minor changes in addition to the other attached files) - Terry Suereth, 2012-12-12 18:59

View differences:

redmine.patched/app/helpers/application_helper.rb 2012-08-06 09:55:07.636623500 -0700
668 668
  #     identifier:version:1.0.0
669 669
  #     identifier:source:some/file
670 670
  def parse_redmine_links(text, project, obj, attr, only_path, options)
671
    text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
671
    text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-]+)\|)?(r|c|cl)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
672 672
      leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
673 673
      link = nil
674 674
      if project_identifier
675 675
        project = Project.visible.find_by_identifier(project_identifier)
676 676
      end
677 677
      if esc.nil?
678
        if prefix.nil? && sep == 'r'
678
        if prefix.nil? && (sep == 'r' || sep == 'c' || sep == 'cl')
679 679
          if project
680 680
            repository = nil
681 681
            if repo_identifier
......
685 685
            end
686 686
            # project.changesets.visible raises an SQL error because of a double join on repositories
687 687
            if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
688
              link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
688
              link = link_to(h("#{project_prefix}#{repo_prefix}#{sep}#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
689 689
                                        :class => 'changeset',
690 690
                                        :title => truncate_single_line(changeset.comments, :length => 100))
691 691
            end
redmine.patched/app/helpers/repositories_helper.rb 2012-08-06 09:55:07.695635300 -0700
170 170
                            :onchange => "this.name='repository[password]';"))
171 171
  end
172 172

  
173
  def perforce_field_tags(form, repository)
174
    content_tag('p', form.text_field(:root_url, :label => 'P4PORT', :size => 60, :required => true, :disabled => (repository && !repository.root_url.blank?))) +
175
    content_tag('p', form.text_field(:url, :label => 'Root directory', :size => 60, :required => true) +
176
                     '<br />(//depot/foo/bar/...)') +
177
    content_tag('p', form.text_field(:login, :size => 30)) +
178
    content_tag('p', form.password_field(:password, :size => 30, :name => 'ignore',
179
                                         :value => ((repository.new_record? || repository.password.blank?) ? '' : ('x'*15)),
180
                                         :onfocus => "this.value=''; this.name='repository[password]';",
181
                                         :onchange => "this.name='repository[password]';"))
182
  end
183

  
184

  
173 185
  def darcs_field_tags(form, repository)
174 186
    content_tag('p', form.text_field(
175 187
                     :url, :label => l(:field_path_to_repository),
redmine.patched/app/models/repository/perforce.rb 2012-08-06 09:55:07.700636300 -0700
1
# Redmine - project management software
2
# Copyright (C) 2006-2011  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
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
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

  
18
# Portions of this code adapted from Michael Vance, see:
19
#  http://www.redmine.org/issues/339
20
# Adapted by Terry Suereth.
21

  
22
require 'redmine/scm/adapters/perforce_adapter'
23

  
24
class Repository::Perforce < Repository
25
  validates_presence_of :root_url, :url
26

  
27
  def self.scm_adapter_class
28
    Redmine::Scm::Adapters::PerforceAdapter
29
  end
30

  
31
  def self.scm_name
32
    'Perforce'
33
  end
34

  
35
  def supports_directory_revisions?
36
    false
37
  end
38

  
39
  def repo_log_encoding
40
    'UTF-8'
41
  end
42

  
43
  def latest_changesets(path, rev, limit=10)
44
    revisions = scm.revisions(path, rev, nil, :limit => limit)
45
    revisions ? changesets.find_all_by_revision(revisions.collect(&:identifier), :order => "committed_on DESC", :include => :user) : []
46
  end
47

  
48
  def fetch_changesets
49
    scm_info = scm.info
50
    if scm_info
51
      # latest revision found in database
52
      db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
53
      # latest revision in the repository
54
      scm_revision = scm_info.lastrev.identifier.to_i
55
      if db_revision < scm_revision
56
        logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
57
        identifier_from = db_revision + 1
58
        while (identifier_from <= scm_revision)
59
          # loads changesets by batches of 200
60
          identifier_to = [identifier_from + 199, scm_revision].min
61
          revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
62
          revisions.reverse_each do |revision|
63
            transaction do
64
              changeset = Changeset.create(:repository   => self,
65
                                           :revision     => revision.identifier,
66
                                           :committer    => revision.author,
67
                                           :committed_on => revision.time,
68
                                           :comments     => revision.message)
69

  
70
              revision.paths.each do |change|
71
                changeset.create_change(change)
72
              end unless changeset.new_record?
73
            end
74
          end unless revisions.nil?
75
          identifier_from = identifier_to + 1
76
        end
77
      end
78
    end
79
  end
80

  
81
end
redmine.patched/config/configuration.yml 2012-08-06 09:55:07.703636900 -0700
1
default:
2
  scm_perforce_command: /root/p4
redmine.patched/config/settings.yml 2012-08-06 09:55:07.707637700 -0700
91 91
  serialized: true
92 92
  default: 
93 93
  - Subversion
94
  - Perforce
94 95
  - Darcs
95 96
  - Mercurial
96 97
  - Cvs
redmine.patched/lib/redmine/scm/adapters/perforce_adapter.rb 2012-09-07 13:54:22.862129700 -0700
1
# Redmine - project management software
2
# Copyright (C) 2006-2011  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
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
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

  
18
# Portions of this code adapted from Michael Vance, see:
19
#  http://www.redmine.org/issues/339
20
# Adapted by Terry Suereth.
21

  
22
require 'redmine/scm/adapters/abstract_adapter'
23
require 'uri'
24

  
25
module Redmine
26
  module Scm
27
    module Adapters
28
      class PerforceAdapter < AbstractAdapter
29

  
30
        # P4 executable name
31
        P4_BIN = Redmine::Configuration['scm_perforce_command'] || "p4"
32

  
33
        class << self
34
          def client_command
35
            @@bin    ||= P4_BIN
36
          end
37

  
38
          def sq_bin
39
            @@sq_bin ||= shell_quote_command
40
          end
41

  
42
          def client_version
43
            @@client_version ||= (p4_binary_version || [])
44
          end
45

  
46
          def client_available
47
            !client_version.empty?
48
          end
49

  
50
          def p4_binary_version
51
            scm_version = scm_version_from_command_line.dup
52
            bin_version = ''
53
            if scm_version.respond_to?(:force_encoding)
54
              scm_version.force_encoding('ASCII-8BIT')
55
            end
56
            if m = scm_version.match(%r{Rev\. P4/[^/]+/([^/]+)/})
57
              bin_version = m[1].scan(%r{\d+}).collect(&:to_i)
58
            end
59
            bin_version
60
          end
61

  
62
          def scm_version_from_command_line
63
            shellout("#{sq_bin} -V") { |io| io.read }.to_s
64
          end
65
        end
66

  
67
        # Get info about the p4 repository
68
        def info
69
          cmd = "#{self.class.sq_bin}"
70
          cmd << credentials_string
71
          cmd << " changes -m 1 -s submitted -t "
72
          cmd << shell_quote(depot)
73
          info = nil
74
          shellout(cmd) do |io|
75
            io.each_line do |line|
76
              change = parse_change(line)
77
              next unless change
78
              begin
79
                info = Info.new({:root_url => url,
80
                                 :lastrev => Revision.new({
81
                                   :identifier => change[:id],
82
                                   :author => change[:author],
83
                                   :time => change[:time],
84
                                   :message => change[:desc]
85
                                 })
86
                               })
87
              rescue
88
              end
89
            end
90
          end
91
          return nil if $? && $?.exitstatus != 0
92
          info
93
        rescue CommandFailed
94
          return nil
95
        end
96

  
97
        # Returns an Entries collection
98
        # or nil if the given path doesn't exist in the repository
99
        def entries(path=nil, identifier=nil, options={})
100
          query_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
101
          query_path.chomp!
102
          query_path = query_path.gsub(%r{\/\Z}, '') + "/*"
103
          identifier = (identifier and identifier.to_i > 0) ? "@#{identifier}" : nil
104
          entries = Entries.new
105

  
106
          p4login = credentials_string
107

  
108
          # Dirs
109
          cmd = "#{self.class.sq_bin}"
110
          cmd << p4login
111
          cmd << " dirs "
112
          cmd << shell_quote(query_path)
113
          cmd << "#{identifier}" if identifier
114
          # <path>/* - no such file(s).
115
          # -or-
116
          # <path>
117
          shellout(cmd) do |io|
118
            io.each_line do |line|
119
              # TODO this is actually unnecessary as the cmd will
120
              # write to stderr, not stdin, so we'll never even get
121
              # to this line
122
              next if line =~ %r{ - no such file\(s\)\.$}
123
              full_path = line.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
124
              full_path.chomp!
125
              name = full_path.split("/").last()
126
              entries << Entry.new({
127
                                     :name => name,
128
                                     :path => full_path,
129
                                     :kind => 'dir',
130
                                     :size => nil,
131
                                     :lastrev => make_revision(p4login, full_path + "/...", identifier)
132
                                   })
133
            end
134
          end
135

  
136
          # Files
137
          cmd = "#{self.class.sq_bin}"
138
          cmd << p4login
139
          cmd << " files "
140
          cmd << shell_quote(query_path)
141
          cmd << "#{identifier}" if identifier
142
          # <path>#<n> - <action> change <n> (<type>)
143
          shellout(cmd) do |io|
144
            io.each_line do |line|
145
              next unless line =~ %r{(.+)#(\d+) - (\S+) change (\d+) \((.+)\)}
146
              full_path = $1
147
              action = $3
148
              id = $4
149
              next if action == 'delete'
150
              fixed_path = full_path.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
151
              fixed_path.chomp!
152
              name = fixed_path.split("/").last()
153
              size = nil
154

  
155
              subcmd = "#{self.class.sq_bin}"
156
              subcmd << p4login
157
              subcmd << " fstat -Ol "
158
              subcmd << shell_quote(full_path)
159
              shellout(subcmd) do |subio|
160
                subio.each_line do |subline|
161
                  next unless subline =~ %r{\.\.\. fileSize (\d+)}
162
                  size = $1
163
                end
164
              end
165

  
166
              entries << Entry.new({
167
                                     :name => name,
168
                                     :path => fixed_path,
169
                                     :kind => 'file',
170
                                     :size => size,
171
                                     :lastrev => make_revision(p4login, fixed_path, identifier)
172
                                   })
173
            end
174
          end
175

  
176
          return nil if entries.empty?
177
          logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug?
178
          entries.sort_by_name
179
        end
180

  
181
        def properties(path, identifier=nil)
182
          return nil
183
        end
184

  
185
        def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
186
          base_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
187
          base_path.chomp!
188
          base_path = base_path.gsub(%r{\/\Z}, '')
189
          # We don't know if 'path' is a file or directory, and p4 has different syntax requirements for each --
190
          #  luckily, we can try both at the same time, and one will work while the other gets effectively ignored.
191
          query_path_file = base_path
192
          query_path_dir = query_path_file + "/..."
193
          # options[:reverse] doesn't make any sense to Perforce
194
          identifer_from = nil if options[:all]
195
          identifier_to = nil if options[:all]
196
          identifier_from = (identifier_from and identifier_from.to_i > 0) ? "@#{identifier_from}" : nil
197
          identifier_to = (identifier_to and identifier_to.to_i > 0) ? "@#{identifier_to}" : nil
198
          revisions = Revisions.new
199

  
200
          p4login = credentials_string
201

  
202
          cmd = "#{self.class.sq_bin}"
203
          cmd << p4login
204
          cmd << " changes -t "
205
          cmd << "-m #{options[:limit]} " if options[:limit]
206
          cmd << shell_quote(query_path_file)
207
          cmd << "#{identifier_to}," if identifier_to
208
          cmd << "#{identifier_from}" if identifier_from
209
          cmd << " "
210
          cmd << shell_quote(query_path_dir)
211
          cmd << "#{identifier_to}," if identifier_to
212
          cmd << "#{identifier_from}" if identifier_from
213
          shellout(cmd) do |io|
214
            io.each_line do |line|
215
              change = parse_change(line)
216
              next unless change
217
              full_desc = ''
218
              paths = []
219
              subcmd = "#{self.class.sq_bin}"
220
              subcmd << p4login
221
              subcmd << " describe -s #{change[:id]}"
222
              shellout(subcmd) do |subio|
223
                subio.each_line do |subline|
224
                  if subline =~ %r{\AChange #{change[:id]}}
225
                    next
226
                  elsif subline =~ %r{\AAffected files \.\.\.}
227
                    next
228
                  elsif subline =~ %r{\A\.\.\. (.+)#(\d+) (\S+)}
229
                    if options[:with_paths]
230
                      subpath = $1
231
                      revision = $2
232
                      action_full = $3
233
                      next if subpath !~ %r{^#{Regexp.escape(base_path)}}
234
                      case
235
                        when action_full == 'add'
236
                          action = 'A'
237
                        when action_full == 'edit'
238
                          action = 'M'
239
                        when action_full == 'delete'
240
                          action = 'D'
241
                        when action_full == 'branch'
242
                          action = 'C'
243
                        when action_full == 'import'
244
                          action = 'A'
245
                        when action_full == 'integrate'
246
                          action = 'M'
247
                        else
248
                          action = 'A' # FIXME: best guess, it's a new file?
249
                      end
250
                      fixed_path = subpath.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
251
                      paths << {:action => action, :path => fixed_path, :revision => revision}
252
                    end
253
                  else
254
                    full_desc << subline
255
                  end
256
                end
257
              end
258
              revisions << Revision.new({
259
                                          :identifier => change[:id],
260
                                          :author => change[:author],
261
                                          :time => change[:time],
262
                                          :message => full_desc.empty? ? change[:desc] : full_desc,
263
                                          :paths => paths
264
                                        })
265
            end
266
          end
267
          return nil if revisions.empty?
268
          revisions
269
        end
270

  
271
        def diff(path, identifier_from, identifier_to=nil)
272
          base_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
273
          base_path.chomp!
274
          base_path = base_path.gsub(%r{\/\Z}, '')
275
          # We don't know if 'path' is a file or directory, and p4 has different syntax requirements for each.
276
          query_path_file = base_path
277
          query_path_dir = query_path_file + "/..."
278

  
279
          identifier_to = (identifier_to and identifier_to.to_i > 0) ? "@#{identifier_to}" : "@#{identifier_from.to_i - 1}"
280
          identifier_from = (identifier_from and identifier_from.to_i > 0) ? "@#{identifier_from}" : "#head"
281

  
282
          p4login = credentials_string
283

  
284
          diff = []
285

  
286
          # File
287
          cmd = "#{self.class.sq_bin}"
288
          cmd << p4login
289
          cmd << " diff2 -du "
290
          cmd << shell_quote(query_path_file)
291
          cmd << "#{identifier_to} "
292
          cmd << shell_quote(query_path_file)
293
          cmd << "#{identifier_from}"
294
          shellout(cmd) do |io|
295
            io.each_line do |line|
296
              next if line =~ %r{ - no such file\(s\)\.$}
297
              next if line =~ %r{\A====.+==== identical\Z}
298

  
299
              if line =~ %r{\A==== (.+) - (.+) ==== ?(.*)}
300
                file1 = $1
301
                file2 = $2
302
                action = $3
303
                filename = file1
304
                if(file1 =~ %r{<\s*none\s*>})
305
                  filename = file2
306
                end
307
                filename = filename.gsub(%r{ \(.*\)\Z}, '') # remove filetype declaration
308
                filename = filename.gsub(%r{\#\d+\Z}, '') # remove file revision
309

  
310
                diff << "Index: #{filename}"
311
                diff << "==========================================================================="
312
                diff << "--- #{filename}#{identifier_to}"
313
                diff << "+++ #{filename}#{identifier_from}"
314
              else
315
                diff << line
316
              end
317
            end
318
          end
319

  
320
          # Dir
321
          cmd = "#{self.class.sq_bin}"
322
          cmd << p4login
323
          cmd << " diff2 -du "
324
          cmd << shell_quote(query_path_dir)
325
          cmd << "#{identifier_to} "
326
          cmd << shell_quote(query_path_dir)
327
          cmd << "#{identifier_from}"
328
          shellout(cmd) do |io|
329
            io.each_line do |line|
330
              next if line =~ %r{ - no such file\(s\)\.$}
331
              next if line =~ %r{\A====.+==== identical\Z}
332

  
333
              if line =~ %r{\A==== (.+) - (.+) ==== ?(.*)}
334
                file1 = $1
335
                file2 = $2
336
                action = $3
337
                filename = file1
338
                if(file1 =~ %r{<\s*none\s*>})
339
                  filename = file2
340
                end
341
                filename = filename.gsub(%r{ \(.*\)\Z}, '') # remove filetype declaration
342
                filename = filename.gsub(%r{\#\d+\Z}, '') # remove file revision
343

  
344
                diff << "Index: #{filename}"
345
                diff << "==========================================================================="
346
                diff << "--- #{filename}#{identifier_to}"
347
                diff << "+++ #{filename}#{identifier_from}"
348
              else
349
                diff << line
350
              end
351
            end
352
          end
353

  
354
          return nil if diff.empty?
355
          diff
356
        end
357

  
358
        def cat(path, identifier=nil)
359
          return nil if path.empty?
360
          query_path = "#{depot_no_dots}#{path}"
361
          cmd = "#{self.class.sq_bin}"
362
          cmd << credentials_string
363
          cmd << " print -q "
364
          cmd << shell_quote(query_path)
365
          cat = nil
366
          shellout(cmd) do |io|
367
            io.binmode
368
            cat = io.read
369
          end
370
          return nil if $? && $?.exitstatus != 0
371
          cat
372
        end
373

  
374
        def annotate(path, identifier=nil)
375
          return nil if path.empty?
376
          query_path = "#{depot_no_dots}#{path}"
377
          cmd = "#{self.class.sq_bin}"
378
          cmd << credentials_string
379
          cmd << " annotate -q -c "
380
          cmd << shell_quote(query_path)
381
          blame = Annotate.new
382
          shellout(cmd) do |io|
383
            io.each_line do |line|
384
              # <n>: <line>
385
              next unless line =~ %r{(\d+)\:\s(.*)$}
386
              id = $1
387
              rest = $2
388
              blame.add_line(rest.rstrip, Revision.new(:identifier => id))
389
            end
390
          end
391
          return nil if $? && $?.exitstatus != 0
392
          blame
393
        end
394

  
395
        private
396

  
397
        def credentials_login
398
          return nil unless !@login.blank? && !@password.blank?
399

  
400
          File.open("/tmp/perforce_adapter_login", 'w') { |f| f.write(@password) }
401
          ticket = shellout("#{self.class.sq_bin} -p #{shell_quote(@root_url)} -u #{shell_quote(@login)} login -p </tmp/perforce_adapter_login 2>/dev/null") { |io| io.read }.to_s
402
          File.delete("/tmp/perforce_adapter_login")
403
          if ticket.respond_to?(:force_encoding)
404
            ticket.force_encoding('ASCII-8BIT')
405
          end
406

  
407
          str = " -P "
408
          if m = ticket.match(%r/[0-9A-Za-z]{32}/)
409
            str << "#{shell_quote(m[0])}"
410
          else
411
            str << "#{shell_quote(@password)}"
412
          end
413
          str
414
        end
415

  
416
        def credentials_string
417
          str = ''
418
          str << " -p #{shell_quote(@root_url)}"
419
          str << " -u #{shell_quote(@login)}" unless @login.blank?
420
          str << credentials_login
421
          str
422
        end
423

  
424
        def depot
425
          url
426
        end
427

  
428
        def depot_no_dots
429
          url.gsub(Regexp.new("#{Regexp.escape('...')}$"), '')
430
        end
431

  
432
        def parse_change(line)
433
          # Change <n> on <day> <time> by <user>@<client> '<desc>'
434
          return nil unless line =~ %r{Change (\d+) on (\S+) (\S+) by (\S+) '(.*)'$}
435
          id = $1
436
          day = $2
437
          time = $3
438
          author = $4
439
          desc = $5
440
          # FIXME: inefficient to say the least
441
          #cmd = "#{self.class.sq_bin} users #{author.split('@').first}"
442
          #shellout(cmd) do |io|
443
          #  io.each_line do |line|
444
          #    # <user> <<email>> (<name>) accessed <date>
445
          #    next unless line =~ %r{\S+ \S+ \((.*)\) accessed \S+}
446
          #    author = $1
447
          #  end
448
          #end
449
          author = author.split('@').first
450
          return {:id => id, :author => author, :time => Time.parse("#{day} #{time}").localtime, :desc => desc}
451
        end
452

  
453
        def make_revision(p4login, path, identifier)
454
          return nil if path.empty?
455
          identifier ||= ''
456
          query_path = "#{depot_no_dots}#{path}"
457

  
458
          cmd = "#{self.class.sq_bin}"
459
          cmd << p4login
460
          cmd << " changes -m 1 -s submitted -t "
461
          cmd << shell_quote(query_path)
462
          cmd << "#{identifier}" if identifier
463
          revisions = []
464
          shellout(cmd) do |io|
465
            io.each_line do |line|
466
              change = parse_change(line)
467
              next unless change
468
              revisions << Revision.new({
469
                                          :identifier => change[:id],
470
                                          :author => change[:author],
471
                                          :time => change[:time],
472
                                          :message => change[:desc]
473
                                        })
474
            end
475
          end
476
          return nil if revisions.empty?
477
          revisions.first
478
        end
479

  
480
      end
481
    end
482
  end
483
end
redmine.patched/lib/redmine.rb 2012-08-06 09:55:07.711638500 -0700
26 26
end
27 27

  
28 28
Redmine::Scm::Base.add "Subversion"
29
Redmine::Scm::Base.add "Perforce"
29 30
Redmine::Scm::Base.add "Darcs"
30 31
Redmine::Scm::Base.add "Mercurial"
31 32
Redmine::Scm::Base.add "Cvs"
(6-6/11)