Files
gtav-src/tools_ng/lib/pipeline/scm/perforce.rb
T
2025-09-29 00:52:08 +02:00

711 lines
21 KiB
Ruby
Executable File

#
# File:: pipeline/scm/perforce.rb
# Description:: Perforce SCM Class
#
# Author:: David Muir <david.muir@rockstarnorth.com>
# Author:: Derek Ward <derek@rockstarnorth.com>
# Author:: Greg Smith <greg@rockstarnorth.com>
#
# 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