BranchController.php #1

  • //
  • guest/
  • perforce_software/
  • chronicle/
  • main/
  • application/
  • site/
  • controllers/
  • BranchController.php
  • View
  • Commits
  • Open Download .zip Download (48 KB)
<?php
/**
 * Controller for dealing with site branches.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 * @todo        enforce permissions - what p4 rights are req'd to make streams?
 */
class Site_BranchController extends Zend_Controller_Action
{
    public $contexts = array(
        'manage'        => array('json'),
        'add'           => array('json', 'partial'),
        'edit'          => array('json', 'partial'),
        'delete'        => array('json'),
        'pull'          => array('json', 'partial'),
        'pull-details'  => array('json', 'partial'),
        'switch'        => array('json')
    );

    /**
     * Set the management layout for all actions.
     */
    public function init()
    {
        $this->getHelper('layout')->setLayout('manage-layout');
    }

    /**
     * Transfer user's credentials to this branch if they are not
     * already logged in. Supports jsonp if 'callback' is passed.
     */
    public function switchAction()
    {
        // force json context.
        $this->contextSwitch->initContext('json');

        $user      = P4Cms_User::fetchActive();
        $view      = $this->view;
        $request   = $this->getRequest();
        $sessionId = $request->getParam('sessionId');
        $csrf      = $request->getParam(P4Cms_Form::CSRF_TOKEN_NAME);
        $callback  = $request->getParam('callback');

        // whitelist the allowed characters for callbacks
        $callback  = preg_replace('/[^a-zA-Z0-9_\.]/', '', $callback);

        // if the user isn't already logged into this branch, and a
        // session id is present for another branch, try and clone
        // the session data from the branch the user is switching
        // from so that they will remain logged in on the new branch.
        if ($user->isAnonymous() && $sessionId) {
            // pull out any current session data for this branch
            // we will merge it with the auth and cache details
            // present on the branch they are switching from
            $original = $_SESSION;

            // kill our current session so we can read in the session
            // data of the branch they are switching from
            session_destroy();

            // force our session id to the passed value
            session_id($sessionId);

            // try to restart, we silence warnings that occur for invalid IDs
            @session_start();

            // regenerate a fresh id but don't destroy the passed session
            session_regenerate_id(false);

            // only copy over the authentication and cache details
            // if the csrf token matches, otherwise we leave the
            // current session data alone
            $data = array();
            if ($csrf === P4Cms_Form::getCsrfToken()) {
                $authKey   = Zend_Auth::getInstance()->getStorage()->getNamespace();
                $cacheKey  = P4Cms_Cache_Frontend_Action::SESSION_NAMESPACE;
                $_SESSION += array($authKey => null, $cacheKey => null);
                $data      = array(
                    $authKey  => $_SESSION[$authKey],
                    $cacheKey => $_SESSION[$cacheKey]
                );
            }

            // blend any source branch data (auth/cache) with the
            // existing destinations branch's session data
            $_SESSION = $data + $original;
        }

        $view->callback = $callback;
        $view->site     = P4Cms_Site::fetchActive();
    }

    /**
     * Loads manage grid pre-filtered for the active site.
     */
    public function manageActiveAction()
    {
        $site = P4Cms_Site::fetchActive();
        $this->getRequest()->setParam('site', array('sites' => array($site->getSiteId())));

        $this->_forward('manage');
    }

