diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb index ae45d2a..8a58310 100644 --- a/app/helpers/repositories_helper.rb +++ b/app/helpers/repositories_helper.rb @@ -162,6 +162,18 @@ module RepositoriesHelper :onchange => "this.name='repository[password]';")) 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 darcs_field_tags(form, repository) content_tag('p', form.text_field( :url, :label => l(:field_path_to_repository), diff --git a/app/models/repository/perforce.rb b/app/models/repository/perforce.rb new file mode 100644 index 0000000..fcc6435 --- /dev/null +++ b/app/models/repository/perforce.rb @@ -0,0 +1,87 @@ +# 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) + revisions ? changesets.find_all_by_revision(revisions.collect(&:identifier), :order => "committed_on DESC", :include => :user) : [] + 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/configuration.yml.example b/config/configuration.yml.example index 27eb86f..6d38473 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -80,6 +80,7 @@ # default configuration options for all environments default: + scm_perforce_command: /usr/local/bin/p4 # Outgoing emails configuration (see examples above) email_delivery: delivery_method: :smtp diff --git a/config/settings.yml b/config/settings.yml index 0c9f270..a21faa6 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -91,6 +91,7 @@ enabled_scm: serialized: true default: - Subversion + - Perforce - Darcs - Mercurial - Cvs diff --git a/lib/redmine.rb b/lib/redmine.rb index 43e3f14..00bc6b4 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -66,6 +66,7 @@ else end Redmine::Scm::Base.add "Subversion" +Redmine::Scm::Base.add "Perforce" Redmine::Scm::Base.add "Darcs" Redmine::Scm::Base.add "Mercurial" Redmine::Scm::Base.add "Cvs" diff --git a/lib/redmine/scm/adapters/perforce_adapter.rb b/lib/redmine/scm/adapters/perforce_adapter.rb new file mode 100644 index 0000000..7e10640 --- /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