- <?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;
- }
- }