    /**
     * List sites/branches for management.
     *
     * @publishes   p4cms.site.branch.grid.actions
     *              Modify the passed menu (add/modify/delete items) to influence the actions shown
     *              on entries in the Manage Sites and Branches grid.
     *              P4Cms_Navigation            $actions    A menu to hold grid actions.
     *
     * @publishes   p4cms.site.branch.grid.data.item
     *              Return the passed item after applying any modifications (add properties, change
     *              values, etc.) to influence the row values sent to the Manage Sites and Branches
     *              grid.
     *              array                       $item       The item to potentially modify.
     *              mixed                       $model      The original object/array that was used
     *                                                      to make the item.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.site.branch.grid.data
     *              Adjust the passed data (add properties, modify values, etc.) to influence the
     *              row values sent to the Manage Sites and Branches grid.
     *              Zend_Dojo_Data              $data       The data to be filtered.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.site.branch.grid.populate
     *              Adjust the passed iterator (possibly based on values in the passed form) to
     *              filter which branches will be shown on the Manage Sites and Branches grid.
     *              P4Cms_Model_Iterator        $branches   An iterator of P4Cms_Site objects.
     *              P4Cms_Form_PubSubForm       $form       A form containing filter options.
     *
     * @publishes   p4cms.site.branch.grid.render
     *              Make adjustments to the datagrid helper's options pre-render (e.g. change
     *              options to add columns) for the Manage Sites and Branches grid.
     *              Ui_View_Helper_DataGrid     $helper     The view helper that broadcast this
     *                                                      topic.
     *
     * @publishes   p4cms.site.branch.grid.form
     *              Make arbitrary modifications to the Manage Sites and Branches filters form.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *
     * @publishes   p4cms.site.branch.grid.form.subForms
     *              Return a Form (or array of Forms) to have them added to the Manage Sites and
     *              Branches filters form. The returned form(s) should have a 'name' set on them to
     *              allow them to be uniquely identified.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *
     * @publishes   p4cms.site.branch.grid.form.preValidate
     *              Allows subscribers to adjust the Manage Sites and Branches filters form prior to
     *              validation of the passed data. For example, modify element values based on
     *              related selections to permit proper validation.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     An associative array of form values.
     *
     * @publishes   p4cms.site.branch.grid.form.validate
     *              Return false to indicate the Manage Sites and Branches filters form is invalid.
     *              Return true to indicate your custom checks were satisfied, so form validity
     *              should be unchanged.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     An associative array of form values.
     *
     * @publishes   p4cms.site.branch.grid.form.populate
     *              Allows subscribers to adjust the Manage Sites and Branches filters form after it
     *              has been populated with the passed data.
     *              P4Cms_Form_PubSubForm       $form       The form that published this event.
     *              array                       $values     The values passed to the populate
     *                                                      method.
     */
    public function manageAction()
    {
        // enforce permissions.
        $this->acl->check('site', 'manage-branches');

        // setup list options form
        $request        = $this->getRequest();
        $gridNamespace  = 'p4cms.site.branch.grid';
        $view           = $this->view;

        // collect the actions from interested parties
        $actions = new P4Cms_Navigation;
        P4Cms_PubSub::publish($gridNamespace . '.actions', $actions);
        $view->actions = $actions;

        // determine whether to show add site/branch footer buttons.
        $view->showAddSiteButton   = $this->acl->isAllowed('site', 'add');
        $view->showAddBranchButton = P4Cms_Site::fetchAll(
            array(P4Cms_Site::FETCH_BY_ACL => array('branch', 'pull-from'))
        )->count();

        // set DataGrid view helper namespace
        $helper = $view->dataGrid();
        $helper->setNamespace($gridNamespace);

        // create a list of sites and their branches.
        // the 'site' entries really represent the depot the site branches
        // live in. we don't actually have an object for this so we simply
        // use a P4Cms_Model with a few basic details.
        $user     = P4Cms_User::fetchActive();
        $active   = P4Cms_Site::fetchActive();
        $branches = P4Cms_Site::fetchAll();
        $items    = new P4Cms_Model_Iterator;
        $lastSite = null;
        foreach ($branches as $branch) {
            // if this branch is on a new site (depot really) add a
            // site entry to our list of items
            $siteId = $branch->getSiteId();
            if ($lastSite != $siteId) {
                $item = new P4Cms_Model;
                $item->setId($branch->getSiteId());
                $item->setValues(
                    array(
                        'siteId'    => $item->getId(),
                        'type'      => 'site',
                        'owner'     => P4_Depot::fetch($item->getId())->getOwner(),
                        'name'      => $branch->getConfig()->getTitle()
                    )
                );
                $items[]  = $item;
                $lastSite = $siteId;
            }

            // pull the useful details off the branch and its stream/config
            // and place them onto a new generic model
            $stream = $branch->getStream();
            $config = $branch->getConfig();
            $parent = $stream->getParent();
            $item   = new P4Cms_Model;
            $item->setId($branch->getId());
            $item->setValues(
                array(
                    'siteId'        => $branch->getSiteId(),
                    'type'          => $stream->getType(),
                    'owner'         => $stream->getOwner(),
                    'name'          => $stream->getName(),
                    'basename'      => $branch->getBranchBasename(),
                    'parent'        => $parent,
                    'parentName'    => $parent && isset($items[$parent]) ? $items[$parent]->name : null,
                    'description'   => $stream->getDescription(),
                    'siteTitle'     => $config->getTitle(),
                    'url'           => $config->getUrl(),
                    'depth'         => $stream->getDepth(),
                    'branch'        => $branch,
                    'isParent'      => false,
                    'isActive'      => $branch->getId() == $active->getId(),
                    'canPull'       => $user->isAllowed('branch', 'pull-from', $branch->getAcl()),
                    'canDelete'     => $branch->getId() != $active->getId() && $stream->getType() !== 'mainline'
                )
            );
            $items[$branch->getId()] = $item;

            // update isParent flag on parent's item
            if ($parent && isset($items[$parent])) {
                $items[$parent]->setValue('isParent', true)
                               ->setValue('canDelete', false);
            }
        }

        // create the site form now that we have the list of items to hand it
        $form = new Site_Form_BranchGridOptions(
            array(
                'namespace'   => $gridNamespace,
                'items'       => $items
            )
        );
        $form->populate($request->getParams());

        // complete setting up view
        $view->form         = $form;
        $view->pageSize     = $request->getParam('count', 100);
        $view->rowOffset    = $request->getParam('start', 0);
        $view->pageOffset   = round($view->rowOffset / $view->pageSize, 0) + 1;
        $view->headTitle()->set('Sites and Branches');

        // early exit for standard requests (ie. not json)
        if (!$this->contextSwitch->getCurrentContext()) {
            // ensure we are using the management layout
            $this->getHelper('layout')->setLayout('manage-layout');
            $this->getHelper('helpUrl')->setUrl('branches.html');

            return;
        }

        // create a copy so we can later restore the 'obligitory' items
        $copy = new P4Cms_Model_Iterator($items->getArrayCopy());

        // allow third-parties to influence list
        try {
            P4Cms_PubSub::publish($gridNamespace . '.populate', $items, $form);
        } catch (Exception $e) {
            P4Cms_Log::logException("Error building branches list.", $e);
        }

        // put back any missing parents so the tree can display properly
        $items = $this->_restoreObligatory($items, $copy);

        // compose list of sorted items
        $view->items = $items;
    }

