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