File.php #1

  • //
  • guest/
  • perforce_software/
  • chronicle/
  • main/
  • library/
  • P4/
  • File.php
  • View
  • Commits
  • Open Download .zip Download (61 KB)
<?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 commited.
 *
 * 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::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::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->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)
 *   getRevisions()
 *   getFixes()
 *   integrate()
 *   getIntegrations()
 *   getInterchanges()
 *   getLabels()
 *   getProtections()
 *   move()
 *   getReviewers()
 *   getSize()
 *   tag($rev)
 *   untag($rev)
 */
class P4_File extends P4_ModelAbstract implements P4_ResolvableInterface
{
    const       ALL_FILES           = '//...';
    const       REVERT_UNCHANGED    = 'unchanged';

    protected   $_cache             = array();
    protected   $_filespec          = null;

    /**
     * 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  P4_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;
    }

    /**
     * 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   P4_Connection_Interface $connection  optional - a specific connection to use.
     * @todo    throw a specialized file not found exception if the file does not exist.
     */
    public static function fetch($filespec, P4_Connection_Interface $connection = null)
    {
        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // determine whether the file exists.
        $rev = self::exists($filespec, $connection);
        if ($rev === false) {
            throw new P4_File_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'] = $rev;
        return $file;
    }

    /**
     * Fetch all files matching the given query.
     *
     * @param   P4_File_Query|array         $query          A query object or array expressing fstat options.
     * @param   P4_Connection_Interface     $connection     optional - a specific connection to use.
     * @return  P4_Model_Iterator           List of retrieved files.
     * @throws  InvalidArgumentException    if no filespec is given.
     */
    public static function fetchAll($query, P4_Connection_Interface $connection = null)
    {
        if (!$query instanceof P4_File_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 P4_File_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());

        // try/catch parent to deal with the exception we get on non-existend depots
        try {
            $result = $connection->run('fstat', $flags);
        } catch (P4_Connection_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 P4_Model_Iterator;
            }

            // 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() !== P4_Change::DEFAULT_CHANGE) {
            array_pop($dataBlocks);
        }

        // generate file models from fstat output.
        $files = new P4_Model_Iterator;
        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   P4_File_Query|array         $query          A query object or array expressing fstat options.
     * @param   P4_Connection_Interface     $connection     optional - a specific connection to use.
     * @return  P4_Model_Iterator           count of matching files.
     * @todo    optimize to only fetch a single field per file.
     */
    public static function count($query, P4_Connection_Interface $connection = null)
    {
        if (!$query instanceof P4_File_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 P4_File_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() !== P4_Change::DEFAULT_CHANGE
        ) {
            $count--;
        }

        return $count;
    }

    /**
     * Check if the given filespec is known to Perforce.
     *
     * @param   string                  $filespec           a filespec with no wildcards.
     * @param   P4_Connection_Interface $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 exists($filespec, P4_Connection_Interface $connection = null, $excludeDeleted = false)
    {
        static::_validateFilespec($filespec);

        // if no connection given, use default.
        $connection = $connection ?: static::getDefaultConnection();

        // run files to see if file exists.
        $result = $connection->run('files', $filespec);
        if ($result->hasWarnings()) {
            return false;
        } elseif ($excludeDeleted && strstr($result->getData(0, 'action'), 'delete') !== false) {
            return false;
        } else {
            $rev = $result->getData(0, 'rev');

            // this really shouldn't happen; just being defensive
            if ($rev === false) {
                throw new P4_File_Exception('Failed to capture revision during existance test');
            }

            return $rev;
        }
    }

    /**
     * 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  P4_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  P4_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  P4_File_Exception  if the local file cannot be deleted.
     * @return  P4_File provide fluent interface.
     */
    public function deleteLocalFile()
    {
        $localFile = $this->getLocalFilename();
        if (!file_exists($localFile)) {
            throw new P4_File_Exception("Cannot delete local file. File does not exist.");
        }
        chmod($localFile, 0777);
        if (unlink($localFile) === false) {
            throw new P4_File_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  P4_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  P4_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 P4_File_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  P4_File             provide fluent interface.
     * @throws  P4_File_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  P4_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[P4_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;
            } else if ($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  P4_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 P4_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 P4_File_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  P4_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 P4_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  P4_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  P4_File provide fluent interface.
     */
    public function clearAttribute($attribute, $force = false)
    {
        return $this->clearAttributes(array($attribute), $force);
    }

    /**
     * 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;
    }

    /**
     * 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  P4_File_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 P4_File_Exception(
                    "Cannot get status: " . implode(", ", $result->getWarnings())
                );
            }

            if (is_array($result->getData(0))) {
                $this->setStatusCache($result->getData(0));
            } 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 P4_File_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 (P4_File_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  P4_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  P4_File provide fluent interface.
     */
    public function clearStatusCache()
    {
        $this->_cache['status'] = null;

        return $this;
    }

    /**
     * Lock this file in the depot.
     *
     * @return  P4_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  P4_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()
    {
        $head = static::exists($this->getFilespec(true), $this->getConnection());

        if ($head === $this->getStatus('headRev')) {
            return true;
        }

        return false;
    }

    /**
     * Get the contents of the file in Perforce.
     *
     * File content is fetched once and then cached in the instance.
     * The cache can be primed via setContentCache(). It can be cleared
     * via clearContentCache().
     *
     * @return  string              the contents of the file in the depot.
     * @throws  P4_File_Exception   if the print command fails.
     */
    public function getDepotContents()
    {
        if (!array_key_exists('content', $this->_cache)) {
            // verify we have a filespec set; throws if invalid/missing
            $this->_validateHasFilespec();

            $result = $this->getConnection()->run('print', $this->getFilespecWithRevision());

            // check for warnings.
            if ($result->hasWarnings()) {
                throw new P4_File_Exception(
                    "Failed to get depot contents: " . implode(", ", $result->getWarnings())
                );
            }

            $print  = static::_parsePrintOutput($result->getData());

            // get first element
            $print  = reset($print);

            $this->_cache['content']  = $print['content'];
            $this->_cache['revision'] = $print['rev'];
        }
        return $this->_cache['content'];
    }

    /**
     * Get the annotated contents of the file in Perforce.
     *
     * Annotated file content is fetched once and then cached in the instance.
     * The cache can be primed via setAnnotateCache(). It can be cleared
     * via clearAnnotateCache().
     *
     * @return  array  an array of the file's lines, where each array entry looks like:
     *
     * array(
     *     'upper' => <upper version number>,
     *     'lower' => <lower version number>,
     *     'data'  => <text data for the current line>,
     * )
     *
     */
    public function getAnnotateContent()
    {
        if (
            !array_key_exists('annotatedContent', $this->_cache)
            || !isset($this->_cache['annotatedContent'])
        ) {
            // verify we have a filespec set; throws if invalid/missing
            $this->_validateHasFilespec();

            $result = $this->getConnection()->run('annotate', $this->getFilespec(true));
            $annotate = $result->getData();
            // remove the command's metadata
            array_shift($annotate);
            $this->_cache['annotatedContent'] = $annotate;
        }
        return $this->_cache['annotatedContent'];
    }

    /**
     * Clear the annotated content cache.
     *
     * @return  P4_File provide fluent interface.
     */
    public function clearAnnotateCache()
    {
        $this->_cache['annotatedContent'] = null;

        return $this;
    }

    /**
     * Prime the depot file cache with the given value.
     *
     * @param   string  $content    the contents of the file in the depot.
     * @return  P4_File provide fluent interface.
     */
    public function setContentCache($content)
    {
        $this->_cache['content'] = $content;

        return $this;
    }

    /**
     * Clear the depot file cache.
     *
     * @return  P4_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 P4_File_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  P4_File_Exception if the file cannot be written.
     * @return  P4_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 P4_File_Exception($message);
            }
        }
        if (file_put_contents($this->getLocalFilename(), $content) === false) {
            $message = "Failed to write local file.";
            throw new P4_File_Exception($message);
        }

        return $this;
    }

    /**
     * Touch the local client file.
     * If the file does not exist, it will be created.
     *
     * @throws  P4_File_Exception if the file cannot be touched.
     * @return  P4_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 P4_File_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  P4_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  P4_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  P4_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 (P4_Connection_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 P4_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  P4_File             provide fluent interface.
     * @throws  P4_File_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 P4_File_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 P4_File_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 P4_File_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 file syntax.
     *
     * @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 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;
        }

        // otherwise, get depot file from p4 where.
        $where = $this->where();
        return $where[0];
    }

    /**
     * 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);
    }

    /**
     * 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  P4_File_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 P4_File_Exception("Where failed. File is not mapped.");
            }
            $this->_cache['where'] = array_values($result->getData(0));
        }
        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 P4_Change, except for
     *                              the use of FETCH_BY_FILESPEC which is not permitted here.
     * @return  P4_Iterator         Iterator of P4_Changes
     */
    public function getChanges(array $options = null)
    {
        $this->_validateHasFilespec();

        $options = array_merge(
            (array) $options,
            array(P4_Change::FETCH_BY_FILESPEC => $this->getFilespec(true))
        );

        return P4_Change::fetchAll($options, $this->getConnection());
    }

    /**
     * Convenience function to return the change object associated with the file at its current revspec.
     *
     * @return  P4_Change  The associated change object.
     */
    public function getChange()
    {
        return P4_Change::fetch($this->getStatus('headChange'), $this->getConnection());
    }

    /**
     * Strip the revision specifier from a file specification.
     * This removes the \#rev or \@change 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;
    }

    /**
     * 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;
    }

    /**
     * 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);
    }

    /**
     * Return array with all model fields.
     *
     * @return array
     */
    public function getFields()
    {
        return array_keys($this->getStatus());
    }

    /**
     * Return value of given field of the model.
     *
     * @param  string  $field  model field to retrieve
     * @return mixed
     */
    public function getValue($field)
    {
        return $this->getStatus($field);
    }

    /**
     * Parses print output for one or more files into the below format:
     *
     * Array
     * (
     *      [//depot/path/to/file.ext] => Array
     *      (
     *          [depotFile] => //depot/path/to/file.ext
     *          [rev]       => 6
     *          [change]    => 222450
     *          [action]    => edit
     *          [type]      => text
     *          [time]      => 1257743394
     *          [content]   => Full content of file
     *      )
     * )
     *
     * @param   array   $data   Data from p4 print result
     * @return  array           File details and content keyed on depotFile; formated as above
     */
    protected static function _parsePrintOutput($data)
    {
        $files = array();

        // print output consists of the following elements, repeated for each file:
        //  - file info
        //  - file content  (repeated for every 4k of file content, or when server feels like it)
        foreach ($data as $block) {
            // If we are at a meta-data block, store the file name and meta-data then continue
            if (is_array($block) && isset($block['depotFile'])) {
                $name           = $block['depotFile'];
                $files[$name]   = $block;

                // prime content entry as string
                $files[$name]['content']  = '';

                continue;
            }

            // Be defensive, clear file name if we hit an unrecognized block
            if (is_array($block)) {
                $name = null;
            }

            // If we made it this far, and we have a file name, it's a content block; append it
            if (isset($name)) {
                $files[$name]['content'] .= $block;
            }
        }

        return $files;
    }

    /**
     * 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  P4_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 P4_File_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);
        }

        // 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 (these are dead ends!)
        // use the -n flag to see what would happen without actually opening file.
        if (in_array($action, array('edit', 'delete'))) {
            $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)
                ) {
                    throw new P4_File_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 P4_File_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 P4_File_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  P4_File_Exception     if the filespec is empty or invalid
     */
    private function _validateHasFilespec()
    {
        $filespec = $this->getFilespec();

        if (empty($filespec)) {
            throw new P4_File_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  P4_File_Exception     if the filespec has wildcards.
     */
    private static function _validateFilespec($filespec)
    {
        if (!is_string($filespec) ||
            strpos($filespec, "*")   !== false ||
            strpos($filespec, "...") !== false) {
            throw new P4_File_Exception("Invalid filespec provided. In this context, "
                . "filespecs must be a reference to a single file.");
        }
    }

    /**
     * Create the directory structure for the local file.
     */
    private function _createLocalPath()
    {
        if (!is_dir($this->getLocalPath())) {
            if (!mkdir($this->getLocalPath(), 0755, TRUE)) {
                throw new P4_File_Exception("Unable to create path: " . $this->getLocalPath());
            }
        }
    }
}
# Change User Description Committed
#1 16170 perforce_software Move Chronicle files to follow new path scheme for branching.
//guest/perforce_software/chronicle/library/P4/File.php
#1 8972 Matt Attaway Initial add of the Chronicle source code