    /**
     * Edit site branch.
     */
    public function editAction()
    {
        // enforce permissions.
        $this->acl->check('site', 'manage-branches');

        // set up view
        $request    = $this->getRequest();
        $form       = new Site_Form_EditBranch;
        $view       = $this->view;
        $view->form = $form;
        $view->headTitle()->set('Edit Branch');

        // fetch the branch to edit
        $id         = $request->getParam('id', P4Cms_Site::fetchActive()->getId());
        $branch     = P4Cms_Site::fetch($id);
        $stream     = $branch->getStream();

        // populate form from the request if posted, otherwise from the storage
        $data = $request->isPost()
            ? $request->getParams()
            : array(
                'id'          => $id,
                'name'        => $stream->getName(),
                'parent'      => $stream->getParent(),
                'description' => $stream->getDescription(),
                'urls'        => implode(', ', $branch->getConfig()->getUrls())
            );
        $form->populate($data);

        // if posted, validate the form and update the branch
        if ($request->isPost() && $form->isValid($request->getPost())) {

            // update stream related to the branch
            $stream->setName($form->getValue('name'))
                   ->setParent($form->getValue('parent'))
                   ->setDescription($form->getValue('description'))
                   ->save();

            // update branch url
            $branch->getConfig()
                   ->setUrls($form->getValue('urls'))
                   ->save();

            // clear the global 'sites' cache.
            P4Cms_Cache::remove(P4Cms_Site::CACHE_KEY, 'global');

            // set notification message
            $view->message = "Branch '" . $form->getValue('name') . "' has been successfully updated.";

            // for traditional requests, add notification message and redirect
            if (!$this->contextSwitch->getCurrentContext()) {
                P4Cms_Notifications::add(
                    $view->message,
                    P4Cms_Notifications::SEVERITY_SUCCESS
                );
                $this->redirector->gotoSimple('manage');
            }
        }

        // if form contains errors, set response code and exit
        if ($form->getMessages()) {
            $this->getResponse()->setHttpResponseCode(400);
            $view->errors = $form->getMessages();
            return;
        }
    }

