<?php /** * Abstracts operations against Perforce changes. * * @copyright 2011 Perforce Software. All rights reserved. * @license Please see LICENSE.txt in top-level folder of this distribution. * @version <release>/<patch> */ namespace P4\Spec; use P4\Validate; use P4\Spec\Job; use P4\File\File; use P4\File\Query as FileQuery; use P4\Exception as P4Exception; use P4\Connection\ConnectionInterface; use P4\Connection\Exception\CommandException; use P4\Connection\Exception\ConflictException; use P4\Model\Resolvable\ResolvableInterface; use P4\Model\Fielded\Iterator as FieldedIterator; use P4\Spec\Exception\Exception; use P4\Spec\Exception\UnopenedException; use P4\Spec\Exception\NotFoundException; class Change extends PluralAbstract implements ResolvableInterface { const SPEC_TYPE = 'change'; const ID_FIELD = 'Change'; const DEFAULT_CHANGE = 'default'; const PENDING_CHANGE = 'pending'; const SUBMITTED_CHANGE = 'submitted'; const PUBLIC_CHANGE = 'public'; const RESTRICTED_CHANGE = 'restricted'; const FETCH_BY_FILESPEC = 'filespec'; const FETCH_BY_IDS = 'ids'; const FETCH_BY_STATUS = 'status'; const FETCH_INTEGRATED = 'integrated'; const FETCH_BY_CLIENT = 'client'; const FETCH_BY_USER = 'user'; const RESOLVE_FILE = 'file'; const MAX_SUBMIT_ATTEMPTS = 3; protected $cache = array(); protected $fixStatus = null; protected $fields = array( 'Date' => array( 'accessor' => 'getDate' ), 'User' => array( 'accessor' => 'getUser', 'mutator' => 'setUser' ), 'Client' => array( 'accessor' => 'getClient', 'mutator' => 'setClient' ), 'Status' => array( 'accessor' => 'getStatus' ), 'Description' => array( 'accessor' => 'getDescription', 'mutator' => 'setDescription' ), 'JobStatus' => array( 'accessor' => 'getJobStatus', 'mutator' => 'setJobStatus' ), 'Jobs' => array( 'accessor' => 'getJobs', 'mutator' => 'setJobs' ), 'Type' => array( 'accessor' => 'getType', 'mutator' => 'setType' ), 'Files' => array( 'accessor' => 'getFiles', 'mutator' => 'setFiles' ) ); /** * Get the number of this change. * Extends parent to return an integer value for numbered changes. * * @return null|string|int the number of the change, the literal string 'default' * or null if no id has been set. */ public function getId() { $id = parent::getId(); if ($id !== null && $id !== static::DEFAULT_CHANGE) { $id = intval($id); } return $id; } /** * Set the id of this spec entry. Id must be in a valid format or null. * Extended from parent to clear cache. * * @param null|string $id the id of this entry - pass null to clear. * @return PluralAbstract provides a fluent interface * @throws \InvalidArgumentException if id does not pass validation. */ public function setId($id) { if ($this->getId() !== $id) { $this->cache = array(); } return parent::setId($id); } /** * Determine if the given change id exists. * * @param string $id the id to check for. * @param ConnectionInterface $connection optional - a specific connection to use. * @return bool true if the given id matches an existing change. */ public static function exists($id, ConnectionInterface $connection = null) { // check id for valid format if (!static::isValidId($id)) { return false; } // if no connection given, use default. $connection = $connection ?: static::getDefaultConnection(); // default change always exists. if ($id === static::DEFAULT_CHANGE) { return true; } // attempt to fetch change - check message on failure. try { $connection->run('change', array('-o', $id)); return true; } catch (P4Exception $e) { if (preg_match('/Change [0-9]+ unknown/', $e->getMessage())) { return false; } throw $e; } } /** * Get the requested change entry from Perforce. * * @param string $id the id of the change to fetch. * @param ConnectionInterface $connection optional - a specific connection to use. * @return Change instance of the requested change. * @throws \InvalidArgumentException if no id is given. * @throws Spec\NotFoundException if no such change exists. */ public static function fetch($id, ConnectionInterface $connection = null) { // ensure a valid id is provided. if (!static::isValidId($id)) { throw new \InvalidArgumentException("Must supply a valid id to fetch."); } // if no connection given, use default. $connection = $connection ?: static::getDefaultConnection(); // attempt to fetch change - check message on failure. try { // for numbered changes we run '-o <id>'. for servers running // 2012.1+ we include -O to allow locating renamed changes. // for the default change simply run '-o'. $flags = array(); if ($id != static::DEFAULT_CHANGE) { $flags[] = $connection->isServerMinVersion('2012.1') ? '-Oo' : '-o'; $flags[] = $id; } else { $flags[] = '-o'; } $result = $connection->run('change', $flags)->expandSequences(); $spec = new static($connection); // if we fetched the default change ensure we have the id default. // the id would otherwise be 'new'. $data = $result->getData(-1); if ($id == static::DEFAULT_CHANGE) { $data['Change'] = $id; } // if we don't get any jobs back, that means the change doesn't have any // merge in an empty jobs array to avoid a needless populate call later $spec->setRawValues($data + array('Jobs' => array())) ->deferPopulate(); } catch (P4Exception $e) { if (preg_match('/Change [0-9]+ unknown|Invalid changelist number/', $e->getMessage())) { throw new NotFoundException( "Cannot fetch " . static::SPEC_TYPE . " $id. Record does not exist." ); } throw $e; } return $spec; } /** * Get all changes from Perforce. Adds filtering options. * * @param array $options optional - array of options to augment fetch behavior. * supported options are: * * FETCH_MAXIMUM - set to integer value to limit to the * first 'max' number of entries. * FETCH_BY_FILESPEC - set to a filespec to limit changes to those * affecting the file(s) matching the filespec. * FETCH_BY_IDS - set to an array of change IDs to limit results. * FETCH_BY_STATUS - set to a valid change status to limit result * to changes with that status (e.g. 'pending'). * FETCH_INTEGRATED - set to true to include changes integrated * into the specified files. * FETCH_BY_CLIENT - set to a client to limit changes to those * on the named client. * FETCH_BY_USER - set to a user to limit changes to those * owned by the named user. * * @param ConnectionInterface $connection optional - a specific connection to use. * @return FieldedIterator all records of this type. */ public static function fetchAll($options = array(), ConnectionInterface $connection = null) { // if fetch by ids was passed by is an empty array just return an empty result // otherwise the caller would actually get all changes back erroneously. $options += array(static::FETCH_BY_IDS => null); $ids = $options[static::FETCH_BY_IDS]; if (is_array($ids) && !count($ids)) { return new FieldedIterator; } // simply return parent - method exists to document options. return parent::fetchAll($options, $connection); } /** * Get all of the required fields. * Extends parent to remove 'Description' if present as, despite * being listed as required in the spec, it isn't required. * * @return array a list of required fields for this spec. */ public function getRequiredFields() { $fields = parent::getRequiredFields(); unset($fields[array_search('Description', $fields)]); return $fields; } /** * Extend parent to set id to 'new' if unset and to reopen files that * are open in other pending changelists where necessary. * * @param bool $force optional - default false - true to save submitted change. * @return Change provides a fluent interface * @throws UnopenedException if change contains unopened files. * @throws CommandException if save command fails for some reason. */ public function save($force = false) { $values = $this->getRawValues(); if (!isset($values[static::ID_FIELD]) || $this->isDefault()) { $values[static::ID_FIELD] = "new"; } // ensure all required fields have values. $this->validateRequiredFields($values); // can't update a submitted change without the force option. if (!$force && $this->isSubmitted()) { throw new Exception( "Cannot update a submitted change without the force option." ); } // don't attempt to set files on submitted changes if ($this->isSubmitted()) { unset($values['Files']); } // extend the list of jobs to include the fix status if fixStatus is set if ($this->fixStatus && isset($values['Jobs'])) { foreach ($values['Jobs'] as &$value) { $value .= ' ' . $this->fixStatus; } } // perform save. $connection = $this->getConnection(); try { $flags = array("-i"); if ($this->fixStatus) { $flags[] = "-s"; } if ($force) { $flags[] = $connection->isAdminUser(true) ? "-f" : "-u"; } $result = $connection->run(static::SPEC_TYPE, $flags, $values); // extract change number from last command result. $matches = false; foreach ($result->getData() as $data) { if (preg_match('/^Change ([^ ]+) (created|updated)/', $data, $matches)) { break; } } if (!$matches) { throw new Exception('Cannot determine number of saved change.'); } $id = $matches[1]; } catch (CommandException $e) { // if the exception was caused by non-existent jobs, the change should // have been created. if (preg_match( "/Change ([^ ]+) (created|updated).*Job '([^']+)' doesn't exist\./s", $e->getMessage(), $matches )) { $this->setId($matches[1]); throw $e; } // if exception not caused by un-opened files, re-throw. if (strpos($e->getMessage(), "Can't include file(s) not already opened.") === false) { throw $e; } // if any files are truly un-opened throw a special un-opened files exception. // (save will complain of un-opened files if files are not in default change) $flags = array("-Ro", "-T", "depotFile"); $flags = array_merge($flags, $values['Files']); $result = $connection->run("fstat", $flags); if ($result->hasWarnings()) { throw new UnopenedException( "Cannot save change. One or more files are not open." ); } // all files are actually open, save w.out files first, then reopen. $change = clone $this; $id = $change->setFiles(null)->save()->getId(); $flags = $values['Files']; array_unshift($flags, "-c", $id); $connection->run("reopen", $flags); } // Store the retrieved id. $this->setId($id); // should re-populate (server may change values). $this->deferPopulate(true); return $this; } /** * Save and submit this changelist. * * @param string $description optional - a description of this change. * @param null|string|array $options optional resolve flags, to be used if conflict * occurs. See resolve() for details. * @return Change provides fluent interface. * @throws Exception if the change is not a pending change. * @throws ConflictException if change contains files requiring resolve. */ public function submit($description = null, $options = null) { // ensure change is a pending change. if (!$this->isPending()) { throw new Exception("Can only submit pending changes."); } // if description is given, use it. if (strlen($description)) { $this->setDescription($description); } // save the change before submit. $this->save(); // try repeatedly to submit (with resolves in-between attempts) // note: no need to explicitly sync as submit schedules resolve for ($i = static::MAX_SUBMIT_ATTEMPTS; $i > 0; $i--) { try { $result = $this->getConnection()->run("submit", array("-c", $this->getId())); // everything went ok no need to retry. break; } catch (ConflictException $e) { // if there are no resolve options or we have exceeded // max resolve attempts; re-throw the resolve exception if ($i <= 1 || empty($options)) { throw $e; } // our id might have changed - update our id field to match // note we avoid setId() as it can cause a populate() $this->values[static::ID_FIELD] = $e->getChange()->getId(); $this->resolve($options); } } // extract change number, it could have noisy trigger output prior and/or // after the actual data so just scan till we find it. // note we avoid setId() as it can cause a populate() foreach ($result->getData() as $data) { if (is_array($data) && isset($data['submittedChange'])) { break; } } $this->values[static::ID_FIELD] = $data['submittedChange']; // we cache files and things - clear that out $this->cache = array(); return $this; } /** * Revert all of the files in this changelist. * * @return Change provides fluent interface. * @throws Exception if the change is not a pending change. */ public function revert() { // ensure change is a pending change. if (!$this->isPending()) { throw new Exception("Can only revert pending changes."); } // save the change before revert (updates files in change). $this->save(); // perform revert. $result = $this->getConnection()->run( "revert", array("-c", $this->getId(), '//...') ); // should re-populate. $this->deferPopulate(true); return $this; } /** * Delete this changelist. * * @param bool $force optional - defaults to false - set to true to force delete of * another user/client's changelist or a submitted (empty) change. * @return Change provides fluent interface. */ public function delete($force = false) { $id = $this->getId(); if ($id === null) { throw new Exception("Cannot delete change. No id has been set."); } // default change cannot be deleted. if ($id === Change::DEFAULT_CHANGE) { throw new Exception("Cannot delete the default change."); } // ensure id exists. $connection = $this->getConnection(); if (!static::exists($id, $connection)) { throw new NotFoundException( "Cannot delete change $id. Record does not exist." ); } // unknown or unhandled change status (e.g. 'shelved'). if (!$this->isPending() && !$this->isSubmitted()) { throw new Exception( "Unable to delete change with status '" . $this->getStatus() . "'." ); } // handle submitted changes. $connection = $this->getConnection(); if ($this->isSubmitted()) { // requires force option. if (!$force) { throw new Exception( "Cannot delete a submitted change without the force option." ); } // check for files. if (count($this->getFiles())) { throw new Exception( "Cannot delete a submitted change that contains files." ); } $result = $connection->run("change", array("-d", "-f", $id)); } // handle pending changes (must remove files and fixes first). if ($this->isPending()) { if (!$force && ($this->getUser() !== $connection->getUser() || $this->getClient() !== $connection->getClient())) { throw new Exception( "Cannot delete a change from another user/client without the force option." ); } // remove any associated files or jobs. $change = clone $this; $change->setFiles(null)->setJobs(null)->save(); // delete the change. $flags = array("-d", $id); if ($force) { array_unshift($flags, "-f"); } $connection->run("change", $flags); } // confirm delete successful (change -d does not surface errors). if (static::exists($id)) { throw new Exception("Failed to delete change $id."); } // should re-populate. $this->deferPopulate(true); return $this; } /** * Resolves the change based on the passed option(s). * * You must specify one of the below: * RESOLVE_ACCEPT_MERGED * Automatically accept the Perforce-recom mended file revision: * if theirs is identical to base, accept yours; if yours is identical * to base, accept theirs; if yours and theirs are different from base, * and there are no conflicts between yours and theirs; accept merge; * other wise, there are conflicts between yours and theirs, so skip this file. * RESOLVE_ACCEPT_YOURS * Accept Yours, ignore theirs. * RESOLVE_ACCEPT_THEIRS * Accept Theirs. Use this flag with caution! * RESOLVE_ACCEPT_SAFE * Safe Accept. If either yours or theirs is different from base, * (and the changes are in common) accept that revision. If both * are different from base, skip this file. * RESOLVE_ACCEPT_FORCE * Force Accept. Accept the merge file no matter what. If the merge file * has conflict markers, they will be left in, and you'll need to remove * them by editing the file. * * Additionally, one of the following whitespace options can, optionally, be passed: * IGNORE_WHITESPACE_CHANGES * Ignore whitespace-only changes (for instance, a tab replaced by eight spaces) * IGNORE_WHITESPACE * Ignore whitespace altogether (for instance, deletion of tabs or other whitespace) * IGNORE_LINE_ENDINGS * Ignore differences in line-ending convention * * Lastly, the resolve can be limited to a particular file in the change by passing: * RESOLVE_FILE => filespec with no wildcards * * @param array|string $options Resolve option(s); must include a RESOLVE_* preference. * @return Change provide fluent interface. * @todo implement a way to accept edit */ public function resolve($options) { if (is_string($options)) { $options = array($options); } if (!is_array($options)) { throw new \InvalidArgumentException('Expected a string or array of options.'); } $mode = ''; $whitespace = ''; $arguments = array(); // loop options so we accept the last mode // and whitespace setting we encounter. foreach ($options as $option) { switch ($option) { case static::RESOLVE_ACCEPT_MERGED: $mode = '-am'; break; case static::RESOLVE_ACCEPT_YOURS: $mode = '-ay'; break; case static::RESOLVE_ACCEPT_THEIRS: $mode = '-at'; break; case static::RESOLVE_ACCEPT_SAFE: $mode = '-as'; break; case static::RESOLVE_ACCEPT_FORCE: $mode = '-af'; break; } switch ($option) { case static::IGNORE_WHITESPACE_CHANGES: $whitespace = '-db'; break; case static::IGNORE_WHITESPACE: $whitespace = '-dw'; break; case static::IGNORE_LINE_ENDINGS: $whitespace = '-dl'; break; } } // we can't do anything without a mode; throw if (empty($mode)) { throw new \InvalidArgumentException( 'No action specified. Expected Resolve Accept Merged|Yours|Theirs|Safe|Force' ); } // compile our various flags into our arguments array $arguments[] = $mode; if ($whitespace) { $arguments[] = $whitespace; } $files = $this->getFiles(); if (isset($options[static::RESOLVE_FILE])) { $file = $options[static::RESOLVE_FILE]; if (!in_array($file, $files)) { throw new \InvalidArgumentException( "The RESOLVE_FILE specified is not in this change." ); } $files = array($file); } // resolve files in several (as few as possible) runs as // there is a potential to exceed the arg-max on this command $connection = $this->getConnection(); $batches = $connection->batchArgs($files, $arguments); foreach ($batches as $batch) { $connection->run('resolve', $batch); } return $this; } /** * Set the fix status on jobs attached with this change. It will become the job's * status when the change is submitted (thus we don't allow to set it on already * submitted changes). * * @param string|null $fixStatus status to set on jobs when this change is submitted * to defer to the default behaviour set the value to null * @return Change provides fluent interface. * @throws \InvalidArgumentException if fix status is incorrect type * @throws Exception if change is submitted */ public function setFixStatus($fixStatus) { if (!is_string($fixStatus) && $fixStatus !== null) { throw new \InvalidArgumentException('Job fix status must be a string or null.'); } // setting job's fix status makes sense only on unsubmitted changes if ($this->isSubmitted()) { throw new Exception('Cannot set job fix status on submitted changes.'); } $this->fixStatus = $fixStatus; return $this; } /** * Get the date this change was last modified on the server. * * @return null|string the date this change was last modified on the server, * or null if the change does not exist on the server. * @todo modify to use DateTime object. */ public function getDate() { return $this->getRawValue('Date'); } /** * Get the unixtime this change was last modified on the server. * * @return int|null the unixtime this change was last modified on the server, * or null if the change does not exist on the server. */ public function getTime() { return static::dateToTime($this->getDate(), $this->getConnection()) ?: null; } /** * Get the user that created this change. * * @return string the user that created this change. */ public function getUser() { $user = $this->getRawValue('User'); if (!$user) { $user = $this->getConnection()->getUser(); } return $user; } /** * Set the user that created this change. * * @param $user string|User the user that created this change. * @return Change provides a fluent interface. * @throws \InvalidArgumentException if bad type given for user */ public function setUser($user) { $user = $user instanceof User ? $user->getId() : $user; if ($user !== null && !is_string($user)) { throw new \InvalidArgumentException("Cannot set user. Invalid type given."); } return $this->setRawValue('User', $user); } /** * Get the client on which this change was created. * * @return string the client on which this change was created. */ public function getClient() { $client = $this->getRawValue('Client'); if (!$client) { $client = $this->getConnection()->getClient(); } return $client; } /** * Set the client on this change. * * @param string|Client|null $client the client to set on this change * @return Change provides a fluent interface. * @throws \InvalidArgumentException if bad type given for client */ public function setClient($client) { // normalize input to a string if object given if ($client instanceof Client) { $client = $client->getId(); } if ($client !== null && !is_string($client)) { throw new \InvalidArgumentException("Cannot set client. Invalid type given."); } return $this->setRawValue('Client', $client); } /** * Get the status of this change (either 'pending' or 'submitted'). * * @return string the status of this change: 'pending', 'submitted'. */ public function getStatus() { $status = $this->getRawValue('Status'); if (!$status) { $status = static::PENDING_CHANGE; } return $status; } /** * Get the type of this change (either 'public' or 'restricted'). * * @return string the type of this change: 'public' or 'restricted'. */ public function getType() { $type = $this->hasField('Type') ? $this->getRawValue('Type') : false; if (!$type) { $type = static::PUBLIC_CHANGE; } return $type; } /** * Set the type of this change (either 'public' or 'restricted'). * * @return Change provides fluent interface */ public function setType($type) { if ($type != static::PUBLIC_CHANGE && $type !== static::RESTRICTED_CHANGE) { throw new \InvalidArgumentException("Cannot set type. Type must be public or restricted."); } return $this->setRawValue('Type', $type); } /** * Get the description for this change. * * @return string the description for this change. */ public function getDescription() { return $this->getRawValue('Description'); } /** * Set the description for this change. * * @param string|null $description description for this change. * @return Change provides a fluent interface. * @throws \InvalidArgumentException if description is incorrect type. */ public function setDescription($description) { if ($description !== null && !is_string($description)) { throw new \InvalidArgumentException("Cannot set description. Invalid type given."); } return $this->setRawValue('Description', $description); } /** * Get the job status of this change (the status that associated jobs will * have when the change is submitted). * * The value of the job status field is not preserved. You cannot get the * job status of a saved or submitted change. Once a changelist is saved or * submitted, the job status field is cleared. It can only be read after it * has been explicitly set, and before the change is saved or submitted. * * @return null|string the job status of this change if not yet saved or submitted. * null otherwise. */ public function getJobStatus() { return $this->getRawValue('JobStatus'); } /** * Get the jobs attached to this change. * * @return array the list of jobs attached to this change. * @todo return Job objects in an iterator. */ public function getJobs() { $jobs = $this->getRawValue('Jobs'); return is_array($jobs) ? $jobs : array(); } /** * Get the jobs attached to this change in Job format. * * @return FieldedIterator list of Job's associated with this change. */ public function getJobObjects() { // just skip to an empty iterator if we have no jobs if (!$this->getJobs()) { return new FieldedIterator; } if (!isset($this->cache['jobObjects']) || !$this->cache['jobObjects'] instanceof FieldedIterator ) { $this->cache['jobObjects'] = Job::fetchAll( array(Job::FETCH_BY_IDS => $this->getJobs()), $this->getConnection() ); } return clone $this->cache['jobObjects']; } /** * Get the requested job attached to this change in Job format. * * @param string $job Job identifier * @return Job The requested job * @throws \InvalidArgumentException If the specified job isn't attached to this change. */ public function getJobObject($job) { // validate input if (!is_string($job)) { throw new \InvalidArgumentException('Job must be a string or P4\Job object.'); } foreach ($this->getJobObjects() as $jobObject) { if ($jobObject->getId() == $job) { return $jobObject; } } throw new \InvalidArgumentException('The requested job was not found in this change'); } /** * Set the list of jobs attached to this change. * * @param null|array|FieldedIterator $jobs the jobs to attach to this change. * @return Change provides a fluent interface. * @throws \InvalidArgumentException if jobs is incorrect type. * @throws Exception if change is submitted. */ public function setJobs($jobs) { if ($jobs === null) { $jobs = array(); } // normalize to an array if ($jobs instanceof FieldedIterator) { $jobs = $jobs->toArray(true); } // ensure jobs is an array. if (!is_array($jobs)) { throw new \InvalidArgumentException('Cannot set jobs. Invalid type given.'); } // ensure job elements are strings or job objects. foreach ($jobs as $key => $job) { if ($job instanceof Job) { $jobs[$key] = $job = $job->getId(); } if (!is_string($job)) { throw new \InvalidArgumentException('Each job must be a string.'); } } // don't permit set jobs on submitted changes. if ($this->isSubmitted()) { throw new Exception('Cannot set jobs on a submitted change.'); } // we cache job objects; clear that out $this->cache = array(); return $this->setRawValue('Jobs', $jobs); } /** * Add a job to the list of jobs attached to this change. * * @param string $job the id of the job to attach to this change. * @return Change provides fluent interface. */ public function addJob($job) { $jobs = $this->getJobs(); if (!in_array($job, $jobs)) { $jobs[] = $job; } $this->setJobs($jobs); return $this; } /** * Get the files attached to this change. Revspecs are included for submitted * changes but are not present on pending changes. * * @return array list of files associated with this change. */ public function getFiles() { $files = $this->getRawValue('Files'); return is_array($files) ? $files : array(); } /** * Get the files attached to this change in File format. * * @return FieldedIterator list of File's associated with this change. */ public function getFileObjects() { if (!isset($this->cache['fileObjects']) || !$this->cache['fileObjects'] instanceof FieldedIterator ) { $this->cache['fileObjects'] = File::fetchAll( FileQuery::create()->addFilespecs( $this->getFiles() ), $this->getConnection() ); } return clone $this->cache['fileObjects']; } /** * For numbered changes, it is possible to get additional information * about the files attached to the change (e.g. action, type, rev, from-file). * * This method is a useful alternative to getFileObjects() if you don't * want to incur the cost of an fstat query and the instantiation of files. * * @param bool $shelved optional - get shelved files instead of open files (default false) * @param int|false|null $max optional - limit number of files to report (optimized in 2014.1+) * if set to false, return any pre-existing cached file data * @return array a list of files with metadata (e.g. action, type, rev, from-file) */ public function getFileData($shelved = false, $max = null) { $cacheKeyPrefix = 'fileData-' . ($shelved ? 'shelved-' : ''); // if max is explicitly false, caller doesn't care how many files we return // pick the cached result with the highest number of files if ($max === false) { foreach (array_keys($this->cache) as $cacheKey) { if (strpos($cacheKey, $cacheKeyPrefix) === 0) { $max = max($max, end(explode('-', $cacheKey))); } } } $max = (int) $max; $cacheKey = $cacheKeyPrefix . $max; if (!isset($this->cache[$cacheKey])) { // note: we don't supply '-s' to suppress diffs because this breaks // fromFile information - fortunately, we don't need -s because we // are getting tagged output which suppresses diffs automatically // (see job058799 for more information). $flags = $max && $this->getConnection()->isServerMinVersion('2014.1') ? array('-m', $max) : array(); $flags = array_merge($flags, $shelved ? array('-S', $this->getId()) : array($this->getId())); $data = $this->getConnection()->run('describe', $flags)->expandSequences()->getData(0); // turn describe data inside-out (currently keyed on field name // with entries for each file, re-key by file, then field). $files = array(); foreach ($data as $key => $values) { // skip over job/jobstat as they aren't file related if (strpos($key, 'job') === 0) { continue; } if (is_array($values)) { foreach ($values as $index => $value) { if ($max && $index >= $max) { break; } $files[$index] = isset($files[$index]) ? $files[$index] + array($key => $value) : array($key => $value); } } // record the path so we can provide it later via getPath(). // the 'path' is the deepest path common to all files. if ($key === 'path') { $this->cache['path'] = $values; } // record the 'oldChange' so we can provide it later via // getOriginalId() - old change is the pre-submit id. if ($key === 'oldChange') { $this->cache['oldChange'] = $values; } // record the 'shelved' property so we can use it later // needed to detect remote/un-promoted/shelved changes if ($key === 'shelved') { $this->cache['shelved'] = true; } } // if we didn't encounter a 'shelved' property, it must be false if (!isset($this->cache['shelved'])) { $this->cache['shelved'] = false; } $this->cache[$cacheKey] = $files; } return $this->cache[$cacheKey]; } /** * Get p4 changes list data for a submitted change. * * This is useful because changes reports some information that is not * included in spec data; specifically path and oldChange. The describe * command also reports this data (accessible via getFileData()), but * it includes all files in the change which can be too much data. * * @return array $data p4 changes data from Perforce. * @throws Exception if called on a pending change. */ public function getChangesData() { // not available for pending changes. if ($this->isPending()) { throw new Exception("Cannot get changes data for a pending change."); } if (!isset($this->cache['changesData'])) { $this->cache['changesData'] = $this->getConnection()->run( 'changes', array('@=' . $this->getId()) )->getData(0); } return $this->cache['changesData']; } /** * Get the deepest path common to all files in this change. * * @param bool $trim remove trailing wildcards and partial paths * @param int $maxFiles optional - limit number of files to scan (can cause incorrect result) * @return string the deepest path common to all files. */ public function getPath($trim = true, $maxFiles = null) { // if the path is not cached and this change is // submitted, use p4 changes data to get path if (!array_key_exists('path', $this->cache) && $this->isSubmitted()) { $data = $this->getChangesData(); $this->cache['path'] = $data['path']; } // if path is still not set, use file data (which primes path) if (!array_key_exists('path', $this->cache)) { $paths = $this->getFileData(false, $maxFiles); // compute the common path if it wasn't sent by the server. // the server doesn't report the path for shelved changes. // note: because the paths are sorted, we can get away with // a clever trick and just compare the first and last file. if (!array_key_exists('path', $this->cache) && count($paths)) { $first = $paths[0]['depotFile']; $last = $paths[count($paths) - 1]['depotFile']; $length = min(strlen($first), strlen($last)); $compare = $this->getConnection()->isCaseSensitive() ? 'strcmp' : 'strcasecmp'; for ($i = 0; $i < $length; $i++) { if ($compare($first[$i], $last[$i]) !== 0) { break; } } $this->cache['path'] = substr($first, 0, $i); } elseif (!array_key_exists('path', $this->cache)) { $this->cache['path'] = null; } } $path = $this->cache['path']; return $trim ? substr($path, 0, strrpos($path, '/')) : $path; } /** * Get the original change id. When a change is submitted it is * potentially renumbered to be the highest numbered change. * If the change is not renumbered, returns getId(). * * @return int the original id, falling back to the current id */ public function getOriginalId() { // if the original id is not cached and this change is // submitted, use p4 changes data to get original id if (!array_key_exists('oldChange', $this->cache) && $this->isSubmitted()) { $data = $this->getChangesData(); $this->cache['oldChange'] = isset($data['oldChange']) ? $data['oldChange'] : null; } return isset($this->cache['oldChange']) ? $this->cache['oldChange'] : $this->getId(); } /** * Get the requested file attached to this change in File format. * * @param File|string $file Filespec in string or File format; rev is ignored * @return File The requested file at the revision associated with this change * @throws \InvalidArgumentException If the specified file doesn't exist. */ public function getFileObject($file) { // normalize to string if ($file instanceof File) { $file = $file->getDepotFilename(); } // validate input if (!is_string($file)) { throw new \InvalidArgumentException('File must be a string or P4\File object.'); } // ensure no rev-spec is present on our comparison entry $file = File::stripRevspec($file); foreach ($this->getFileObjects() as $changeFile) { if (File::stripRevspec($changeFile->getDepotfilename()) == $file) { return $changeFile; } } throw new \InvalidArgumentException('The requested file was not found in this change'); } /** * Set the list of opened files attached to this change. * * @param null|array|FieldedIterator $files the files to attach to this change. * @return Change provides a fluent interface. * @throws \InvalidArgumentException if files is incorrect type. * @throws Exception if change is submitted. */ public function setFiles($files) { if ($files === null) { $files = array(); } if ($files instanceof FieldedIterator) { $files = iterator_to_array($files); } // ensure files is an array. if (!is_array($files)) { throw new \InvalidArgumentException('Cannot set files. Invalid type given.'); } // normalize the array entries to a string, stripping revspecs foreach ($files as &$file) { if ($file instanceof File) { $file = $file->getFilespec(); } if (!is_string($file)) { throw new \InvalidArgumentException('All files must be a string or P4\File'); } $file = File::stripRevspec($file); } // don't permit set files on submitted changes. if ($this->isSubmitted()) { throw new Exception('Cannot set files on a submitted change.'); } // we cache file objects; clear that out $this->cache = array(); $this->setRawValue('Files', $files); return $this; } /** * Add a file to the list of files in this changelist. * * @param string|File $file the file to attach to this change. * @return Change provides fluent interface. */ public function addFile($file) { // if file is a File object, extract the filespecs. if ($file instanceof File) { $file = $file->getFilespec(); } $files = $this->getFiles(); if (!in_array($file, $files)) { $files[] = $file; } $this->setFiles($files); return $this; } /** * Check if this is a pending change. * * @return bool true if this is a pending change - false otherwise. */ public function isPending() { return ($this->isDefault() || $this->getStatus() === static::PENDING_CHANGE); } /** * Check if this is a submitted change. * * @return bool true if this is a submitted change - false otherwise. */ public function isSubmitted() { return ($this->getStatus() === static::SUBMITTED_CHANGE); } /** * Test if this is the default change. * * @return bool true if this is the default change - false otherwise. */ public function isDefault() { return ($this->getId() === static::DEFAULT_CHANGE); } /** * Test if this change is accessible. Returns false if the change is * restricted and the description is obfuscated to indicate user * has no permission to view it, true otherwise. * * @return bool false if this change is not accessible, true * otherwise */ public function canAccess() { return $this->getType() !== static::RESTRICTED_CHANGE || trim($this->getDescription()) !== "<description: restricted, no permission to view>"; } /** * Get files that need to be resolved. * * @return FieldedIterator files that need to be resolved. */ public function getFilesToResolve() { $query = FileQuery::create() ->addFilespec(File::ALL_FILES) ->setLimitToChangelist($this->getId()) ->setLimitToNeedsResolve(true) ->setLimitToOpened(true); return File::fetchAll($query, $this->getConnection()); } /** * Get files that must be reverted. * * @return FieldedIterator files that must be reverted. */ public function getFilesToRevert() { // setup fstat filter to match files that must be reverted. // several conditions to catch: // - files open for add, but already existing and not deleted, or // - files that are not open for add, but are deleted in the depot. $filter = "(action=add & headAction=* & ^headAction=...deleted) | " . "(headAction=...delete & ^action=add)"; $query = FileQuery::create() ->addFilespec(File::ALL_FILES) ->setLimitToChangelist($this->getId()) ->setLimitToOpened(true) ->setFilter($filter); return File::fetchAll($query, $this->getConnection()); } /** * Get a field's raw value. * Extend parent to ensure the Description's placeholder value is translated to null. * * @param string $field the name of the field to get the value of. * @return mixed the value of the field. * @throws Exception if the field does not exist. */ public function getRawValue($field) { $value = parent::getRawValue($field); // if the Description field has its placeholder value just return null // you can't actually save the placeholder value its reserved. if ($field == 'Description' && $value == "<enter description here>\n") { return null; } return $value; } /** * Set a field's raw value (avoids mutator). * Extended to limit to allow setting client. We think its read only based * on the spec definition but it really isn't. * * @param string $field the name of the field to set the value of. * @param mixed $value the value to set in the field. * @return SingularAbstract provides a fluent interface * @throws Exception if the field does not exist or is read-only */ public function setRawValue($field, $value) { if ($field == 'Client') { $this->values[$field] = $value; return $this; } return parent::setRawValue($field, $value); } /** * Attempt to detect if this change is a un-promoted shelved change on a remote edge server. * * We cannot do this with 100% accuracy, but we can make an educated guess. * If we satisfy the following conditions it is likely a remote edge shelf: * - server version > 2014.1 * - not promoted * - shelved change * - no files * * @return bool true if the change looks like a remote edge shelf */ public function isRemoteEdgeShelf() { // if the server is older than 2014.1, there is no such thing if (!$this->getConnection()->isServerMinVersion('2014.1')) { return false; } // if the change is promoted, then it is global if ($this->hasField('IsPromoted') && $this->get('IsPromoted') == 1) { return false; } // we need file data (describe output) for this next part $files = $this->getFileData(true, false); // if the change is not shelved, it cannot be an edge shelf if (!$this->cache['shelved']) { return false; } // if we can see files in the change, it cannot be a remote edge shelf if (count($files)) { return false; } // hmmm... looks inaccessible, likely a remote edge shelf return true; } /** * Check if the given id is in a valid format for a change number. * * @param string $id the id to check * @return bool true if id is valid, false otherwise */ protected static function isValidId($id) { $validator = new Validate\ChangeNumber; return $validator->isValid($id); } /** * Get raw spec data direct from Perforce. No caching involved. * Overrides parent to suppress id when id is 'default' and to * fetch files for submitted changes. * * @return array $data the raw spec output from Perforce. * @todo get jobs for submitted changes. */ protected function getSpecData() { $flags = array('-o'); if ($this->getId() !== static::DEFAULT_CHANGE) { $flags[] = $this->getId(); } $data = $this->getConnection() ->run(static::SPEC_TYPE, $flags) ->expandSequences() ->getData(-1); // get files if this is a submitted change // note: can't use isSubmitted here - populate not complete yet. if ($data['Status'] == Change::SUBMITTED_CHANGE) { $describe = $this->getConnection() ->run('describe', array('-s', $this->getId())) ->getData(0); $data['Files'] = array(); for ($i = 0; isset($describe['depotFile' . $i], $describe['rev' . $i]); $i++) { $data['Files'][] = $describe['depotFile' . $i] . '#' . $describe['rev' . $i]; } } return $data; } /** * Produce set of flags for the spec list command, given fetch all options array. * Extends parent to add support for additional options. * * @param array $options array of options to augment fetch behavior. * see fetchAll for documented options. * @return array set of flags suitable for passing to spec list command. */ protected static function getFetchAllFlags($options) { $flags = parent::getFetchAllFlags($options); // always use -l (for full descriptions). $flags[] = "-l"; if (isset($options[static::FETCH_INTEGRATED]) && $options[static::FETCH_INTEGRATED] === true) { $flags[] = "-i"; } if (isset($options[static::FETCH_BY_STATUS])) { $flags[] = "-s"; $flags[] = $options[static::FETCH_BY_STATUS]; } if (isset($options[static::FETCH_BY_CLIENT])) { $flags[] = "-c"; $flags[] = $options[static::FETCH_BY_CLIENT]; } if (isset($options[static::FETCH_BY_USER])) { $flags[] = "-u"; $flags[] = $options[static::FETCH_BY_USER]; } if (isset($options[static::FETCH_BY_IDS]) && is_array($options[static::FETCH_BY_IDS])) { foreach ($options[static::FETCH_BY_IDS] as $id) { $flags[] = '@' . $id . ',@' . $id; } } // filespec must come last. if (isset($options[static::FETCH_BY_FILESPEC]) && $options[static::FETCH_BY_FILESPEC]) { $flags[] = $options[static::FETCH_BY_FILESPEC]; } return $flags; } /** * Given a spec entry from spec list output (p4 changes), produce * an instance of this spec with field values set where possible. * * @param array $listEntry a single spec entry from spec list output. * @param array $flags the flags that were used for this 'fetchAll' run. * @param ConnectionInterface $connection a specific connection to use. * @return Change a (partially) populated instance of this spec class. */ protected static function fromSpecListEntry($listEntry, $flags, ConnectionInterface $connection) { // rename 'desc' field to 'Description'. $listEntry['Description'] = $listEntry['desc']; unset($listEntry['desc']); $change = parent::fromSpecListEntry($listEntry, $flags, $connection); // record the 'path' so we can provide it later via getPath(). // record the 'oldChange' so we can provide it later via getOriginalId(). $change->cache['path'] = isset($listEntry['path']) ? $listEntry['path'] : null; $change->cache['oldChange'] = isset($listEntry['oldChange']) ? $listEntry['oldChange'] : null; return $change; } }