Index: app/helpers/repositories_helper.rb =================================================================== --- app/helpers/repositories_helper.rb (revision 11182) +++ app/helpers/repositories_helper.rb (working copy) @@ -162,6 +162,18 @@ :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/...)') + + 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), Index: app/helpers/application_helper.rb =================================================================== --- app/helpers/application_helper.rb (revision 11182) +++ app/helpers/application_helper.rb (working copy) @@ -704,14 +704,14 @@ # identifier:version:1.0.0 # identifier:source:some/file def parse_redmine_links(text, project, obj, attr, only_path, options) - 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| + 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| 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 link = nil if project_identifier project = Project.visible.find_by_identifier(project_identifier) end if esc.nil? - if prefix.nil? && sep == 'r' + if prefix.nil? && (sep == 'r' || sep == 'c' || sep == 'cl') if project repository = nil if repo_identifier @@ -721,7 +721,7 @@ end # project.changesets.visible raises an SQL error because of a double join on repositories if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier)) - 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}, + 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}, :class => 'changeset', :title => truncate_single_line(changeset.comments, :length => 100)) end Index: app/models/repository/perforce.rb =================================================================== --- app/models/repository/perforce.rb (revision 0) +++ app/models/repository/perforce.rb (revision 0) @@ -0,0 +1,86 @@ +# 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 + attr_protected :root_url + validates_presence_of :url + + 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 Index: config/settings.yml =================================================================== --- config/settings.yml (revision 11182) +++ config/settings.yml (working copy) @@ -91,6 +91,7 @@ serialized: true default: - Subversion + - Perforce - Darcs - Mercurial - Cvs Index: config/configuration.yml.example =================================================================== --- config/configuration.yml.example (revision 11182) +++ config/configuration.yml.example (working copy) @@ -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 Index: lib/redmine/scm/adapters/perforce_adapter.rb =================================================================== --- lib/redmine/scm/adapters/perforce_adapter.rb (revision 0) +++ lib/redmine/scm/adapters/perforce_adapter.rb (revision 0) @@ -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 nil 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