    /**
     * Add a new site branch.
     *
     * @publishes   p4cms.site.branch.add.preSubmit
     *              Provides an opportunity for modules to modify a new branch just prior to its
     *              files being committed to Perforce.
     *              P4Cms_Site              $branch     The branch being added.
     *              P4Cms_Site              $parent     The new branch's parent branch.
     *              P4Cms_Record_Adapter    $adapter    The current storage connection adapter.
     *
     * @publishes   p4cms.site.branch.add.postSubmit
     *              Provides an opportunity for modules to modify a new branch just after its
     *              files have been committed to Perforce.
     *              P4Cms_Site              $branch     The branch just added.
     *              P4Cms_Site              $parent     The new branch's parent branch.
     *              P4Cms_Record_Adapter    $adapter    The current storage connection adapter.
     */
    public function addAction()
    {
        // set up view
        $request    = $this->getRequest();
        $form       = new Site_Form_Branch;
        $view       = $this->view;
        $view->form = $form;
        $view->headTitle()->set('Add Branch');

        // populate form from request to fill in default values
        $form->populate($request->getParams());

        if ($request->isPost() && $form->isValid($request->getPost())) {

            // verify we are allowed to pull from parent.
            $parent = P4Cms_Site::fetch($form->getValue('parent'));
            $this->acl->check('branch', 'pull-from', null, null, $parent->getAcl());

            // compose new branch id.
            $filter = new P4Cms_Filter_TitleToId;
            $id     = '//' . $form->getValue('site') . '/' . $filter->filter($form->getValue('name'));

            // resolve conflicting ids by appending an incrementing number
            for ($raw = $id, $i = 2; P4Cms_Site::exists($id); $i++) {
                $id = $raw . '-' . $i;
            }

            // create the site stream (each site branch relates 1:1 with a stream)
            $stream = new P4_Stream();
            $stream->setId($id)
                   ->setName($form->getValue('name'))
                   ->setDescription($form->getValue('description'))
                   ->setParent($form->getValue('parent'))
                   ->setType('development')
                   ->setOwner(P4Cms_User::fetchActive()->getId())
                   ->setPaths('share ...')
                   ->save();

            // fetch our new site/branch object (must clear site cache first)
            P4Cms_Cache::remove(P4Cms_Site::CACHE_KEY, 'global');
            $branch = P4Cms_Site::fetch($stream->getId());

            // setup a new batch operation to contain the branch copy and configure.
            $p4      = $branch->getConnection();
            $adapter = $branch->getStorageAdapter();
            $change  = $adapter->beginBatch(
                'Creating ' . $stream->getId() . ' from ' . $stream->getParent()
            );

            // copy data from parent to the new branch.
            //  -S  indicates the new stream
            //  -r  so it goes from parent to stream
            //  -c  to put it in the batch
            //  -F  to force the copy
            //  -v  don't copy files to workspace
            $p4->run('copy', array('-c', $change, '-vrFS', $stream->getId()));

            // configure new branch according to parent branch and new branch form.
            $branch->getConfig()
                   ->setValues($parent->getConfig()->getValues())
                   ->setUrls($form->getValue('urls'))
                   ->save();

            // by default new branches should not be accessible by anonymous users.
            // we assume that new branches are for staging, testing, etc.
            $acl = clone $parent->getAcl();
            $acl->setRecord($branch->getAcl()->getRecord());
            $acl->setRule(
                P4Cms_Acl::OP_REMOVE,
                P4Cms_Acl::TYPE_ALLOW,
                P4Cms_Acl_Role::ROLE_ANONYMOUS,
                'branch',
                'access'
            )->save();

            // give third-parties a chance to modify new branch.
            P4Cms_PubSub::publish(
                'p4cms.site.branch.add.preSubmit',
                $branch,
                $parent,
                $adapter
            );

            // commit the copy and configure.
            $adapter->commitBatch();

            // give third-parties the chance to react to a new branch
            P4Cms_PubSub::publish(
                'p4cms.site.branch.add.postSubmit',
                $branch,
                $parent,
                $adapter
            );

            // clear the global 'sites' cache (again).
            P4Cms_Cache::remove(P4Cms_Site::CACHE_KEY, 'global');

            // set notification message
            $view->message = "Branch '" . $form->getValue('name') . "' has been successfully added.";

            // add a notification if requested or in a traditional context.
            if ($request->getParam('notify') || !$this->contextSwitch->getCurrentContext()) {
                P4Cms_Notifications::add(
                    $view->message,
                    P4Cms_Notifications::SEVERITY_SUCCESS
                );
            }

            // for traditional requests, redirect
            if (!$this->contextSwitch->getCurrentContext()) {
                $this->redirector->gotoUrl($request->getBaseUrl());
            }
        }

        // if form contains errors, set response code and exit
        if ($form->getMessages()) {
            $this->getResponse()->setHttpResponseCode(400);
            $view->errors = $form->getMessages();
            return;
        }
    }

    /**
     * Delete a site branch.
     */
    public function deleteAction()
    {
        // enforce permissions
        $this->acl->check('site', 'manage-branches');

        // deny if not accessed via post
        $request = $this->getRequest();
        if (!$request->isPost()) {
            throw new P4Cms_AccessDeniedException(
                "Deleting branches is not permitted in this context."
            );
        }

        // delete stream associated with the branch
        $id     = $request->getParam('id');
        $stream = P4_Stream::fetch($id);
        $stream->delete(true);

        // clear all caches - this clears the global site
        // cache and everything related to this site
        P4Cms_Cache::clean();

        // set notification and redirect for traditional requests
        if (!$this->contextSwitch->getCurrentContext()) {
            P4Cms_Notifications::add(
                'Branch "'. $stream->getName() .'" has been deleted.',
                P4Cms_Notifications::SEVERITY_SUCCESS
            );
            $this->redirector->gotoSimple('manage');
        }

        $this->view->branchId = $id;
    }

