- <?php
- /**
- * Abstracts operations against Perforce files.
- *
- * THEORY OF OPERATION
- *
- * Unlike a typical database, all changes to Perforce files must be pended to
- * the current client workspace before they can be committed.
- *
- * The file model provides access to two copies of file data: the submitted
- * depot copy and the client workspace copy. When you are accessing file data
- * (be it file contents or file attributes), you must consider which of these
- * sources you want to get the data from.
- *
- * For example, if you call getDepotContents() you will get the submitted depot
- * copy of the file; whereas, if you call getLocalContents() you will get the
- * contents of the client workspace file.
- *
- * The class attempts to faithfully represent the behavior of Perforce. There
- * is, however, some simplification at work. In particular, the open() method
- * will automatically add or edit a file as appropriate. It will also sync the
- * file to the client if necessary.
- *
- * Similarly, if a file is open for delete, the add, edit and open methods will
- * revert the file and reopen it. Conversely, if delete() is called on a file
- * that is opened (but not for delete), the file will be reverted and then
- * deleted(). To suppress this behavior, pass false as the force option.
- *
- *
- * COMMON USAGE
- *
- * To fetch a file from Perforce, call fetch() and pass the filespec of the
- * file you wish to retrieve. For example:
- *
- * $file = \P4\File\File::fetch('//depot/file');
- *
- * To fetch several files, call fetchAll() and pass a file query object
- * representing the fstat options that you wish to use. For example:
- *
- * $files = \P4\File\File::fetchAll(
- * new \P4\File\Query(array('filespecs' => '//depot/path/...'))
- * );
- *
- * The query class also has options to filter, sort and limit files. See the
- * \P4\File\Query class for additional details.
- *
- * To submit a file:
- *
- * $file = new P4\File\File;
- * $file->setFilespec('//depot/file');
- * $file->open();
- * $file->setLocalContents('new file content');
- * $file->submit('Description of change');
- *
- * To delete a file:
- *
- * $file->delete();
- * $file->submit('Description of change');
- *
- * @copyright 2011 Perforce Software. All rights reserved.
- * @license Please see LICENSE.txt in top-level folder of this distribution.
- * @version <release>/<patch>
- * @todo make fluent.
- * @todo give submit a clobber option.
- * @todo
- * diff($file)
- * getFixes()
- * integrate()
- * getIntegrations()
- * getInterchanges()
- * getLabels()
- * getProtections()
- * move()
- * getReviewers()
- * getSize()
- * tag($rev)
- * untag($rev)
- */
-
- namespace P4\File;
-
- use P4;
- use P4\Filter\Utf8 as Utf8Filter;
- use P4\Validate;
- use P4\Spec\Change;
- use P4\File\Exception\Exception;
- use P4\File\Exception\NotFoundException;
- use P4\Connection\ConnectionInterface;
- use P4\Connection\Exception\CommandException;
- use P4\Connection\Exception\ConflictException;
- use P4\Model\Resolvable\ResolvableInterface;
- use P4\Model\Connected\ConnectedAbstract;
- use P4\Model\Fielded\FieldedInterface;
- use P4\Model\Fielded\Iterator as FieldedIterator;
- use P4\OutputHandler\Limit;
-
- class File extends ConnectedAbstract implements FieldedInterface, ResolvableInterface
- {
- const ALL_FILES = '//...';
- const MAX_FILESIZE = 'maxSize';
- const REVERT_UNCHANGED = 'unchanged';
-
- const UTF8_CONVERT = 'convert';
- const UTF8_SANITIZE = 'sanitize';
-
- const ANNOTATE_CHANGES = 'changes';
- const ANNOTATE_INTEG = 'integ';
- const ANNOTATE_CONTENT = 'content';
-
- protected $cache = array();
- protected $filespec = null;
-
- /**
- * Implement FieldedInterface.
- * Get the file info as an array.
- *
- * @return array the file info as an array.
- */
- public function toArray()
- {
- $values = array();
- foreach ($this->getFields() as $field) {
- $values[$field] = $this->get($field);
- }
-
- return $values;
- }
-
- /**
- * Implement FieldedInterface.
- * Check if given field is valid model field.
- *
- * @param string $field model field to check
- * @return boolean
- */
- public function hasField($field)
- {
- return $this->hasStatusField($field);
- }
-
- /**
- * Implement FieldedInterface.
- * Return array with all model fields.
- *
- * @return array
- */
- public function getFields()
- {
- return array_keys($this->getStatus());
- }
-
- /**
- * Implement FieldedInterface.
- * Return value of given field of the model.
- *
- * @param string $field model field to retrieve
- * @return mixed
- */
- public function get($field)
- {
- return $this->getStatus($field);
- }
-
- /**
- * Set the filespec identifier for the file/revision.
- * Filespec may be given in depot, client or local file-system
- * syntax. The filename may be followed by a revision specifier.
- * Wildcards are not permitted in the filespec.
- *
- * For more information on filespecs visit:
- * http://perforce.com/perforce/doc.current/manuals/cmdref/o.fspecs.html
- *
- * Note: The instance cache is cleared when the filespec changes.
- *
- * @param string $filespec the filespec of the file.
- * @return File provide fluent interface.
- */
- public function setFilespec($filespec)
- {
- static::validateFilespec($filespec);
- $this->filespec = $filespec;
-
- // identity has changed - clear all of the instance caches.
- $this->cache = array();
-
- return $this;
- }
-
- /**
- * Get the filespec used to identify this file.
- * If a revision specifier was passed to setFilespec or fetch, it
- * will be returned here; otherwise, no revision specifier will
- * be present.
- *
- * @param bool $stripRevspec optional - revspecs will be removed, if present, when true
- * @return string the filespec of the file.
- */
- public function getFilespec($stripRevspec = false)
- {
- return $stripRevspec ? static::stripRevspec($this->filespec) : $this->filespec;
- }
-
- /**
- * Get the filespec used to identify this file including
- * a revision specification if one is known.
- *
- * If getFilespec includes a revspec, this value is used.
- * Otherwise, if we have fetched file contents or status
- * the corresponding numeric revision is used.
- *
- * @return string the filespec with a revision specifier if one is known.
- */
- public function getFilespecWithRevision()
- {
- $filespec = $this->getFilespec();
-
- if ($filespec === null || static::hasRevspec($filespec)) {
- return $filespec;
- }
-
- $revision = '';
- if (isset($this->cache['revision'])) {
- $revision = '#' . $this->cache['revision'];
- }
-
- return $this->filespec . $revision;
- }
-
- /**
- * Get the revision specifier of this file.
- *
- * If getFilespec includes a revspec, this value is used.
- * Otherwise, if we have fetched file contents or status
- * the corresponding numeric revision is used.
- *
- * @return string the revspec of the file.
- */
- public function getRevspec()
- {
- return static::extractRevspec($this->getFilespecWithRevision());
- }
-
- /**
- * If any of the characters @#%* occur in a filename they will be
- * encoded as %40 %23 %25 %2A respectively when using depot or client
- * syntax. If these files are synced to the local disc p4api will
- * automatically unescape the filename. Running a depot path through
- * this method will provide the unescaped filename as it would appear
- * on local disc.
- *
- * @param string $filespec the filespec to decode
- * @return string the decoded filespec
- */
- public static function decodeFilespec($filespec)
- {
- return rawurldecode($filespec);
- }
-
- /**
- * Fetch a model of the given filespec.
- *
- * @param string $filespec a filespec with no wildcards - the filespec may
- * be in any one of depot, client or local file syntax.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @param bool $excludeDeleted optional - exclude deleted files (defaults to false).
- */
- public static function fetch($filespec, ConnectionInterface $connection = null, $excludeDeleted = false)
- {
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // determine whether the file exists.
- $info = self::exists($filespec, $connection, $excludeDeleted);
- if ($info === false) {
- throw new NotFoundException(
- "Cannot fetch file '$filespec'. File does not exist."
- );
- }
-
- // create new file instance and set the key.
- $file = new static($connection);
- $file->setFilespec($filespec);
- $file->_cache['revision'] = isset($info['rev']) ? $info['rev'] : null;
- $file->_cache['depotFile'] = isset($info['depotFile']) ? $info['depotFile'] : null;
-
- return $file;
- }
-
- /**
- * Fetch all files matching the given query.
- *
- * @param Query|array $query A query object or array expressing fstat options.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @return FieldedIterator List of retrieved files.
- * @throws \InvalidArgumentException if no filespec is given.
- */
- public static function fetchAll($query, ConnectionInterface $connection = null)
- {
- if (!$query instanceof Query && !is_array($query)) {
- throw new \InvalidArgumentException(
- 'Query must be a P4\File\Query or array.'
- );
- }
-
- // normalize array input to a query
- if (is_array($query)) {
- $query = new Query($query);
- }
-
- // ensure caller provided a filespec.
- if (!count($query->getFilespecs())) {
- throw new \InvalidArgumentException(
- 'Cannot fetch files. No filespecs provided in query.'
- );
- }
-
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // get fstat flags for given query options and run fstat command.
- $flags = array_merge($query->getFstatFlags(), $query->getFilespecs());
-
- // check server version to see if attribute sort is supported
- if (in_array('-S', $flags) && !$connection->isServerMinVersion('2011.1')) {
- throw new Exception('Cannot sort by attributes for server versions < 2011.1');
- }
-
- // try/catch parent to deal with the exception we get on non-existend depots
- try {
- $result = $connection->run('fstat', $flags);
- } catch (CommandException $e) {
- // if the 'depot' has been interpreted as an invalid client, just return no matches
- if (preg_match("/Command failed: .+ - must refer to client/", $e->getMessage())) {
- return new FieldedIterator;
- }
-
- // unexpected error; rethrow it
- throw $e;
- }
-
- // if fetching by change, the last block of data contains
- // the change description - remove it (unless we're fetching
- // from the default changelist)
- $dataBlocks = $result->getData();
- if ($query->getLimitToChangelist() !== null
- && $query->getLimitToChangelist() !== Change::DEFAULT_CHANGE) {
- array_pop($dataBlocks);
- }
-
- // generate file models from fstat output.
- $files = new FieldedIterator;
- foreach ($dataBlocks as $data) {
- $file = new static($connection);
- $file->setFilespec($data['depotFile']);
- $file->setStatusCache($data);
-
- $files[] = $file;
- }
-
- return $files;
- }
-
- /**
- * Count files matching the given query.
- * This is a faster alternative to counting the result of fetchAll().
- *
- * @param Query|array $query A query object or array expressing fstat options.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @return FieldedIterator count of matching files.
- * @todo optimize to only fetch a single field per file.
- */
- public static function count($query, ConnectionInterface $connection = null)
- {
- if (!$query instanceof Query && !is_array($query)) {
- throw new \InvalidArgumentException(
- 'Query must be a P4\File\Query or array.'
- );
- }
-
- // normalize array input to a query
- if (is_array($query)) {
- $query = new Query($query);
- }
-
- // ensure caller provided a filespec.
- if (!count($query->getFilespecs())) {
- throw new \InvalidArgumentException(
- 'Cannot count files. No filespecs provided in query.'
- );
- }
-
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // remove options that cause unnecessary work for the server
- $query = clone $query;
- $query->setSortBy(null)->setReverseOrder(false);
-
- // only fetch a single field for performance.
- $query->setLimitFields('depotFile');
-
- // get fstat flags for given query and run fstat command.
- $flags = array_merge($query->getFstatFlags(), $query->getFilespecs());
- $result = $connection->run('fstat', $flags);
- $count = count($result->getData());
-
- // if fetching by change, the last block of data contains
- // the change description - remove it (unless we're fetching
- // from the default changelist)
- if ($query->getLimitToChangelist() !== null
- && $query->getLimitToChangelist() !== Change::DEFAULT_CHANGE
- ) {
- $count--;
- }
-
- return $count;
- }
-
- /**
- * Check if the given filespec is known to Perforce.
- *
- * @param string $filespec a filespec with no wildcards.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @param bool $excludeDeleted optional - exclude deleted files (defaults to false).
- * @return bool|array info about the file or false if filespec doesn't exist
- */
- public static function exists($filespec, ConnectionInterface $connection = null, $excludeDeleted = false)
- {
- static::validateFilespec($filespec);
-
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // run files to see if file exists.
- try {
- $result = $connection->run('files', $filespec);
- } catch (CommandException $e) {
- if (strpos($e->getMessage(), ' - must refer to client')) {
- return false;
- }
- throw $e;
- }
- if ($result->hasWarnings()) {
- return false;
- } elseif ($excludeDeleted && strstr($result->getData(-1, 'action'), 'delete') !== false) {
- return false;
- } else {
- // grab the last block - can get multiple files if overlay mappings in use.
- $info = $result->getData(-1);
-
- // this really shouldn't happen; just being defensive
- if (!is_array($info) || !$info) {
- throw new Exception('Failed to capture file info during existence test');
- }
-
- return $info;
- }
- }
-
- /**
- * Check if the given filespec is a directory known to Perforce.
- *
- * @param string $filespec a filespec with no wildcards.
- * @param ConnectionInterface $connection optional - a specific connection to use.
- * @param bool $excludeDeleted optional - exclude deleted files (defaults to false).
- * @return bool|int head revision number or false if filespec doesn't exist
- */
- public static function dirExists($filespec, ConnectionInterface $connection = null)
- {
- static::validateFilespec($filespec);
-
- // if no connection given, use default.
- $connection = $connection ?: static::getDefaultConnection();
-
- // run files to see if file exists.
- $result = $connection->run('dirs', $filespec);
-
- return $result->getData(0, 'dir') == $filespec;
- }
-
- /**
- * Open file for add or edit as appropriate.
- *
- * If the file is open for delete, revert and edit unless force=false.
- * Will sync the file before opening it for edit.
- *
- * @param int $change optional - a numbered pending change to open the file in.
- * @param string $fileType optional - the file-type to open the file as.
- * @param bool $force optional - defaults to true - reverts files that are
- * open for delete then reopens them. if false, files that are
- * open for delete will result in an exception being thrown.
- * @return File provide fluent interface.
- */
- public function open($change = null, $fileType = null, $force = true)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // add the file if it doesn't exist or is deleted at head - otherwise edit.
- if (!static::exists($this->getFilespecWithRevision(), $this->getConnection()) ||
- $this->getStatus('headAction') == 'delete') {
- $this->add($change, $fileType);
- } else {
- $this->sync(true);
- $this->edit($change, $fileType, $force);
- }
-
- return $this;
- }
-
- /**
- * Open this file for delete.
- *
- * If the file is open, but not for delete, the file will be
- * reverted and then deleted unless the force flag has been
- * set to false.
- *
- * @param int $change optional - a numbered pending change to open the file in.
- * @param bool $force optional - defaults to true - reverts files that are
- * open then deletes them. if false, files that are
- * open (not for delete) will result in an exception
- * being thrown.
- * @return File provide fluent interface.
- */
- public function delete($change = null, $force = true)
- {
- return $this->openForAction('delete', $change, null, $force);
- }
-
- /**
- * Delete the local file from the workspace.
- *
- * @throws Exception if the local file cannot be deleted.
- * @return File provide fluent interface.
- */
- public function deleteLocalFile()
- {
- $localFile = $this->getLocalFilename();
- if (!file_exists($localFile)) {
- throw new Exception("Cannot delete local file. File does not exist.");
- }
- chmod($localFile, 0777);
- if (unlink($localFile) === false) {
- throw new Exception("Failed to delete local file.");
- }
-
- return $this;
- }
-
- /**
- * Open the file for add.
- *
- * @param int $change optional - a numbered pending change to open the file in.
- * @param string $fileType optional - the file-type to open the file as.
- * @return File provides fluent interface.
- */
- public function add($change = null, $fileType = null)
- {
- return $this->openForAction('add', $change, $fileType, false);
- }
-
- /**
- * Open the file for edit.
- *
- * If the file is opened for delete, the file will be reverted
- * and then edited unless the force flag has been set to false.
- *
- * @param int $change optional - a numbered pending change to open the file in.
- * @param string $fileType optional - the file-type to open the file as.
- * @param bool $force optional - defaults to true - set to false to avoid reopening.
- * @return File provide fluent interface.
- * @todo make force work against branch/delete, etc.
- */
- public function edit($change = null, $fileType = null, $force = true)
- {
- // If our 'have' rev and our 'head' revision aren't the
- // same value throw an exception (caller needs to sync).
- if (!$this->hasStatusField('haveRev')
- || $this->getStatus('headRev') != $this->getStatus('haveRev')
- ) {
- throw new Exception(
- 'Workspace file is not at specified revision; unable to edit'
- );
- }
-
- return $this->openForAction('edit', $change, $fileType, $force);
- }
-
- /**
- * Flush the file - tells the server we have the file.
- *
- * @return File provide fluent interface.
- * @throws Exception if the flush fails.
- */
- public function flush()
- {
- return $this->sync(false, true);
- }
-
- /**
- * Resolves the file 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
- *
- * @param array|string $options Resolve option(s); must include a RESOLVE_* preference.
- * @return File 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.');
- }
-
- // limit the resolve to just our file and let change do the work
- $options[Change::RESOLVE_FILE] = $this->getFilespec(true);
- $this->getChange()->resolve($options);
-
- return $this;
- }
-
- /**
- * Used to check if the file requires resolve or not. This function
- * will return true only when a resolve is scheduled. It doesn't attempt to
- * look at the current state and estimate if calling 'submit' would result in
- * an unresolved exception.
- *
- * @return bool true if file is resolved, false otherwise
- */
- public function needsResolve()
- {
- $this->validateHasFilespec();
-
- $result = $this->getConnection()->run(
- 'resolve',
- '-n',
- $this->getFilespecWithRevision()
- );
-
- return (bool) $result->hasData();
- }
-
- /**
- * Check if the file has the named attribute.
- *
- * @param string $attribute the name of the attribute to check for.
- * @return bool true if the file has an attribute with this name.
- */
- public function hasAttribute($attribute)
- {
- return array_key_exists($attribute, $this->getAttributes());
- }
-
- /**
- * Check if the file has the named open attribute.
- *
- * @param string $attribute the name of the open attribute to check for.
- * @return bool true if the file has an open attribute with this name.
- */
- public function hasOpenAttribute($attribute)
- {
- return array_key_exists($attribute, $this->getOpenAttributes());
- }
-
- /**
- * Get all submitted attributes of this file.
- * Submitted attributes are attributes that have been committed to the depot.
- *
- * @param bool $open optional - get open attributes - defaults to false.
- * @return array all attributes of the file.
- */
- public function getAttributes($open = false)
- {
- $attributes = array();
- foreach ($this->getStatus() as $field => $value) {
- if (!$open && substr($field, 0, 5) == 'attr-') {
- $attributes[substr($field, 5)] = $value;
- } elseif ($open && substr($field, 0, 9) == 'openattr-') {
- $attributes[substr($field, 9)] = $value;
- }
- }
- return $attributes;
- }
-
- /**
- * Get all pending attributes for this file.
- * Pending attributes are attributes that have been written to the client
- * but are not yet submitted to the depot.
- *
- * @return array all pending attributes of the file.
- */
- public function getOpenAttributes()
- {
- return $this->getAttributes(true);
- }
-
- /**
- * Get the named attribute from the set of submitted attributes on this file.
- * Submitted attributes are attributes that have been committed to the depot.
- *
- * @param string $attribute the name of the attribute to get the value of.
- * @return string the value of the attribute.
- */
- public function getAttribute($attribute)
- {
- return $this->getStatus('attr-' . $attribute);
- }
-
- /**
- * Get the named attribute from the set of pending attributes on this file.
- * Pending attributes are attributes that have been written to the client
- * but are not yet submitted to the depot.
- *
- * @param string $attribute the name of the open attribute to get the value of.
- * @return string the value of the attribute.
- */
- public function getOpenAttribute($attribute)
- {
- return $this->getStatus('openattr-' . $attribute);
- }
-
- /**
- * Set attributes on this file. Does not clear existing attributes.
- *
- * @param array $attributes the set of key/value pairs to set on the file.
- * @param bool $propagate optional - defaults to true - automatically propagate
- * the attributes to new revisions.
- * @param bool $force optional - write the attributes to the depot directly
- * by default attributes are pended to the client workspace.
- * @return File provide fluent interface.
- */
- public function setAttributes($attributes, $propagate = true, $force = false)
- {
- if (!is_array($attributes)) {
- throw new \InvalidArgumentException(
- "Can't set attributes. Attributes must be an array."
- );
- }
-
- // if no attributes to set, nothing to do.
- if (empty($attributes)) {
- return $this;
- }
-
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $params = array();
- foreach ($attributes as $key => $value) {
- $value = is_null($value) ? '' : $value;
-
- // ensure value is a string.
- if (!is_string($value)) {
- throw new \InvalidArgumentException("Cannot set attribute. Value must be a string.");
- }
-
- // ensure attribute key name is valid.
- $validator = new Validate\AttributeName;
- if (!$validator->isValid($key)) {
- throw new \InvalidArgumentException("Cannot set attribute. Attribute name is invalid.");
- }
-
- // add params for attribute name/value.
- $params[] = '-n';
- $params[] = $key;
- $params[] = '-v';
- $params[] = bin2hex($value);
- }
-
- // setup shared inital parameters
- $prefixParams = array();
- if ($propagate) {
- $prefixParams[] = '-p';
- }
- if ($force) {
- $prefixParams[] = '-f';
- }
-
- // write value in binhex to avoid problems with binary data.
- $prefixParams[] = '-e';
-
- // permit revspec only if force writing attribute.
- $filespec = $force
- ? $this->getFilespecWithRevision()
- : $this->getFilespec(true);
-
- // see if we can set multiple attributes at once (for performance)
- // if we're unable (e.g. a value exceeds arg-max), set individually via input.
- $batches = array();
- $connection = $this->getConnection();
- try {
- $batches = $connection->batchArgs($params, $prefixParams, array($filespec), 4);
- } catch (P4\Exception $e) {
- $prefixParams[] = '-i';
- foreach ($attributes as $key => $value) {
- $value = is_null($value) ? '' : $value;
- $result = $this->getConnection()->run(
- 'attribute',
- array_merge($prefixParams, array('-n', $key, $filespec)),
- bin2hex($value)
- );
-
- // stop processing if we encounter warnings.
- if ($result->hasWarnings()) {
- break;
- }
- }
- }
-
- // if we were able to batch the arguments, process them now.
- foreach ($batches as $batch) {
- $result = $this->getConnection()->run('attribute', $batch);
-
- // stop processing if we encounter warnings.
- if ($result->hasWarnings()) {
- break;
- }
- }
-
- if ($result->hasWarnings()) {
- throw new Exception(
- "Failed to set attribute(s) on file: " . implode(", ", $result->getWarnings())
- );
- }
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Set the given attribute/value on the file.
- *
- * By default attributes will propagate to new revisions of the file
- * To disable this, set the propagate argument to false.
- *
- * By default attributes will be pended. To write attributes to the depot
- * directly, set the force flag to true.
- *
- * @param string $key the name of the attribute to write.
- * @param string|null $value the value to write.
- * @param bool $propagate optional - defaults to true - propagate the attribute
- * to new revisions.
- * @param bool $force optional - defaults to false - write the attribute
- * to the depot directly.
- * @return File provide fluent interface.
- */
- public function setAttribute($key, $value, $propagate = true, $force = false)
- {
- // ensure attribute key name is valid.
- // we do this prior to forming the array as an
- // invalid key (e.g. an array) would cause an error.
- $validator = new Validate\AttributeName;
- if (!$validator->isValid($key)) {
- throw new \InvalidArgumentException("Cannot set attribute. Attribute name is invalid.");
- }
-
- return $this->setAttributes(array($key => $value), $propagate, $force);
- }
-
- /**
- * Clear the specified attributes on this file.
- *
- * @param array $attributes the set of attributes to clear.
- * @param bool $force optional - clear the attributes in the depot directly
- * by default attributes are pended to the client workspace.
- * @return File provide fluent interface.
- */
- public function clearAttributes($attributes, $force = false)
- {
- if (!is_array($attributes)) {
- throw new \InvalidArgumentException(
- "Can't clear attributes. Attributes must be an array."
- );
- }
-
- // if no attributes given, nothing to clear.
- if (empty($attributes)) {
- return $this;
- }
-
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
- $filespec = $force
- ? $this->getFilespecWithRevision()
- : $this->getFilespec(true);
-
- // make -n/attr-name argument pairs.
- $params = array();
- foreach ($attributes as $attribute) {
- $params[] = "-n";
- $params[] = $attribute;
- }
-
- // there is a potential to exceed the arg-max/option-limit;
- // run attribute command as few times as possible
- $connection = $this->getConnection();
- $prefixParams = $force ? array('-f') : array();
- foreach ($connection->batchArgs($params, $prefixParams, array($filespec), 2) as $batch) {
- $connection->run('attribute', $batch);
- }
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Clear the given attribute on the file.
- *
- * By default the cleared attribute will be pended. To clear attributes in the depot
- * directly, set the force flag to true.
- *
- * @param string $attribute the name of the attribute to clear.
- * @param bool $force optional - defaults to false - clear the attribute
- * in the depot directly.
- * @return File provide fluent interface.
- */
- public function clearAttribute($attribute, $force = false)
- {
- return $this->clearAttributes(array($attribute), $force);
- }
-
- /**
- * Get file status (run fstat on file).
- *
- * File status is fetched once and then cached in the instance.
- * The cache can be primed via setStatusCache(). It can be cleared
- * via clearStatusCache().
- *
- * Attributes are fetched along with the status.
- *
- * @param string $field optional - a specific status field to get.
- * by default all fields are returned.
- * @throws Exception if the requested status field does not exist.
- */
- public function getStatus($field = null)
- {
- // if cache is not primed, run fstat.
- if (!array_key_exists('status', $this->cache) || !isset($this->cache['status'])) {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $result = $this->getConnection()->run(
- 'fstat',
- array('-Oal', $this->getFilespecWithRevision())
- );
- if ($result->hasWarnings()) {
- throw new Exception(
- "Cannot get status: " . implode(", ", $result->getWarnings())
- );
- }
-
- // grab the last block - can get multiple files if overlay mappings in use.
- if (is_array($result->getData(-1))) {
- $this->setStatusCache($result->getData(-1));
- } else {
- $this->setStatusCache(array());
- }
- }
-
- // return a specific field or all fields as appropriate.
- if ($field) {
- if (!array_key_exists($field, $this->cache['status'])) {
- throw new Exception(
- "Can't fetch status. The requested field ('"
- . $field . "') does not exist."
- );
- } else {
- return $this->cache['status'][$field];
- }
- } else {
- return $this->cache['status'];
- }
- }
-
- /**
- * Determine if this file has the named status field.
- *
- * @param string $field the name of the field to check for.
- * @return bool true if the field exists.
- */
- public function hasStatusField($field)
- {
- try {
- $this->getStatus($field);
- return true;
- } catch (Exception $e) {
- return false;
- }
- }
-
- /**
- * Set the file status cache to the given array of fields/values.
- *
- * @param array $status an array of field/value pairs.
- * @throws \InvalidArgumentException if the given value is not an array.
- * @return File provide fluent interface.
- */
- public function setStatusCache($status)
- {
- if (!is_array($status)) {
- throw new \InvalidArgumentException('Cannot set status cache. Status must be an array.');
- }
- $this->cache['status'] = $status;
-
- if (isset($status['headRev'])) {
- $this->cache['revision'] = $status['headRev'];
- }
-
- return $this;
- }
-
- /**
- * Clear the file status cache.
- *
- * @return File provide fluent interface.
- */
- public function clearStatusCache()
- {
- $this->cache['status'] = null;
-
- return $this;
- }
-
- /**
- * Lock this file in the depot.
- *
- * @return File provide fluent interface.
- */
- public function lock()
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $this->getConnection()->run('lock', $this->getFilespec(true));
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Unlock this file in the depot.
- *
- * @return File provide fluent interface.
- */
- public function unlock()
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $this->getConnection()->run('unlock', $this->getFilespec(true));
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Check if the file is opened in Perforce by the current client.
- *
- * @return bool true if the file is opened by the current client.
- */
- public function isOpened()
- {
- if ($this->hasStatusField('action')) {
- return true;
- } else {
- return false;
- }
- }
-
- /**
- * Checks if this file is at the head revision or not.
- *
- * @return bool true if the file is at head, false otherwise
- */
- public function isHead()
- {
- $info = static::exists($this->getFilespec(true), $this->getConnection());
-
- if (isset($info['rev']) && $info['rev'] === $this->getStatus('headRev')) {
- return true;
- }
-
- return false;
- }
-
- /**
- * Test if a file is deleted in the depot.
- * Note: this method reports the deleted status based on the
- * filespec, which could be a non-head revision.
- *
- * @return boolean indicated whether the file is deleted.
- */
- public function isDeleted()
- {
- $headAction = $this->getStatus('headAction');
- if (preg_match('/delete/', $headAction)) {
- return true;
- }
- return false;
- }
-
- /**
- * Test if the file was purged at this revision in the depot.
- *
- * @return bool true if the file was purged at this revision.
- */
- public function isPurged()
- {
- return $this->getStatus('headAction') == 'purge';
- }
-
- /**
- * Test if the file was deleted or purged at this revision in the depot.
- *
- * @return bool true if the file was deleted or purged at this revision.
- */
- public function isDeletedOrPurged()
- {
- return $this->isDeleted() || $this->isPurged();
- }
-
- /**
- * Test if the file was added at this revision in the depot.
- *
- * @return bool true if the file was added at this revision.
- */
- public function isAdded()
- {
- $headAction = $this->getStatus('headAction');
- if (preg_match('/add|branch|import/', $headAction)) {
- return true;
- }
- return false;
- }
-
- /**
- * Test if the file has a text type in the depot.
- *
- * @return boolean indicated whether the file is text.
- */
- public function isText()
- {
- return (bool) preg_match('/text|unicode|utf16/', $this->getStatus('headType'));
- }
-
- /**
- * Test if the file has a binary type in the depot.
- *
- * @return boolean indicated whether the file is binary.
- */
- public function isBinary()
- {
- return !$this->isText();
- }
-
- /**
- * Get the contents of the file in Perforce.
- *
- * File content is fetched once and then cached in the instance
- * (unless the file is truncated due to max-filesize).
- *
- * The cache can be primed via setContentCache().
- * It can be cleared via clearContentCache().
- *
- * @param null|array options to influence behaviour
- * MAX_FILESIZE - crop the file after this many bytes - won't split
- * multi-byte chars if UTF8_SANITIZE is set
- * UTF8_CONVERT - attempt to covert non UTF-8 to UTF-8
- * UTF8_SANITIZE - replace invalid UTF-8 sequences with �
- * @param bool $cropped updated by reference, indicates the file contents
- * exceeded max-filesize and were truncated.
- * @return string the contents of the file in the depot.
- * @throws Exception if the print command fails.
- */
- public function getDepotContents(array $options = null, &$cropped = false)
- {
- $cropped = false;
- $options = (array) $options + array(
- static::MAX_FILESIZE => null,
- static::UTF8_CONVERT => false,
- static::UTF8_SANITIZE => false
- );
- $maxSize = $options[static::MAX_FILESIZE];
- $convert = $options[static::UTF8_CONVERT];
- $sanitize = $options[static::UTF8_SANITIZE];
-
- // if cache is empty, get content from the server
- if (!array_key_exists('content', $this->cache)) {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // setup output handler to support limiting file content length
- // this is necessary to avoid running out of memory.
- $content = "";
- $handler = new Limit;
- $handler->setOutputCallback(
- function ($data, $type) use (&$content, $maxSize) {
- if ($type !== 'text' && $type !== 'binary') {
- return Limit::HANDLER_REPORT;
- }
- if (is_array($data)) {
- return Limit::HANDLER_HANDLED;
- }
-
- // using isset instead of strlen, because it was surprisingly much faster
- if ($maxSize && isset($content[$maxSize + 1])) {
- return Limit::HANDLER_HANDLED | Limit::HANDLER_CANCEL;
- }
-
- $content .= $data;
-
- return Limit::HANDLER_HANDLED;
- }
- );
-
- // run the print command with our output handler
- // ensure depot syntax to avoid multiple file output if overlay mappings in use.
- $result = $this->getConnection()->runHandler($handler, 'print', $this->getDepotFilenameWithRevision());
-
- // check for warnings.
- if ($result->hasWarnings()) {
- throw new Exception(
- "Failed to get depot contents: " . implode(", ", $result->getWarnings())
- );
- }
-
- // don't cache truncated contents
- if (!$maxSize || !isset($content[$maxSize + 1])) {
- $this->cache['content'] = $content;
- }
- } else {
- $content = $this->cache['content'];
- }
-
- // need to do a final crop if maxSize is set and exceeded.
- if ($maxSize && isset($content[$maxSize + 1])) {
- $content = substr($content, 0, $maxSize);
- $cropped = true;
- }
-
- // if we are requested to convert or replace; return filtered
- if ($convert || $sanitize) {
- $filter = new Utf8Filter;
- $content = $filter->setConvertEncoding($convert)
- ->setReplaceInvalid($sanitize)
- ->filter($content);
-
- // if we cropped the file and the caller requested sanitized output,
- // check if the last character is '�' and remove it (likely our fault)
- if ($cropped && $sanitize && substr($content, -3) === "\xEF\xBF\xBD") {
- $content = substr($content, 0, -3);
- }
- }
-
- // if no options; just return cached directly
- return $content;
- }
-
- /**
- * Stream the contents of the file in Perforce to stdout via echo.
- *
- * File content is streamed for each call; no caching occurs.
- *
- * @return File to maintain a fluent interface
- */
- public function streamDepotContents()
- {
- // we anticipate the output of print will lead with a meta-data block in array format
- // followed by zero or more strings representing the data of the file. our handler
- // simply echo's anything which isn't an array to stream the contents.
- $handler = new Limit;
- $handler->setOutputCallback(
- function ($data) {
- if (!is_array($data)) {
- echo $data;
- }
-
- return Limit::HANDLER_HANDLED;
- }
- );
-
- // run the print command with our output handler
- // ensure depot syntax to avoid multiple file output if overlay mappings in use.
- $this->getConnection()->runHandler($handler, 'print', $this->getDepotFilenameWithRevision());
-
- return $this;
- }
-
-
- /**
- * Return the contents of the file in Perforce limited by the provided line range(s).
- *
- * File content is returned for each call; no caching occurs.
- *
- * @param array|string line range(s) to scan between. ranges can be specified
- * as either a string in the format start-end (e.g. 1-2)
- * or an array with the keys start => 1, end => 2.
- * passing a single range or an array of ranges is supported.
- * @return array array of captured lines keyed on line number,
- * each line includes its line ending
- * @throws \InvalidArgumentException if malformed line ranges are specified
- */
- public function getDepotContentLines($lines)
- {
- // normalize to an array of line ranges (we may have received a single input)
- if (is_string($lines) || isset($lines['start'], $lines['end'])) {
- $lines = array($lines);
- }
-
- // as we've normalized, non-array inputs at this point are complaint worthy
- if (!is_array($lines)) {
- throw new \InvalidArgumentException('String or array input expected');
- }
-
- // normalize to a list of ranges with start/end keys
- $ranges = array();
- foreach ($lines as $line) {
- // validate string inputs and normalize them to array format
- if (is_string($line)) {
- if (!preg_match('/^\s*([0-9]+)-([0-9]+)\s*$/', $line, $matches)) {
- throw new \InvalidArgumentException('String arguments must be in the format 1-2');
- }
- $line = array('start' => $matches[1], 'end' => $matches[2]);
- }
-
- // should surely be an array at this point
- if (!is_array($line)) {
- throw new \InvalidArgumentException('Expected range to be in string or array format');
- }
-
- // verify start and end are present and numeric
- if (!isset($line['start'], $line['end'])
- || !ctype_digit((string) $line['start'])
- || !ctype_digit((string) $line['end'])
- ) {
- throw new \InvalidArgumentException('Array arguments must have a numeric start and end key');
- }
-
- if ($line['start'] < 1) {
- throw new \InvalidArgumentException('Line numbers cannot be lower than 1');
- }
-
- if ($line['end'] < $line['start']) {
- throw new \InvalidArgumentException('Range end must be greater than or equal to range start');
- }
-
- $ranges[] = array('start' => (int) $line['start'], 'end' => (int) $line['end']);
- }
-
- // primary sort by start, secondary sort by end
- usort(
- $ranges,
- function ($a, $b) {
- return ($a['start'] - $b['start']) ?: ($a['end'] - $b['end']);
- }
- );
-
- // no ranges? no problem, just return
- if (!$ranges) {
- return array();
- }
-
- // ok we've got at least one valid range; lets setup an output handler
- // to collect the line(s) of interest
- $lines = array();
- $lineNum = 1;
- $handler = new Limit;
- $handler->setOutputCallback(
- function ($data) use (&$ranges, &$lines, &$lineNum) {
- // cancel if we have more data but have run out of ranges
- if (!$ranges) {
- return Limit::HANDLER_HANDLED | Limit::HANDLER_CANCEL;
- }
-
- // we anticipate the output of print will lead with a meta-data block in array format
- // followed by zero or more strings representing the data of the file. our handler
- // ignores array data in order to stream the contents.
- // it also ignores empty blocks so we don't add lines for empty files.
- if (is_array($data) || !strlen($data)) {
- return Limit::HANDLER_HANDLED;
- }
-
- // split on newlines, but keep the line ending on each line
- $pieces = preg_split("/(\r\n|\n|\r)/", $data, null, PREG_SPLIT_DELIM_CAPTURE);
- foreach ($pieces as $piece) {
- $range = reset($ranges);
- $isNewLine = preg_match("/\r\n|\n|\r/", $piece) === 1;
-
- // if we're within the active range capture the data
- if ($lineNum >= $range['start'] && $lineNum <= $range['end']) {
- $lines += array($lineNum => '');
- $lines[$lineNum] .= $piece;
- }
-
- // if this is a newline; increment the line number
- if ($isNewLine) {
- $lineNum++;
- }
-
- // if we just traversed a line and that takes us past our range,
- // remove the active range as its done with
- if ($lineNum > $range['end']) {
- array_shift($ranges);
- }
- }
-
- return Limit::HANDLER_HANDLED;
- }
- );
-
- // run the print command with our output handler
- $this->getConnection()->runHandler($handler, 'print', $this->getDepotFilenameWithRevision());
-
- return $lines;
- }
-
- /**
- * Get the annotated contents of the file in Perforce.
- *
- * array(
- * 'upper' => <upper version number>,
- * 'lower' => <lower version number>,
- * 'data' => <text data for the current line>
- * )
- *
- * @param array $options optional - influence annotate results
- * ANNOTATE_CHANGES - get change numbers instead of revs (defaults to false)
- * ANNOTATE_INTEG - follow integrations to source via -I (defaults to false)
- * ANNOTATE_CONTENT - include line content (defaults to true)
- * @return array an array of the file's lines with upper/lower rev and data if content option is true
- */
- public function getAnnotatedContent(array $options = array())
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // normalize options
- $options += array(
- static::ANNOTATE_CHANGES => false,
- static::ANNOTATE_INTEG => false,
- static::ANNOTATE_CONTENT => true
- );
-
- // setup output handler to (optionally) filter file content
- // this is more memory efficient than doing it after the fact
- $result = array();
- $handler = new Limit;
- $content = $options[static::ANNOTATE_CONTENT];
- $handler->setOutputCallback(
- function ($data) use (&$result, $content) {
- if (is_array($data) && isset($data['upper'], $data['lower'], $data['data'])) {
- $line = array(
- 'upper' => $data['upper'],
- 'lower' => $data['lower']
- );
- if ($content) {
- $line['data'] = $data['data'];
- }
-
- $result[] = $line;
- }
-
- return Limit::HANDLER_HANDLED;
- }
- );
-
- $flags = array(
- $options[static::ANNOTATE_CHANGES] ? '-c' : null,
- $options[static::ANNOTATE_INTEG] ? '-I' : null,
- $this->getFilespec()
- );
- $this->getConnection()->runHandler($handler, 'annotate', array_filter($flags));
-
- return $result;
- }
-
- /**
- * Prime the depot file content cache with the given value.
- *
- * @param string $content the contents of the file in the depot.
- * @return File provide fluent interface.
- */
- public function setContentCache($content)
- {
- $this->cache['content'] = $content;
-
- return $this;
- }
-
- /**
- * Clear the depot file content cache.
- *
- * @return File provide fluent interface.
- */
- public function clearContentCache()
- {
- unset($this->cache['content']);
-
- return $this;
- }
-
- /**
- * Get the contents of the local file in the client workspace.
- *
- * @return string the contents of the local client file.
- */
- public function getLocalContents()
- {
- if (!file_exists($this->getLocalFilename())) {
- throw new Exception(
- 'Cannot get local file contents. Local file does not exist.'
- );
- }
- return file_get_contents($this->getLocalFilename());
- }
-
- /**
- * Write contents to the local client file.
- * If the file does not exist, it will be created.
- *
- * @param string $content the content to write to the file
- * @throws Exception if the file cannot be written.
- * @return File provide fluent interface.
- */
- public function setLocalContents($content)
- {
- $this->touchLocalFile();
- if (!is_writable($this->getLocalFilename())) {
- if (!chmod($this->getLocalFilename(), 0644)) {
- $message = "Failed to make local file writable.";
- throw new Exception($message);
- }
- }
- if (file_put_contents($this->getLocalFilename(), $content) === false) {
- $message = "Failed to write local file.";
- throw new Exception($message);
- }
-
- return $this;
- }
-
- /**
- * Touch the local client file.
- * If the file does not exist, it will be created.
- *
- * @throws Exception if the file cannot be touched.
- * @return File provide fluent interface.
- */
- public function touchLocalFile()
- {
- if (!is_dir($this->getLocalPath())) {
- $this->createLocalPath();
- }
- if (!is_file($this->getLocalFilename())) {
- if (!touch($this->getLocalFilename())) {
- $message = "Failed to touch local file.";
- throw new Exception($message);
- }
- }
-
- return $this;
- }
-
- /**
- * Open the file in another change and/or as a different filetype.
- *
- * @param string $change the change list to open the file in.
- * @param string $type the filetype to open the file as.
- * @throws \InvalidArgumentException if neither a change nor a type are given.
- * @return File provide fluent interface.
- */
- public function reopen($change = null, $type = null)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // ensure user has specified a change and/or a type
- if (!$change && !$type) {
- throw new \InvalidArgumentException(
- 'Cannot reopen file. You must provide a change and/or a filetype.'
- );
- }
-
- $params = array();
- if ($change) {
- $params[] = '-c';
- $params[] = $change;
- }
- if ($type) {
- $params[] = '-t';
- $params[] = $type;
- }
- $params[] = $this->getFilespec(true);
- $this->getConnection()->run('reopen', $params);
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Revert the file.
- *
- * @param string|array|null $options options to influence the operation:
- * REVERT_UNCHANGED - only revert if unchanged
- * @return File provides fluent interface.
- */
- public function revert($options = null)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // if the unchanged option is given, add -a flag.
- $params = array();
- $unchanged = in_array(static::REVERT_UNCHANGED, (array) $options);
- if ($unchanged) {
- $params[] = "-a";
- }
-
- $params[] = $this->getFilespec(true);
-
- $this->getConnection()->run('revert', $params);
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- return $this;
- }
-
- /**
- * Submit the file to perforce.
- * If the optional resolve flags are passed, an attempt will be made to automatically
- * resolve/resubmit should a conflict occur.
- *
- * @param string $description the change description.
- * @param null|string|array $options optional resolve flags, to be used if conflict
- * occurs. See resolve() for details.
- * @throws \InvalidArgumentException if no description is given.
- * @return File provide fluent interface.
- */
- public function submit($description, $options = null)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // ensure that we have a description.
- if (!is_string($description) || !strlen($description)) {
- throw new \InvalidArgumentException(
- 'Cannot submit. Description must be a non-empty string.'
- );
- }
-
- // ensure the file is in the default pending change.
- // this is required to avoid inadvertently affecting
- // a numbered pending change description and its files.
- if ($this->hasStatusField('change') && $this->getStatus('change') != 'default') {
- $this->reopen('default');
- }
-
- // setup the submit options
- $params = array();
- $params[] = '-d';
- $params[] = $description;
- $params[] = $this->getFilespec(true);
-
- try {
- $this->getConnection()->run('submit', $params);
- } catch (ConflictException $e) {
- // if there are no resolve options; re-throw the resolve exception
- if (empty($options)) {
- throw $e;
- }
-
- // re-do submit via our change as this will
- // attempt to do the resolve. note change presently
- // does a wasted try prior to resolve but hopefully
- // the use is seldom enough we don't take a notable
- // performance hit on it.
- $e->getChange()->submit(null, $options);
- }
-
- // file has changed - clear all of the instance caches.
- $this->cache = array();
-
- // if we had a rev-spec previously, take it off
- $this->setFilespec($this->getFilespec(true));
-
- return $this;
- }
-
- /**
- * Sync the file from the depot.
- * Note when the File is fetched, or if made via new the first time it is
- * accessed and has a valid filespec, the revision is pinned at that point in
- * time. Sync will always use the pinned revision which is not necessarily head.
- *
- * @param bool $force optional - defaults to false - force sync the file.
- * @param bool $flush optional - defaults to false - don't transfer the file.
- * @return File provide fluent interface.
- * @throws Exception if sync fails.
- */
- public function sync($force = false, $flush = false)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $params = array();
- if ($force) {
- $params[] = '-f';
- }
- if ($flush) {
- $params[] = '-k';
- }
- $params[] = $this->getFilespecWithRevision();
- $result = $this->getConnection()->run('sync', $params);
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- // verify sync was successful.
- if ($result->hasWarnings()) {
- // if we had warnings throw if the haveRev doesn't equal the headRev
- // unless it is a deleted file in which case we expect a warning
- $haveRev = $this->hasStatusField('haveRev') ? $this->getStatus('haveRev') : -1;
- $headRev = $this->hasStatusField('headRev') ? $this->getStatus('headRev') : 0;
- if (!$this->isDeleted() && $headRev !== $haveRev) {
- throw new Exception(
- "Failed to sync file: " . implode(", ", $result->getWarnings())
- );
- }
- }
-
- return $this;
- }
-
- /**
- * Get the file's size in the depot.
- *
- * @return int the depot file's size in bytes, or zero.
- * @todo make this work properly.
- */
- public function getFileSize()
- {
- if (!$this->hasStatusField('fileSize')) {
- throw new Exception('The file does not have a fileSize attribute.');
- }
- return (int) $this->getStatus('fileSize');
- }
-
- /**
- * Get the size of the local client file.
- *
- * @return int the local file's size in bytes, or zero.
- */
- public function getLocalFileSize()
- {
- if (!file_exists($this->getLocalFilename())) {
- throw new Exception('The local file does not exist.');
- }
- return (int) filesize($this->getLocalFilename());
- }
-
- /**
- * Get the path to the file in local file syntax.
- *
- * @return string the path to the file in local file syntax.
- */
- public function getLocalFilename()
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $filespec = $this->getFilespec(true);
-
- // if filespec is in local-file syntax return it.
- if (strlen($filespec) >=2 && substr($filespec, 0, 2) != '//') {
- return $filespec;
- }
-
- // otherwise, get local filename from p4 where.
- $where = $this->where();
- return $where[2];
- }
-
- /**
- * Get the local path to the file.
- *
- * @return string the local path to the file.
- */
- public function getLocalPath()
- {
- return dirname($this->getLocalFilename());
- }
-
- /**
- * Get the path to the file in depot syntax.
- *
- * We try several different means of getting the filespec in depot syntax:
- * 1. Take the filespec itself if it leads with '//' and is not '//<client>'
- * 2. Check the depotFile cache which gets set on fetch()
- * 3. Try getStatus('depotFile') - this is free if cached and more accurate than where
- * 4. Run 'p4 where' as a last resort - necessary if file doesn't exist in the depot
- *
- * @return string the path to the file in depot file syntax.
- */
- public function getDepotFilename()
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $filespec = $this->getFilespec(true);
-
- // if filespec is already in depot-file syntax, return it.
- // note, we must verify that it doesn't start with the client name.
- $clientPrefix = "//" . $this->getConnection()->getClient() . "/";
- if (strlen($filespec) >= 2 && substr($filespec, 0, 2) == '//' &&
- substr($filespec, 0, strlen($clientPrefix)) != $clientPrefix) {
- return $filespec;
- }
-
- // if we have previously cached the depotFile (e.g. on fetch), use it.
- if (isset($this->cache['depotFile'])) {
- return $this->cache['depotFile'];
- }
-
- // if no depotFile in cache, check file status for depotFile
- // we favor status (fstat) over where because it is more accurate.
- if ($this->hasStatusField('depotFile')) {
- return $this->getStatus('depotFile');
- }
-
- // otherwise, get depot file from p4 where.
- $where = $this->where();
- return $where[0];
- }
-
- /**
- * Get the path to the file in depot syntax and append revision.
- *
- * @return string the path to the file in depot syntax with revision.
- */
- public function getDepotFilenameWithRevision()
- {
- return $this->getDepotFilename() . $this->getRevspec();
- }
-
- /**
- * Get the depot path to the file.
- *
- * @return string the depot path to the file.
- */
- public function getDepotPath()
- {
- return dirname($this->getDepotFilename());
- }
-
- /**
- * Get the basename of the file.
- *
- * @param string $suffix if filename ends in this suffix it will be cut off.
- * @return string the basename of the file.
- */
- public function getBasename($suffix = null)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- return basename($this->getFilespec(true), $suffix);
- }
-
- /**
- * Get the file extension of the file.
- *
- * @return string the extension of the file.
- */
- public function getExtension()
- {
- return pathinfo($this->getBasename(), PATHINFO_EXTENSION);
- }
-
- /**
- * Determine how this file maps through the client view.
- *
- * Produces an array with three variations on the filespec.
- * Depot-syntax, client-syntax and local file-system syntax
- * (in that order).
- *
- * Caches the result so that subsequent lookups do not incur
- * the 'p4 where' command overhead.
- *
- * @return array three variations of the filespec: depot-syntax
- * client-syntax and local-syntax (respectively).
- * @throws Exception if the file is not mapped by the client.
- */
- public function where()
- {
- if (!array_key_exists('where', $this->cache) || !isset($this->cache['where'])) {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- $result = $this->getConnection()->run('where', $this->getFilespec(true));
- if ($result->hasWarnings()) {
- throw new Exception("Where failed. File is not mapped.");
- }
-
- // take the last valid looking response. normally we only get back a
- // single data block with the keys depotFile, clientFile and path.
- // if the client view maps multiple paths into one folder we may also get
- // blocks containing 'unmap' or 'remap' -- we ignore unmaps because they
- // indicate paths that are not mapped, but we honor remaps because they
- // actually give us a more accurate path (and tend to come last).
- foreach ($result->getData() as $data) {
- if (isset($data['depotFile'], $data['clientFile'], $data['path']) && !isset($data['unmap'])) {
- $this->cache['where'] = array(
- $data['depotFile'], $data['clientFile'], $data['path']
- );
- }
- }
-
- // double check we located a valid response; throw if we didn't
- if (!array_key_exists('where', $this->cache)) {
- throw new Exception("Where failed. File is not mapped.");
- }
- }
- return $this->cache['where'];
- }
-
- /**
- * Convienence function to return all changes associated with this file.
- *
- * @param array $options optional - array of options to augment fetch behavior.
- * supported options are the same as Change, except for
- * the use of FETCH_BY_FILESPEC which is not permitted here.
- * @return FieldedIterator Iterator of Changes
- */
- public function getChanges(array $options = null)
- {
- $this->validateHasFilespec();
-
- $options = array_merge(
- (array) $options,
- array(Change::FETCH_BY_FILESPEC => $this->getFilespec(true))
- );
-
- return Change::fetchAll($options, $this->getConnection());
- }
-
- /**
- * Get the filelog (list of revisions) for this file.
- * Ordered with the most recent revisions first.
- *
- * @return array list of revisions.
- * @todo add options to control filelog flags.
- */
- public function getFilelog(array $options = null)
- {
- $this->validateHasFilespec();
-
- // note that due to a bug (job004873), we have to pass depot file name
- // as filelog won't work with path prefixed by the client if the client
- // was not synchronized
- $result = $this->getConnection()->run(
- 'filelog',
- array(
- '-i', // include inherited history
- '-l', // get full changelist descriptions
- '-s', // only include contributing integrations
- $this->getDepotFilename()
- )
- );
-
- // filelog has one data-block per file
- // (multiple files represent inherited history)
- $files = array();
- foreach ($result->getData() as $file => $log) {
-
- // each file block must have a depotFile property
- if (!isset($log['depotFile'])) {
- continue;
- }
- $file = $log['depotFile'];
-
- // explode filelog result into multi-dimensional array of revisions
- // initial output is a flat list of keys/values where the keys have
- // a trailing number to group them by revision (e.g. rev0, rev1)
- // keys with comma-separated trailing numbers indicate integrations
- // into or out of that revision (e.g. file0,0 file0,1 ... file1,0)
- foreach ($log as $key => $value) {
- if (!preg_match('/(.*?)(([0-9]+,)?[0-9]+)$/', $key, $matches)) {
- continue;
- }
-
- // pull out the key's base, index and optional integ-index
- $base = $matches[1];
- $index = current(explode(',', $matches[2]));
- $integ = strpos($matches[2], ',') ? end(explode(',', $matches[2])) : null;
-
- if ($integ !== null) {
- $files[$file][$index]['integrations'][$integ][$base] = $value;
- } else {
- $files[$file][$index][$base] = $value;
- }
- }
- }
-
- return $files;
- }
-
- /**
- * Convenience function to return the change object associated with the file at its current revspec.
- *
- * @return Change The associated change object.
- */
- public function getChange()
- {
- return Change::fetch($this->getStatus('headChange'), $this->getConnection());
- }
-
- /**
- * Strip the revision specifier from a file specification.
- * This removes the \#rev, \@change, etc. component from a filespec.
- *
- * @param string $filespec the filespec to strip the revspec from.
- * @return string the filespec without the revspec.
- */
- public static function stripRevspec($filespec)
- {
- $revPos = strpos($filespec, "#");
- if ($revPos !== false) {
- $filespec = substr($filespec, 0, $revPos);
- }
- $revPos = strpos($filespec, "@");
- if ($revPos !== false) {
- $filespec = substr($filespec, 0, $revPos);
- }
- return $filespec;
- }
-
- /**
- * Extracts the revision specifier from a file specification.
- * This removes the filename leaving just the revspec (e.g. \#rev).
- *
- * @param string $filespec the filespec to extract the revspec from.
- * @return string|false the revspec or false if filespec contains no revision.
- */
- public static function extractRevspec($filespec)
- {
- $revPos = strpos($filespec, "#");
- if ($revPos !== false) {
- return substr($filespec, $revPos);
- }
- $revPos = strpos($filespec, "@");
- if ($revPos !== false) {
- return substr($filespec, $revPos);
- }
- return false;
- }
-
- /**
- * Check if the given filespec has a revision specifier.
- *
- * @param string $filespec the filespec to check for a revspec.
- * @return bool true if the filespec has a revspec component.
- */
- public static function hasRevspec($filespec)
- {
- if (strpos($filespec, "#") !== false ||
- strpos($filespec, "@") !== false) {
- return true;
- }
- return false;
- }
-
- /**
- * Strip trailing wildcards from a file specification.
- * This removes '/...', '/*' or positional argument (e.g. /%%1) from the end of filespec.
- *
- * @param string $filespec the filespec to strip the wildcards from
- * @return string the filespec without trailing wildcards
- */
- public static function stripWildcards($filespec)
- {
- // remove trailing wildcards from $filespec matching following patterns:
- // /...
- // /*
- // /%%\d+
- return preg_replace('/\/(\.{3}|\*|%%\d+)$/', '', $filespec);
- }
-
- /**
- * Open the file for the specified action.
- *
- * @param string $action the action to open the file for ('add', 'edit' or 'delete').
- * @param int $change optional - a numbered pending change to open the file in.
- * @param string $fileType optional - the file-type to open the file as.
- * @param bool $force optional - defaults to true - set to false to avoid reopening.
- * @return File provide fluent interface.
- * @todo better handling of files open for branch operations - currently, such files
- * will be reverted because the action won't match - this is not correct.
- */
- protected function openForAction($action, $change = null, $fileType = null, $force = true)
- {
- // verify we have a filespec set; throws if invalid/missing
- $this->validateHasFilespec();
-
- // action must be one of: add, edit or delete.
- if (!in_array($action, array('add', 'edit', 'delete'))) {
- throw new Exception("Cannot open file. Invalid open 'action' specified.");
- }
-
- // if already opened for specified action, verify change and type, then return.
- if ($this->isOpenForAction($action)) {
- if (($change && $this->getStatus('change') !== $change)
- || ($fileType && $this->getStatus('type') !== $fileType)
- ) {
- $this->reopen($change, $fileType);
- }
-
- return $this;
- }
-
- $p4 = $this->getConnection();
- $file = $this->getFilespec(true);
-
- // if force is true, revert files opened for the wrong action
- // unless it's open for integrate and we are trying to edit
- // or it's open for branch and we are trying to add (to keep
- // the integration credit).
- if ($force
- && $this->isOpened()
- && !$this->isOpenForAction($action)
- && !($action == 'edit' && $this->isOpenForAction('integrate'))
- && !($action == 'add' && $this->isOpenForAction('branch'))
- ) {
- $result = $p4->run('revert', $file);
-
- // if a file was opened for 'virtual' delete (was not in the client
- // workspace) and subsequently synced (e.g. edit() called), the above
- // p4 revert won't sync the file to the workspace, but the server will
- // think we 'have' it -- detect this case and force sync to correct.
- if ($result->getData(0, 'oldAction') === 'delete'
- && $result->getData(0, 'action') === 'cleared'
- && $result->getData(0, 'haveRev') !== 'none'
- ) {
- $p4->run(
- 'sync',
- array('-f', $this->getFilespecWithRevision())
- );
- }
- }
-
- // setup command flags.
- $flags = array();
- if ($change) {
- $flags[] = '-c';
- $flags[] = $change;
- }
- if ($fileType) {
- $flags[] = '-t';
- $flags[] = $fileType;
- }
-
- // allows delete to work without having to sync file.
- if ($action === 'delete') {
- $flags[] = '-v';
- }
- $flags[] = $file;
-
- // throw for edit or delete of a deleted file, and for add/edit/delete
- // on a stream depot from a non-stream client (these are dead ends!)
- // use the -n flag to see what would happen without actually opening file.
- $result = $p4->run($action, array_merge(array('-n'), $flags));
- foreach ($result->getData() as $data) {
- if (is_string($data)
- && (preg_match("/warning: $action of deleted file/", $data)
- || preg_match('/warning: cannot submit from non-stream client/', $data))
- ) {
- throw new Exception(
- "Failed to open file for $action: " . $data
- );
- }
- }
-
- // open file for specified action.
- $result = $p4->run($action, $flags);
-
- // check for warnings.
- if ($result->hasWarnings()) {
- throw new Exception(
- "Failed to open file for $action: " . implode(", ", $result->getWarnings())
- );
- }
-
- // status has changed - clear the status cache.
- $this->clearStatusCache();
-
- // verify file was opened for specified action.
- if (!$this->hasStatusField('action') || $this->getStatus('action') !== $action) {
- throw new Exception(
- "Failed to open file for $action: " . $result->getData(0)
- );
- }
-
- return $this;
- }
-
- /**
- * Checks if the file is open for the given action.
- *
- * Applies a bit of fuzzy logic to consider move/add to be open for
- * edit since a file must be opened for edit before it can be moved.
- *
- * @param string $action the action to check for
- * @return bool true if the file is open for the given action
- */
- protected function isOpenForAction($action)
- {
- // if not opened at all, nothing more to check
- if (!$this->isOpened()) {
- return false;
- }
-
- $openAction = $this->getStatus('action');
- if ($openAction == $action) {
- return true;
- }
-
- // consider move/add to also be open for edit - a file must be opened
- // for edit before it can be moved; therefore, a move/add file is open
- // for edit - without this, calling edit() on the target of a move
- // would incur a revert unless force is explicitly set to false.
- if ($openAction == 'move/add' && $action == 'edit') {
- return true;
- }
-
- return false;
- }
-
- /**
- * Ensure that a valid, non-empty, filespec has been set on this instance.
- * Will throw an exception if the filespec has wildcards or is unset.
- *
- * @throws Exception if the filespec is empty or invalid
- */
- private function validateHasFilespec()
- {
- $filespec = $this->getFilespec();
-
- if (empty($filespec)) {
- throw new Exception("Cannot complete operation, no filespec has been specified");
- }
-
- $this->validateFilespec($filespec);
- }
-
- /**
- * Ensure that the given filespec has no wildcards.
- * Will throw an exception if the filespec has wildcards
- *
- * @param string $filespec a filespec key to check for wildcards.
- * @throws Exception if the filespec has wildcards.
- */
- private static function validateFilespec($filespec)
- {
- if (!is_string($filespec) ||
- !strlen($filespec) ||
- strpos($filespec, "*") !== false ||
- strpos($filespec, "...") !== false) {
- throw new Exception(
- "Invalid filespec provided. In this context, "
- . "filespecs must be a reference to a single file."
- );
- }
- }
-
- /**
- * Create the directory structure for the local file.
- */
- public function createLocalPath()
- {
- if (!is_dir($this->getLocalPath())) {
- if (!mkdir($this->getLocalPath(), 0755, true)) {
- throw new Exception("Unable to create path: " . $this->getLocalPath());
- }
- }
- }
- }