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