    /**
     * Pull changes from another branch to the current branch.
     *
     * @publishes   p4cms.site.branch.pull.postSubmit
     *              Provides an opportunity for modules to react to pulling changes from the source
     *              branch into the target branch just after the files have been committed to
     *              Perforce.
     *              Site_Model_PullPathGroup    $paths      The paths affected by this pull, grouped
     *                                                      logically.
     *              P4Cms_Site                  $target     The target branch for the pull.
     *              P4Cms_Site                  $source     The source branch for the pull.
     *              P4Cms_Record_Adapter        $adapter    The current storage connection adapter.
     */
    public function pullAction()
    {
        // enforce permissions (we will check pull-from later)
        $this->acl->check('branch', 'pull-into');

        $request = $this->getRequest();
        $mode    = $request->getParam('mode', Site_Form_Pull::MODE_MERGE);
        $paths   = new Site_Model_PullPathGroup;
        $adapter = P4Cms_User::fetchActive()->getPersonalAdapter();
        $target  = P4Cms_Site::fetchActive();
        $source  = $request->getParam('source')
            ? P4Cms_Site::fetch($request->getParam('source'))
            : null;

        // verify that we can pull from the selected source.
        if ($source) {
            $this->acl->check('branch', 'pull-from', null, null, $source->getAcl());
        }

        // if the request is posted and a source head-change has been
        // specified, we will use this to "pin" the pull to that point
        // in time - if we don't do this, newly submitted files might
        // make their way into the pull operation.
        if ($request->getPost('headChange')) {
            $headChange = $request->getPost('headChange');
        } else {
            $headChange = P4_Change::fetchAll(
                array(
                    P4_Change::FETCH_BY_STATUS => P4_Change::SUBMITTED_CHANGE,
                    P4_Change::FETCH_MAXIMUM   => 1
                ),
                $adapter->getConnection()
            )->first()->getId();

            // set on request so it makes its way into the form.
            $request->setParam('headChange', $headChange);
        }

        // if we have a source selected, preview the pull so that
        // we can inform the user about what paths will be affected
        // this means that we actually perform the pull twice if
        // the user is posting (doing the pull) - we can't skip this
        // step because we need to know all of the path groups to
        // validate the user's input.
        // @todo    consider caching this result for performance.
        if ($source) {
            $paths = $this->_doPull($source, $target, $mode, $headChange, $adapter, null, true);
        }

        // set the pull mode and source on the path group so that
        // the form can investigate how the paths were generated
        $paths->setValues(array('mode' => $mode, 'source' => $source));

        // set up view
        $form       = new Site_Form_Pull(array('pathGroup' => $paths));
        $view       = $this->view;
        $view->form = $form;
        $view->headTitle()->set('Pull Changes');

        // allow form to be primed via get params. we exclude 'paths' when
        // doing this as we want to keep the defaults if this isn't a post.
        $values = $request->getParams();
        unset($values['paths']);
        $form->populate($values);

        if ($request->isPost() && $form->isValid($request->getParams())) {

            // collect selected paths to merge from the form.
            // the form only contains ids that relate back to path groups.
            // we need to find the groups associated with those ids to get the paths.
            $include = array();
            foreach ($form->getValue('paths') as $pathGroupId) {
                $group = $paths->findById($pathGroupId);
                if ($group) {
                    $include = array_merge($include, $group->getIncludePaths());
                }
            }

            // perform the pull
            $paths    = $this->_doPull($source, $target, $mode, $headChange, $adapter, $include);
            $affected = $paths->getCount($paths::RECURSIVE);

            // give third-parties the chance to react to a completed pull
            P4Cms_PubSub::publish(
                'p4cms.site.branch.pull.postSubmit',
                $paths,
                $target,
                $source,
                $adapter
            );

            // clear all caches because pull can have a very broad impact
            P4Cms_Cache::clean();

            // set notification message
            $view->severity = P4Cms_Notifications::SEVERITY_SUCCESS;
            $view->message  = "Pulled " . $affected . " item" . ($affected != 1 ? "s" : "")
                            . " from '" . $source->getStream()->getName() . "'.";

            // we add the notification even for context specific requests
            // because the JS that drives the pull dialog does a reload
            // and the user wouldn't otherwise see a notification.
            P4Cms_Notifications::add($view->message, $view->severity);

            // for traditional requests, add notification message and redirect
            if (!$this->contextSwitch->getCurrentContext()) {
                $this->redirector->gotoUrl($request->getBaseUrl());
            }
        }

        // if form contains errors, set response code and exit
        if ($form->getMessages()) {
            $this->getResponse()->setHttpResponseCode(400);
            $view->errors = $form->getMessages();
            return;
        }
    }

    /**
     * Provides the details for specified include path(s).
     */
    public function pullDetailsAction()
    {
        // enforce 'pull-into' permission (later we check pull-from).
        $this->acl->check('branch', 'pull-into');

        // default to partial context if none is specified
        if (!$this->contextSwitch->getCurrentContext()) {
            $this->contextSwitch->initContext('partial');
        }

        $request    = $this->getRequest();
        $mode       = $request->getParam('mode', Site_Form_Pull::MODE_MERGE);
        $headChange = $request->getParam('headChange') ?: 'now';
        $groupId    = $request->getParam('groupId');
        $adapter    = P4Cms_User::fetchActive()->getPersonalAdapter();
        $target     = P4Cms_Site::fetchActive();
        $source     = P4Cms_Site::fetch($request->getParam('source'));

        // enforce pull-from permission
        $this->acl->check('branch', 'pull-from', null, null, $source->getAcl());

        $groups  = $this->_doPull($source, $target, $mode, $headChange, $adapter, null, true);
        $group   = $groups->findById($groupId);
        $details = $group->getDetails(Site_Model_PullPathGroup::RECURSIVE);
        $columns = $request->getParam('columns', $details->getProperty('columns'));

        // sort details by conflict, then by label (so conflicts are first)
        $details->sortBy(
            array(
                'conflict'  => array($details::SORT_DESCENDING),
                'label'     => array($details::SORT_NATURAL, $details::SORT_NO_CASE)
            )
        );

        $this->view->groups  = $groups;
        $this->view->details = $details;
        $this->view->columns = $columns;
    }

