Project

General

Profile

Feature #339 » lib-redmine-scm-adapters-perforce_adapter.rb

lib/redmine/scm/adapters/perforce_adapter.rb - Terry Suereth, 2012-12-12 18:59

 
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
(8-8/11)