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