    /**
     * Pull from source branch to target.
     *
     * @param   P4Cms_Site                  $source         the site/branch to copy from.
     * @param   P4Cms_Site                  $target         the site/branch to copy into.
     * @param   string                      $mode           merge or copy
     * @param   int                         $headChange     limit source files to this change
     *                                                      (ignore newer revisions)
     * @param   P4Cms_Record_Adapter        $adapter        the storage adapter to use.
     * @param   array|null                  $include        the list of paths to pull (null for all)
     * @param   bool|null                   $preview        set to true to revert instead of commit
     *                                                      needed to detect conflicts.
     * @return  Site_Model_PullPathGroup    the paths affected by this pull grouped logically.
     *
     * @publishes   p4cms.site.branch.pull.preSubmit
     *              Provides an opportunity for modules to react to pulling changes from the source
     *              branch into the target branch just prior to the paths being grouped. This event
     *              will occur for both actual pulls (where a postSubmit event will follow) and for
     *              previews (used to inform the user of which files are available for pull) where
     *              the data is never actually submitted.
     *              P4Cms_Site              $target     The target branch for the pull.
     *              P4Cms_Site              $source     The source branch for the pull.
     *              int                     $headChange A numerical revision representing the head
     *                                                  change on the source branch to pull.
     *              P4Cms_Record_Adapter    $adapter    The current storage connection adapter.
     */
    protected function _doPull(
        P4Cms_Site $source,
        P4Cms_Site $target,
        $mode,
        $headChange,
        P4Cms_Record_Adapter $adapter,
        array $include = null,
        $preview = false)
    {
        // include defaults to all files.
        $include = $include ?: array($target->getId() . '/...');

        // append head change to all of the include paths so that
        // we don't pull changes newer than those shown to the user.
        foreach ($include as &$path) {
            $path .= "@" . $headChange;
        }

        // begin a new batch to contain the pull operation
        $adapter->beginBatch(
            ($mode == Site_Form_Pull::MODE_COPY ? 'Copying' : 'Merging')
            . ' from ' . $source->getStream()->getName()
            . ' to '   . $target->getStream()->getName()
        );

        // two modes of operation:
        //   copy - clones source branch into target
        //  merge - propagates new changes since last pull
        if ($mode == Site_Form_Pull::MODE_COPY) {
            $conflicts = $this->_doCopy($source, $target, $include, $headChange, $preview, $adapter);
        } else {
            $conflicts = $this->_doMerge($source, $target, $include, $headChange, $preview, $adapter);
        }

        // give third-parties one last shot at modifying pull
        P4Cms_PubSub::publish(
            'p4cms.site.branch.pull.preSubmit',
            $target,
            $source,
            $headChange,
            $adapter
        );

        // determine which files have been affected by this pull operation
        // and organize the affected paths into human-friendly groups.
        $result = $adapter->getConnection()->run(
            'fstat',
            array(
                '-e',
                $adapter->getBatchId(),
                '-Ro',
                '-T',
                'depotFile,action',
                $target->getId() . '/...'
            )
        );
        $paths = $this->_groupPullPaths($result, $conflicts, $source, $target);

        // if we're previewing, revert and return early.
        if ($preview) {
            $adapter->revertBatch();
            return $paths;
        }

        // submit our pull operation.
        $adapter->commitBatch();

        // pulling changes can affect everything, clear all caches.
        P4Cms_Cache::clean();

        return $paths;
    }

