# # File:: pipeline/scm/perforce.rb # Description:: Perforce SCM Class # # Author:: David Muir # Author:: Derek Ward # Author:: Greg Smith # # References: # * P4Ruby PDF Documentation (http://www.perforce.com/) # # Requirements: # * Perforce p4ruby gem installed # #----------------------------------------------------------------------------- # Uses #----------------------------------------------------------------------------- require 'pipeline/gui/password_dialog' require 'pipeline/log/log' require 'pipeline/os/path' require 'pipeline/os/start' require 'pipeline/util/string' require 'p4' require 'socket' #----------------------------------------------------------------------------- # Implementation #----------------------------------------------------------------------------- module Pipeline module SCM # # == Description # Class representing a connection to a Perforce repository. # # This is a wrapper around the p4ruby gem which handles converting absolute # paths to depot paths using the specified clientspec. Paths are normalised # internally although its best to pass normalised absolute paths. # class Perforce < P4 #--------------------------------------------------------------------- # Constants #--------------------------------------------------------------------- # None #--------------------------------------------------------------------- # Instance Methods #--------------------------------------------------------------------- # Class constructor. def initialize( ) super() self.exception_level = P4::RAISE_ERRORS end # # We override the base class' connect method to handle our Perforce # username password. If the user has already connected (e.g. with # P4V, P4Win) they will have a ticket on the machine with a password # token. We can use this password token if its available, otherwise # we have to popup a password dialog (erk). # # Added a disable_logging option for instances where p4 must be used at the # initialization level of the framework (e.g. the Config class). Otherwise # a circular dependency is created and causes stalls in Ruby scripts. # def connect( disable_logging = false ) Perforce::log().debug( "#{self.port}: #{P4::identify()}" ) unless disable_logging begin # Connect to our server result = super( ) unless ( self.connected?() ) if( result == true ) then return true end # Determine if we have a ticket begin login_result = self.run_login( '-s' ).shift( ) puts "#{self.port} User #{self.user} has valid session or no password set. No login necessary." Perforce::log().debug( "login -s result: #{login_result}" ) unless disable_logging Perforce::log().debug( "#{self.port} User #{self.user} has valid session or no password set. No login necessary." ) unless disable_logging rescue P4Exception => ex begin Perforce::log().warn( "#{self.port} User #{self.user} requires login. Prompting." ) unless disable_logging puts "#{ex.message} #{self.port} User #{self.user} requires login. Prompting." #In order to remove a circular dependency between the Config class and Perforce, generate where to #find the dialog application in relation to the current script. This code assumes that this script is within a #"tools" folder. It's not perfect, but better than indefinitely hanging. # split_path = File.dirname(__FILE__).split('\\') dialog_path = "" split_path.each do |path| dialog_path += path + "/" if path.downcase.casecmp(TOOLS_DIR) == 0 then break end end dialog_path = "x:/gta5/tools_ng/bin/dialog/dialog.exe" #dialog_path += DIALOG command = "#{dialog_path} #{ARGUMENTS} #{self.port} #{self.user}" Perforce::log().debug( "Executing login dialog: #{command}." ) unless disable_logging puts "Executing login dialog: #{command}." if ( system( command ) ) then # Our custom dialog does the login for us; so we just check the status again. self.run_login( '-s' ).shift( ) else Perforce::log().error( "#{ex.message} #{self.port} User #{self.user} login failed. Password incorrect?" ) unless disable_logging throw P4Exception.new( "#{ex.message} #{self.port} User #{self.user} #{self.client} login2. Password incorrect?" ) end rescue P4Exception => ex Perforce::log().error( "#{ex.message} #{self.port} User #{self.user} login failed. Password incorrect?" ) unless disable_logging Perforce::log().exception( ex ) unless disable_logging result = false end end result rescue P4Exception => ex Perforce::log().error( "Exception during Perforce connection: #{ex.message}." ) unless disable_logging ex.backtrace.each do |err| Perforce::log().error( err ) end return false end end # # Return a P4::Map object containing the current client specification. # def clientspec_map( ) P4::Map.new( self.run_client( '-o' ).shift()['View'] ) end # # We override the method_missing function to handle a mapping of local to # depot filenames. The P4Ruby class assumes all filenames are in depot # format, but we have a useful depot2local function for filename # translation. # def method_missing( m, *args ) if ( 'run_where' == m.to_s ) then # Just don't recurse through local2depot super( m, *args ) else nargs = [] args.each do |arg| # Map absolute local filenames to their depot couterpart. if ( arg.is_a?( String ) and ( arg.length > 0 ) and ( arg[1] == ':' ) ) then puts "local2depot: #{arg}" nargs << local2depot( arg ) else nargs << arg end end super( m, *nargs ) end end # assetbuilder fstat fix for files with @ character # note it does replace #, for which it would invalidate revision specs. def run_fstat_escaped( *args ) return nil if args.nil? files = args.flatten # Sub out the p4 reserved characters or our fstat will fail for i in 0...files.length puts files[i] files[i].mgsub!( LESSER_WILDCARD_SUB ) puts files[i] end fstat_results = self.run_fstat( args ) end # # 'noncompact' wrapper around fstat Perforce command. # for files Perforce knows nothing about Perforce would not return a result OR nil. # # ...However when issuing a fstat command on an array of files these 'compacted' results # are a real bother to decipher what files where in fact non-existent. # ...so this helper aspires to return an fstat result that returns a result for all files # if the file doesn't exist then the 'Version' == 0. def run_fstat_noncompact( *args ) return nil if args.nil? files = args.flatten # Sub out the p4 reserved characters or our fstat will fail for i in 0...files.length files[i].mgsub!( WILDCARD_SUB ) end fstat_results = self.run_fstat( args ) return_results = nil # Re-instate the p4 reserved characters or our fstat file comparison will not work for i in 0...files.length files[i].mgsub!( INVERSE_WILDCARD_SUB ) end files.each do | file | file = OS::Path.normalise( file ) fstat_result = nil # can be more elegant later - just paranoid about case and a OS:Path normalise if fstat_results fstat_results.each do |fstat| client_filename = OS::Path.normalise(fstat['clientFile']) if ( 0 == file.casecmp( client_filename ) ) fstat_result = fstat # This means we'll be opening for edit so we need to re-instate the p4 reserved character sub fstat_result['clientFile'].mgsub!( WILDCARD_SUB ) break end end end if ( fstat_result.nil? ) # Nb. There is no 'depotFile' created here, since it cannot have any practical use...yet. fstat_result = {} fstat_result['clientFile'] = file fstat_result['headAction'] = "nonexistent" # I created a non-standard action, better this that returning 'deleted' when it is NOT. fstat_result['headRev'] = "0" end return_results = [] if return_results.nil? return_results << fstat_result end return_results end # # Friendly wrapper around the edit / add Perforce commands. # Attempt to open a file for edit, if the file does not exist in the # repository then the file is opened for add. # # It attempts to pass down any specified arguments to the add and edit # commands (if they are appropriate). # # === Assumptions # The largest assumption is that all files either require addition # or edit. If in doubt pass one file at a time like the Asset Builder. # def run_edit_or_add( *args ) # See if last argument exists in depot. If it does then edit, # other add. filename = args[args.size-1] if ( self.exists?( filename ) == false ) then # Add the -f flag to force inclusion of wildcards in filenames. args.insert(0, '-f' ) # Add file. self.run_add( *args ) else # Sub our wildcards filename.mgsub!( WILDCARD_SUB ) args[args.size-1] = filename # Edit file. self.run_edit( *args ) end end # # Sync files from Perforce using a block that is executed for each file. # # If there is an error syncing with the file the block is still # executed with the error information filled. # # === Example Usage # # files_synced += p4.run_sync_with_block( args, "#{project.common}/..." ) do # |client_file, synced, errors| # # puts "Sync: #{client_file}" # end # def run_sync_with_block( *args, &block ) # Preview to fetch file list files_to_sync = [] args.flatten.each do |arg| nargs = [] nargs << '-n' nargs << arg # Determine if we have a revision spec on the end of this arg. revision = nil rev = arg.split( '@' ) if ( rev.size > 1 ) then revision = "@#{rev[1]}" end if ( revision.nil? ) then rev = arg.split( '#' ) if ( rev.size > 1 ) then revision = "##{rev[1]}" end end Perforce::log().debug( "Sync [preview]: #{nargs}" ) perforce_array = self.run_sync( nargs ) perforce_array.each do |hash| next unless ( hash.is_a?( Hash ) ) if ( not hash.has_key?( 'clientFile' ) ) then Perforce::log().error( "Internal error in sync_with_block. Unrecognised Hash (#{hash.inspect})." ) next end file = {} file[:client] = hash['clientFile'].gsub("@","%40") file[:revision] = revision files_to_sync << file end end # Actual sync, invoking block for each file # Ensure we have remove our path from the args. DOH! nargs = [] args.flatten.each do |arg| nargs << arg if arg.starts_with( '-' ) end files_synced = files_to_sync.dup() files_to_sync.each do |client_file| begin Perforce::log().debug( "Sync: #{nargs.join(' ')} #{client_file[:client]}#{client_file[:revision]}" ) self.run_sync( nargs, "#{client_file[:client]}#{client_file[:revision]}" ) yield( client_file[:client], true, [] ) if ( block_given? ) rescue P4Exception => ex # Perforce had a problem syncing the file, remove from our # files_to_sync array. Perforce::log().error( "Perforce had a problem syncing the file #{client_file} the Exception was #{ex.message} #{ex.inspect}" ) files_synced.delete( client_file ) yield( client_file, false, self.errors ) if ( block_given? ) self.errors.clear() end end files_synced end # # Returns a Array of local filename Strings that were commited in the # speecified changelist. # def files_from_changelist( changelist, local = true ) files = [] begin changelist = self.run_describe( '-s', changelist.to_s ).shift depot_files = changelist['depotFile'] depot_files.each do |filename| files << depot2local( filename ) if ( local ) files << filename unless ( local ) end rescue P4Exception => ex Perforce::log().error( "Failed to fetch files from changelist: #{ex.inspect}." ) end files end # # Creates a changelist returning the changelist number. # def create_changelist( description = '' ) spec = self.fetch_change() spec[ 'Files' ] = [] spec[ 'Jobs' ] = [] spec[ 'Description' ] = description out = self.save_change( spec ).shift if ( out =~ /Change (\d+) created./ ) cl = $1 Perforce::log().info( "#{self.port}: Changelist #{cl} created." ) return ( cl ) end message = "Error creating pending changelist: #{out}." Perforce::log().error( message ) throw P4Exception.new( message ) end # # Deletes an empty changelist, specified by changelist number. # def delete_changelist( changelist ) self.run_change( '-d', changelist.to_s ) Perforce::log().info( "#{self.port}: Changelist #{changelist} deleted." ) end # # Submit a empty changelist, specified by changelist number. # def submit_changelist( changelist ) self.run_submit( '-c', changelist.to_s ) Perforce::log().info( "#{self.port}: Changelist #{changelist} submitted." ) end # # Determines whether a file exists in the SCM repository, returning # true if it exists, false otherwise. # def exists?( filename ) real_filename = filename.mgsub( WILDCARD_SUB ) result = run_fstat( real_filename ) result.each do |res| return false if res["headAction"] == "delete" end ( result.size > 0 ) end # # Runs a batch fstat on filenames and filters and returns # a hash, one entry is files that need to be added and one # of files to open for edit # def batchCheckForAddOrEdit( filenames ) filesToAdd = [] filesToEdit = [] # Want to leave the case of filenames intact but sort the # slashes out OS::Path::no_downcase_on_normalise() do for f in 0..(filenames.size-1) do temp = OS::Path::normalise(filenames[f]) filenames[f] = temp filenames[f].mgsub!( WILDCARD_SUB ) end result = run( "fstat", filenames ) result.each do |res| perforceFilename = res["clientFile"] puts "Record for: #{perforceFilename}" idx = -1 if perforceFilename != nil then temp = OS::Path::normalise( perforceFilename ) perforceFilename = temp # Find out where the filename is in the array for deleting # later on, using casecmp to ignore case of the perforce and # passed in filename matchedFilename = false while matchedFilename == false do for i in 0..(filenames.size - 1) do if ( filenames[i].casecmp(perforceFilename) == 0 ) then idx = i matchedFilename = true break end end end end if ( res.has_key?( 'action' ) ) then elsif ( res.has_key?( 'headAction' ) ) then end # If it's already been marked for add or edit, no need to have it on # either list to send to perforce if ( res["action"] != "add" and res["action"] != "edit" ) then filesToEdit << perforceFilename # Check if file has been deleted from perforce elsif ( res["headAction"] == "delete") then filesToAdd << perforceFilename end filenames.delete_at(idx) end end # Add leftover files that had no records to the add list filesToAdd.concat(filenames) { :add => filesToAdd, :edit => filesToEdit } end # # Friendly get latest wrapper around 'run_sync'. # def get_latest( path, recurse = false ) if ( recurse ) path_recurse = "#{path}/..." self.run_sync( path_recurse ) else self.run_sync( path ) end end # # Convert a depot filename string into a local filename string. # def depot2local( filename ) begin if ( filename.is_a?( Array ) ) then where = [] wheretemp = self.run_where( filename ) wheretemp.each do |file| where << file['path'] end where else where = self.run_where( filename ).shift where['path'] end rescue nil end end # # Convert a local filename string into a depot filename string. # def local2depot( filename ) begin if ( filename.is_a?( Array ) ) then where = [] wheretemp = self.run_where( filename ) wheretemp.each do |file| where << file['depotFile'] end where else where = self.run_where( filename ).shift where['depotFile'] end rescue nil end end # # DHM WARNING:: DEPRECATED FUNCTION DO NOT USE # # allows you to turn on/off the modtime option for workspaces # could be extended to all the other options with a hash # but i'm not really sure if any of them are useful at the moment def set_client_option_modtime( modtime, clientname = nil ) clientspec = nil clientspec = self.fetch_client if clientname == nil clientspec = self.fetch_client(clientname) if clientname != nil options = clientspec["Options"] if modtime != (options.include?("nomodtime") == false) then if modtime then clientspec["Options"].gsub!("nomodtime","modtime") else clientspec["Options"].gsub!("modtime","nomodtime") end self.save_client(clientspec) end end #--------------------------------------------------------------------- # Class Methods #--------------------------------------------------------------------- # Class constructor from a few options. def Perforce::create( port, user, client ) p4 = Perforce.new( ) p4.port = port p4.user = user p4.client = client p4.connect( ) p4 end # Return Perforce class log object. def Perforce::log( ) @@log = Log.new( 'perforce' ) if ( @@log.nil? ) @@log end #--------------------------------------------------------------------- # Private #--------------------------------------------------------------------- private TOOLS_DIR = 'tools' DIALOG = 'bin/dialog/dialog.exe' ARGUMENTS = '--special p4login' WILDCARD_SUB = { /[@]/ => '%40', /[\#]/ => '%23', /[\*]/ => '%2A' } INVERSE_WILDCARD_SUB = { /%40/ => '@', /%23/ => '#', /%2A/ => '*' } LESSER_WILDCARD_SUB = { /[@]/ => '%40', /[\*]/ => '%2A' } LESSER_INVERSE_WILDCARD_SUB = { /%40/ => '@', /%2A/ => '*' } @@log = nil end # # == Description # Class wrapping Perforce class which ensures that a connection is # established before issuing commands. # class Perforce_Connected P4_CONNECTION_SLEEP = 1 P4_CONNECTION_MAX_ATTEMPTS = 100 # Class constructor. def initialize( ) @p4 = Perforce.new( ) end # Connect to determine if we are connected is nosensicle hence we intercept this method here. def connected?( ) @p4.connected? end # Connect to connect is nosensicle hence we intercept this method here. def connect( ) @p4.connect end # Connect and pass to SCM::Perforce object. def method_missing( m, *args, &block ) # DHM 3 September 2009 # We detect any '=' characters at the end and don't pre-connect # to the Perforce server. if ( 61 == m.to_s[-1] ) then @p4.send( m, *args, &block ) else max_attempts = 0 self.connect( ) unless self.connected?( ) # Continually attempt to connect until we have reached our # maximum attempts count. while ( ( not connected? ) and ( max_attempts < P4_CONNECTION_MAX_ATTEMPTS ) ) connect Kernel.sleep( P4_CONNECTION_SLEEP ) max_attempts += 1 end # Pass through our method call to our Perforce object if we # managed to connect, otherwise error. if ( self.connected?() ) then @p4.send( m, *args, &block ) else message = "Perforce_Connected failed to connect after #{P4_CONNECTION_MAX_ATTEMPTS} attempts." SCM::Perforce::log().error( message ) throw P4Exception.new( message ) end end end #-------------------------------------------------------------------- # Class Methods #-------------------------------------------------------------------- # Construct a Perforce_Connected object with arguments. def Perforce_Connected::create( port, user, client ) _p4 = Perforce_Connected.new _p4.port = port _p4.user = user _p4.client = client _p4.connect _p4 end end end # SCM module end # Pipeline module # pipeline/scm/perforce.rb