711 lines
21 KiB
Ruby
Executable File
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
|