    /**
     * Copy (clobber) from source branch to target.
     *
     * Returns a list of files with changes in the target
     * (not present in the source) that will be overwritten.
     *
     * @param   P4Cms_Site              $source         the site/branch to copy from.
     * @param   P4Cms_Site              $target         the site/branch to copy into.
     * @param   array                   $include        the list of paths to copy.
     * @param   int                     $headChange     limit source files to this change
     *                                                  (ignore newer revisions)
     * @param   bool|null               $preview        set to true if this is only a preview
     * @param   P4Cms_Record_Adapter    $adapter        the storage adapter to use.
     * @return  array                   files with conflicting changes (in depot syntax)
     */
    protected function _doCopy(
        P4Cms_Site $source,
        P4Cms_Site $target,
        array $include,
        $headChange,
        $preview,
        P4Cms_Record_Adapter $adapter)
    {
        $p4 = $adapter->getConnection();

        // copy data from source branch to the active branch.
        //  -S  indicates the source stream
        //  -P  indicates the target stream
        //  -c  to put it in the batch
        //  -F  to force the copy
        //  -v  don't copy files to workspace
        //  leading filespec arguments to limit scope (batched)
        $change   = $adapter->getBatchId();
        $batches  = $p4->batchArgs(
            $include,
            array('-vF', '-c', $change, '-S', $source->getId(), '-P', $target->getId())
        );
        foreach ($batches as $batch) {
            $p4->run('copy', $batch);
        }

        // to detect conflicts (files in the target with changes
        // that will be overwritten), we preview a merge in the
        // opposite direction (target -> source)

        // we need to do this using the source's connection
        $p4 = $source->getStorageAdapter()->getConnection();

        // we need to reverse the include paths to reference the
        // source branch instead of the target branch - we also
        // strip the 'headChange' revspec so that we don't miss
        // new conflicts on the target branch.
        $targetBase = $target->getId() . "/";
        $sourceBase = $source->getId() . "/";
        $reverse    = array();
        foreach ($include as $path) {
            if (strpos($path, $targetBase) === 0) {
                $reverse[] = P4_File::stripRevspec(
                    $sourceBase . substr($path, strlen($targetBase))
                );
            }
        }

        // preview the reverse merge
        //  -S  indicates the source stream
        //  -P  indicates the target stream
        //  -F  to force the merge
        //  -n  to preview the merge
        //  plus filespec arguments to limit scope (batched)
        $batches = $p4->batchArgs(
            $reverse,
            array('-F', '-n', '-S', $target->getId(), '-P', $source->getId())
        );

        // collect conflicts from merge result - note we need to
        // modify the depot files to reference the target branch.
        $conflicts = array();
        foreach ($batches as $batch) {
            $result = $p4->run('merge', $batch);
            foreach ($result->getData() as $conflict) {
                if (isset($conflict['depotFile'])
                    && strpos($conflict['depotFile'], $sourceBase) === 0
                ) {
                    $conflicts[] = $targetBase
                                 . substr($conflict['depotFile'], strlen($sourceBase));
                }
            }
        }

        return $conflicts;
    }

    /**
     * Merge changes from source branch to target.
     *
     * Returns a list of files with changes in the target
     * (not present in the source) that will be overwritten.
     *
     * @param   P4Cms_Site              $source         the site/branch to merge from.
     * @param   P4Cms_Site              $target         the site/branch to merge into.
     * @param   array                   $include        the list of paths to merge.
     * @param   int                     $headChange     limit source files to this change
     * @param   bool|null               $preview        set to true if this is only a preview
     * @param   P4Cms_Record_Adapter    $adapter        the storage adapter to use.
     *                                                  (ignore newer revisions)
     * @return  array                   files with conflicting changes (in depot syntax)
     *
     * @publishes   p4cms.site.branch.pull.conflicts
     *              Intended to provide modules an opportunity to programmatically resolve conflicts
     *              where possible. A resolve '-as' is run prior to this event so only files that
     *              were not safely auto resolved will be included. Any files which remain
     *              unresolved will be shown with a conflict warning to the end user.
     *              P4_Result               $conflicts  A list of the conflicts encountered during
     *                                                  a pull operation.
     *              P4Cms_Site              $target     The target branch for the pull.
     *              P4Cms_Site              $source     The source branch for the pull.
     *              int                     $headChange The head change number this pull was pinned
     *                                                  to.
     *              bool                    $preview    Set to true if the pull operation is just a
     *                                                  preview, false if the pull operation is to
     *                                                  be completed.
     *              P4Cms_Record_Adapter    $adapter    The current storage connection adapter.
     */
    protected function _doMerge(
        P4Cms_Site $source,
        P4Cms_Site $target,
        array $include,
        $headChange,
        $preview,
        P4Cms_Record_Adapter $adapter)
    {
        $p4 = $adapter->getConnection();

        // merge data from source branch to the active branch.
        //  -S  indicates the source stream
        //  -P  indicates the target stream
        //  -c  to put it in the batch
        //  -F  to force the merge
        //  plus filespec arguments to limit scope (batched)
        $change   = $adapter->getBatchId();
        $batches  = $p4->batchArgs(
            $include,
            array('-F', '-c', $change, '-S', $source->getId(), '-P', $target->getId())
        );
        foreach ($batches as $batch) {
            $p4->run('merge', $batch);
        }

        // perform initial safe-resolve to deal with files that can be
        // merged cleanly (only if source has changed and target has not)
        $p4->run('resolve', array('-as'));

        // allow interested parties to handle outstanding (unsafe) conflicts.
        // the last entry is the change description, so we remove it here.
        $conflicts = $p4->run('fstat', array('-e', $change, '-Ru', $target->getId() . '/...'));
        $conflicts->setData(array_slice($conflicts->getData(), 0, -1));

        P4Cms_PubSub::publish(
            'p4cms.site.branch.pull.conflicts',
            $conflicts,
            $target,
            $source,
            $headChange,
            $preview,
            $adapter
        );

        // make a final determination as to which files have
        // conflicts that cannot be safely resolved
        $result = $p4->run(
            'fstat',
            array(
                '-e',
                $change,
                '-Ru',
                '-T',
                'depotFile',
                $target->getId() . '/...'
            )
        );

        // extract depot-files as a flat list.
        $conflicts = array();
        foreach ($result->getData() as $conflict) {
            if (isset($conflict['depotFile'])) {
                $conflicts[] = $conflict['depotFile'];
            }
        }

        // resolve remaining conflicts with the source branch as the authority.
        $p4->run('resolve', array('-at'));

        return $conflicts;
    }

