diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb
index f8e602cae..32b74d1e1 100644
--- a/app/helpers/repositories_helper.rb
+++ b/app/helpers/repositories_helper.rb
@@ -171,6 +171,17 @@ module RepositoriesHelper
)
end
+ def perforce_field_tags(form, repository)
+ content_tag('p', form.text_field(:root_url, :label => 'P4PORT', :size => 60, :required => true, :disabled => (repository && !repository.root_url.blank?))) +
+ content_tag('p', form.text_field(:url, :label => 'Root directory', :size => 60, :required => true) +
+ '
(//depot/foo/bar/...)'.html_safe) +
+ content_tag('p', form.text_field(:login, :size => 30)) +
+ content_tag('p', form.password_field(:password, :size => 30, :name => 'ignore',
+ :value => ((repository.new_record? || repository.password.blank?) ? '' : ('x'*15)),
+ :onfocus => "this.value=''; this.name='repository[password]';",
+ :onchange => "this.name='repository[password]';"))
+ end
+
def mercurial_field_tags(form, repository)
content_tag(
'p',
diff --git a/app/models/repository/perforce.rb b/app/models/repository/perforce.rb
new file mode 100644
index 000000000..67b860577
--- /dev/null
+++ b/app/models/repository/perforce.rb
@@ -0,0 +1,92 @@
+# Redmine - project management software
+# Copyright (C) 2006-2011 Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# Portions of this code adapted from Michael Vance, see:
+# http://www.redmine.org/issues/339
+# Adapted by Terry Suereth.
+
+require 'redmine/scm/adapters/perforce_adapter'
+
+class Repository::Perforce < Repository
+ validates_presence_of :url
+
+ safe_attributes 'root_url', :if => lambda {|repository, user| repository.new_record? || repository.root_url.blank?}
+
+ def self.scm_adapter_class
+ Redmine::Scm::Adapters::PerforceAdapter
+ end
+
+ def self.scm_name
+ 'Perforce'
+ end
+
+ def supports_directory_revisions?
+ false
+ end
+
+ def repo_log_encoding
+ 'UTF-8'
+ end
+
+ def latest_changesets(path, rev, limit=10)
+ revisions = scm.revisions(path, rev, nil, :limit => limit)
+ if revisions
+ identifiers = revisions.collect(&:identifier).compact
+ changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).to_a
+ else
+ []
+ end
+ end
+
+ def fetch_changesets
+ logger.info("Executing Perforce fetch_changesets")
+ scm_info = scm.info
+ if scm_info
+ logger.info("SCM info retrieved")
+ # latest revision found in database
+ db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
+ logger.info("SCM: db_revision " + db_revision.to_s )
+ # latest revision in the repository
+ scm_revision = scm_info.lastrev.identifier.to_i
+ logger.info("SCM: scm_revision " + scm_revision.to_s )
+ if db_revision < scm_revision
+ logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
+ identifier_from = db_revision + 1
+ while (identifier_from <= scm_revision)
+ # loads changesets by batches of 200
+ identifier_to = [identifier_from + 199, scm_revision].min
+ revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
+ revisions.reverse_each do |revision|
+ transaction do
+ changeset = Changeset.create(:repository => self,
+ :revision => revision.identifier,
+ :committer => revision.author,
+ :committed_on => revision.time,
+ :comments => revision.message)
+
+ revision.paths.each do |change|
+ changeset.create_change(change)
+ end unless changeset.new_record?
+ end
+ end unless revisions.nil?
+ identifier_from = identifier_to + 1
+ end
+ end
+ end
+ end
+
+end
diff --git a/config/settings.yml b/config/settings.yml
index 0c41b7eda..0e0c23983 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -134,6 +134,7 @@ enabled_scm:
serialized: true
default:
- Subversion
+ - Perforce
- Mercurial
- Cvs
- Bazaar
diff --git a/lib/redmine/preparation.rb b/lib/redmine/preparation.rb
index 2ba3b5447..eb01445e3 100644
--- a/lib/redmine/preparation.rb
+++ b/lib/redmine/preparation.rb
@@ -25,6 +25,7 @@ module Redmine
ActiveRecord::Base.include Redmine::I18n
Scm::Base.add "Subversion"
+ Scm::Base.add "Perforce"
Scm::Base.add "Mercurial"
Scm::Base.add "Cvs"
Scm::Base.add "Bazaar"
diff --git a/lib/redmine/scm/adapters/perforce_adapter.rb b/lib/redmine/scm/adapters/perforce_adapter.rb
new file mode 100644
index 000000000..7e106409a
--- /dev/null
+++ b/lib/redmine/scm/adapters/perforce_adapter.rb
@@ -0,0 +1,484 @@
+# Redmine - project management software
+# Copyright (C) 2006-2011 Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# Portions of this code adapted from Michael Vance, see:
+# http://www.redmine.org/issues/339
+# Adapted by Terry Suereth.
+
+require 'redmine/scm/adapters/abstract_adapter'
+require 'uri'
+
+module Redmine
+ module Scm
+ module Adapters
+ class PerforceAdapter < AbstractAdapter
+
+ # P4 executable name
+ P4_BIN = Redmine::Configuration['scm_perforce_command'] || "p4"
+
+ class << self
+ def client_command
+ @@bin ||= P4_BIN
+ end
+
+ def sq_bin
+ @@sq_bin ||= shell_quote_command
+ end
+
+ def client_version
+ @@client_version ||= (p4_binary_version || [])
+ end
+
+ def client_available
+ !client_version.empty?
+ end
+
+ def p4_binary_version
+ scm_version = scm_version_from_command_line.dup
+ bin_version = ''
+ if scm_version.respond_to?(:force_encoding)
+ scm_version.force_encoding('ASCII-8BIT')
+ end
+ if m = scm_version.match(%r{Rev\. P4/[^/]+/([^/]+)/})
+ bin_version = m[1].scan(%r{\d+}).collect(&:to_i)
+ end
+ bin_version
+ end
+
+ def scm_version_from_command_line
+ shellout("#{sq_bin} -V") { |io| io.read }.to_s
+ end
+ end
+
+ # Get info about the p4 repository
+ def info
+ cmd = "#{self.class.sq_bin}"
+ cmd << credentials_string
+ cmd << " changes -m 1 -s submitted -t "
+ cmd << shell_quote(depot)
+ info = nil
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ logger.info("DEBUG SCM " + line)
+ change = parse_change(line)
+ next unless change
+ begin
+ info = Info.new({:root_url => url,
+ :lastrev => Revision.new({
+ :identifier => change[:id],
+ :author => change[:author],
+ :time => change[:time],
+ :message => change[:desc]
+ })
+ })
+ rescue
+ end
+ end
+ end
+ return nil if $? && $?.exitstatus != 0
+ info
+ rescue CommandFailed
+ return nil
+ end
+
+ # Returns an Entries collection
+ # or nil if the given path doesn't exist in the repository
+ def entries(path=nil, identifier=nil, options={})
+ query_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
+ query_path.chomp!
+ query_path = query_path.gsub(%r{\/\Z}, '') + "/*"
+ identifier = (identifier and identifier.to_i > 0) ? "@#{identifier}" : nil
+ entries = Entries.new
+
+ p4login = credentials_string
+
+ # Dirs
+ cmd = "#{self.class.sq_bin}"
+ cmd << p4login
+ cmd << " dirs "
+ cmd << shell_quote(query_path)
+ cmd << "#{identifier}" if identifier
+ # /* - no such file(s).
+ # -or-
+ #
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ # TODO this is actually unnecessary as the cmd will
+ # write to stderr, not stdin, so we'll never even get
+ # to this line
+ next if line =~ %r{ - no such file\(s\)\.$}
+ full_path = line.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
+ full_path.chomp!
+ name = full_path.split("/").last()
+ entries << Entry.new({
+ :name => name,
+ :path => full_path,
+ :kind => 'dir',
+ :size => nil,
+ :lastrev => make_revision(p4login, full_path + "/...", identifier)
+ })
+ end
+ end
+
+ # Files
+ cmd = "#{self.class.sq_bin}"
+ cmd << p4login
+ cmd << " files "
+ cmd << shell_quote(query_path)
+ cmd << "#{identifier}" if identifier
+ # # - change ()
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ next unless line =~ %r{(.+)#(\d+) - (\S+) change (\d+) \((.+)\)}
+ full_path = $1
+ action = $3
+ id = $4
+ next if action == 'delete'
+ fixed_path = full_path.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
+ fixed_path.chomp!
+ name = fixed_path.split("/").last()
+ size = nil
+
+ subcmd = "#{self.class.sq_bin}"
+ subcmd << p4login
+ subcmd << " fstat -Ol "
+ subcmd << shell_quote(full_path)
+ shellout(subcmd) do |subio|
+ subio.each_line do |subline|
+ next unless subline =~ %r{\.\.\. fileSize (\d+)}
+ size = $1
+ end
+ end
+
+ entries << Entry.new({
+ :name => name,
+ :path => fixed_path,
+ :kind => 'file',
+ :size => size,
+ :lastrev => make_revision(p4login, fixed_path, identifier)
+ })
+ end
+ end
+
+ return nil if entries.empty?
+ logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug?
+ entries.sort_by_name
+ end
+
+ def properties(path, identifier=nil)
+ return nil
+ end
+
+ def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
+ base_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
+ base_path.chomp!
+ base_path = base_path.gsub(%r{\/\Z}, '')
+ # We don't know if 'path' is a file or directory, and p4 has different syntax requirements for each --
+ # luckily, we can try both at the same time, and one will work while the other gets effectively ignored.
+ query_path_file = base_path
+ query_path_dir = query_path_file + "/..."
+ # options[:reverse] doesn't make any sense to Perforce
+ identifer_from = nil if options[:all]
+ identifier_to = nil if options[:all]
+ identifier_from = (identifier_from and identifier_from.to_i > 0) ? "@#{identifier_from}" : nil
+ identifier_to = (identifier_to and identifier_to.to_i > 0) ? "@#{identifier_to}" : nil
+ revisions = Revisions.new
+
+ p4login = credentials_string
+
+ cmd = "#{self.class.sq_bin}"
+ cmd << p4login
+ cmd << " changes -t "
+ cmd << "-m #{options[:limit]} " if options[:limit]
+ cmd << shell_quote(query_path_file)
+ cmd << "#{identifier_to}," if identifier_to
+ cmd << "#{identifier_from}" if identifier_from
+ cmd << " "
+ cmd << shell_quote(query_path_dir)
+ cmd << "#{identifier_to}," if identifier_to
+ cmd << "#{identifier_from}" if identifier_from
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ change = parse_change(line)
+ next unless change
+ full_desc = ''
+ paths = []
+ subcmd = "#{self.class.sq_bin}"
+ subcmd << p4login
+ subcmd << " describe -s #{change[:id]}"
+ shellout(subcmd) do |subio|
+ subio.each_line do |subline|
+ if subline =~ %r{\AChange #{change[:id]}}
+ next
+ elsif subline =~ %r{\AAffected files \.\.\.}
+ next
+ elsif subline =~ %r{\A\.\.\. (.+)#(\d+) (\S+)}
+ if options[:with_paths]
+ subpath = $1
+ revision = $2
+ action_full = $3
+ next if subpath !~ %r{^#{Regexp.escape(base_path)}}
+ case
+ when action_full == 'add'
+ action = 'A'
+ when action_full == 'edit'
+ action = 'M'
+ when action_full == 'delete'
+ action = 'D'
+ when action_full == 'branch'
+ action = 'C'
+ when action_full == 'import'
+ action = 'A'
+ when action_full == 'integrate'
+ action = 'M'
+ else
+ action = 'A' # FIXME: best guess, it's a new file?
+ end
+ fixed_path = subpath.gsub(Regexp.new("#{Regexp.escape(depot_no_dots)}"), '')
+ paths << {:action => action, :path => fixed_path, :revision => revision}
+ end
+ else
+ full_desc << subline
+ end
+ end
+ end
+ revisions << Revision.new({
+ :identifier => change[:id],
+ :author => change[:author],
+ :time => change[:time],
+ :message => full_desc.empty? ? change[:desc] : full_desc,
+ :paths => paths
+ })
+ end
+ end
+ return nil if revisions.empty?
+ revisions
+ end
+
+ def diff(path, identifier_from, identifier_to=nil)
+ base_path = path.empty? ? "#{depot_no_dots}" : "#{depot_no_dots}#{path}"
+ base_path.chomp!
+ base_path = base_path.gsub(%r{\/\Z}, '')
+ # We don't know if 'path' is a file or directory, and p4 has different syntax requirements for each.
+ query_path_file = base_path
+ query_path_dir = query_path_file + "/..."
+
+ identifier_to = (identifier_to and identifier_to.to_i > 0) ? "@#{identifier_to}" : "@#{identifier_from.to_i - 1}"
+ identifier_from = (identifier_from and identifier_from.to_i > 0) ? "@#{identifier_from}" : "#head"
+
+ p4login = credentials_string
+
+ diff = []
+
+ # File
+ cmd = "#{self.class.sq_bin}"
+ cmd << p4login
+ cmd << " diff2 -du "
+ cmd << shell_quote(query_path_file)
+ cmd << "#{identifier_to} "
+ cmd << shell_quote(query_path_file)
+ cmd << "#{identifier_from}"
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ next if line =~ %r{ - no such file\(s\)\.$}
+ next if line =~ %r{\A====.+==== identical\Z}
+
+ if line =~ %r{\A==== (.+) - (.+) ==== ?(.*)}
+ file1 = $1
+ file2 = $2
+ action = $3
+ filename = file1
+ if(file1 =~ %r{<\s*none\s*>})
+ filename = file2
+ end
+ filename = filename.gsub(%r{ \(.*\)\Z}, '') # remove filetype declaration
+ filename = filename.gsub(%r{\#\d+\Z}, '') # remove file revision
+
+ diff << "Index: #{filename}"
+ diff << "==========================================================================="
+ diff << "--- #{filename}#{identifier_to}"
+ diff << "+++ #{filename}#{identifier_from}"
+ else
+ diff << line
+ end
+ end
+ end
+
+ # Dir
+ cmd = "#{self.class.sq_bin}"
+ cmd << p4login
+ cmd << " diff2 -du "
+ cmd << shell_quote(query_path_dir)
+ cmd << "#{identifier_to} "
+ cmd << shell_quote(query_path_dir)
+ cmd << "#{identifier_from}"
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ next if line =~ %r{ - no such file\(s\)\.$}
+ next if line =~ %r{\A====.+==== identical\Z}
+
+ if line =~ %r{\A==== (.+) - (.+) ==== ?(.*)}
+ file1 = $1
+ file2 = $2
+ action = $3
+ filename = file1
+ if(file1 =~ %r{<\s*none\s*>})
+ filename = file2
+ end
+ filename = filename.gsub(%r{ \(.*\)\Z}, '') # remove filetype declaration
+ filename = filename.gsub(%r{\#\d+\Z}, '') # remove file revision
+
+ diff << "Index: #{filename}"
+ diff << "==========================================================================="
+ diff << "--- #{filename}#{identifier_to}"
+ diff << "+++ #{filename}#{identifier_from}"
+ else
+ diff << line
+ end
+ end
+ end
+
+ return nil if diff.empty?
+ diff
+ end
+
+ def cat(path, identifier=nil)
+ return nil if path.empty?
+ query_path = "#{depot_no_dots}#{path}"
+ cmd = "#{self.class.sq_bin}"
+ cmd << credentials_string
+ cmd << " print -q "
+ cmd << shell_quote(query_path)
+ cat = nil
+ shellout(cmd) do |io|
+ io.binmode
+ cat = io.read
+ end
+ return nil if $? && $?.exitstatus != 0
+ cat
+ end
+
+ def annotate(path, identifier=nil)
+ return nil if path.empty?
+ query_path = "#{depot_no_dots}#{path}"
+ cmd = "#{self.class.sq_bin}"
+ cmd << credentials_string
+ cmd << " annotate -q -c "
+ cmd << shell_quote(query_path)
+ blame = Annotate.new
+ shellout(cmd) do |io|
+ io.each_line do |line|
+ # :
+ next unless line =~ %r{(\d+)\:\s(.*)$}
+ id = $1
+ rest = $2
+ blame.add_line(rest.rstrip, Revision.new(:identifier => id))
+ end
+ end
+ return nil if $? && $?.exitstatus != 0
+ blame
+ end
+
+ private
+
+ def credentials_login
+ return '' unless !@login.blank? && !@password.blank?
+
+ File.open("/tmp/perforce_adapter_login", 'w') { |f| f.write(@password) }
+ ticket = shellout("#{self.class.sq_bin} -p #{shell_quote(@root_url)} -u #{shell_quote(@login)} login -p /dev/null") { |io| io.read }.to_s
+ File.delete("/tmp/perforce_adapter_login")
+ if ticket.respond_to?(:force_encoding)
+ ticket.force_encoding('ASCII-8BIT')
+ end
+
+ str = " -P "
+ if m = ticket.match(%r/[0-9A-Za-z]{32}/)
+ str << "#{shell_quote(m[0])}"
+ else
+ str << "#{shell_quote(@password)}"
+ end
+ str
+ end
+
+ def credentials_string
+ str = ''
+ str << " -p #{shell_quote(@root_url)}"
+ str << " -u #{shell_quote(@login)}" unless @login.blank?
+ str << credentials_login
+ str
+ end
+
+ def depot
+ url
+ end
+
+ def depot_no_dots
+ url.gsub(Regexp.new("#{Regexp.escape('...')}$"), '')
+ end
+
+ def parse_change(line)
+ # Change on