    /**
     * Group the paths affected by a pull operation in a human-friendly way.
     *
     * @param   P4_Result   $result         the output from a merge or copy command
     * @param   array       $conflicts      a flat list of depot-files in conflict.
     * @param   P4Cms_Site  $source         the source site/branch of the pull operation
     * @param   P4Cms_Site  $target         the target site/branch of the pull operation
     * @return  Site_Model_PullPathGroup    the paths affected by this pull grouped logically.
     *
     * @publishes   p4cms.site.branch.pull.groupPaths
     *              The passed paths object starts with all paths being pulled directly associated
     *              with it. Modules should add sub-groups and move logically grouped paths into
     *              them. They can also set callbacks on the sub-groups to provide human friendly
     *              entry titles and counts. Any paths left at the top level will be automatically
     *              moved into an 'Other' group after this event completes.
     *              Site_Model_PullPathGroup    $paths      The path structure to be organized.
     *              P4Cms_Site                  $source     The source branch.
     *              P4Cms_Site                  $target     The target branch.
     *              P4_Result                   $result     the output from a merge or copy command.
     */
    protected function _groupPullPaths($result, array $conflicts, $source, $target)
    {
        $paths = new Site_Model_PullPathGroup;

        // put all paths in the root initially.
        // here we also check if this file is conflicting.
        foreach ($result->getData() as $path) {
            if (isset($path['depotFile'])) {
                $path['conflict'] = in_array($path['depotFile'], $conflicts);
                $paths->addPath($path);
            }
        }

        // let third-parties organize paths.
        // the objective here is to move paths from the
        // root down into sub-groups.
        P4Cms_PubSub::publish(
            'p4cms.site.branch.pull.groupPaths',
            $paths,
            $source,
            $target,
            $result
        );

        // put all remaining root paths into a 'other' sub-group.
        if ($paths->getPaths()->count()) {
           $other = new Site_Model_PullPathGroup(
               array(
                   'label' => 'Other',
                   'order' => 100,
                   'paths' => $paths->getPaths()
               )
           );
           $paths->setPaths(null);
           $paths->addSubGroup($other);
        }

        return $paths;
    }

    /**
     * Scans over the filtered list of branch/site items and re-adds any missing
     * ancestors to ensure we can show a full heirachy to our matches.
     *
     * @param   P4Cms_Model_Iterator    $items      The filtered list of items
     * @param   P4Cms_Model_Iterator    $originals  A full list of all items
     * @return  P4Cms_Model_Iterator    A new iterator with the obligatory items restored
     */
    protected function _restoreObligatory(P4Cms_Model_Iterator $items, P4Cms_Model_Iterator $originals)
    {
        // produce an original list indexed by id for later lookups
        $originalsById = new P4Cms_Model_Iterator;
        foreach ($originals as $original) {
            $originalsById[$original->getId()] = $original;
        }

        // produce an array of obligatory items to later tack back on
        $obligatory = array();
        $itemKeys   = $items->invoke('getId');
        foreach ($items as $item) {
            // skip over site entries; they won't have any parents
            if ($item->getValue('type') == 'site') {
                continue;
            }

            // if the item's site isn't listed; add it to obligatory
            if (!in_array($item->getValue('siteId'), $itemKeys)) {
                $obligatory[] = $item->getValue('siteId');
            }

            // switch to the 'stream' layer and go through parents
            $parent = $item->getValue('branch')->getStream();
            while ($parent = $parent->getParentObject()) {
                if (!in_array($parent->getId(), $itemKeys)) {
                    $obligatory[] = $parent->getId();
                }
            }
        }

        // append and mark obligatory items but maintain original item ordering
        $obligatory = array_unique($obligatory);
        $result     = new P4Cms_Model_Iterator;
        foreach ($originalsById as $id => $item) {
           if (in_array($id, $obligatory)) {
               $item->setValue('obligatory', true);
               $result->append($item);
           } else if (in_array($id, $itemKeys)) {
               $result->append($item);
           }
        }

        return $result;
    }
}
# Change User Description Committed
#1 16170 perforce_software Move Chronicle files to follow new path scheme for branching.
//guest/perforce_software/chronicle/application/site/controllers/BranchController.php
#1 8972 Matt Attaway Initial add of the Chronicle source code