diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php index 898fbc8e..32d1fa46 100644 --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -1,1275 +1,1276 @@ getRepositoryAPI(); $log = $this->getLogEngine(); // TODO: In Mercurial, you normally can not create a branch and a bookmark // with the same name. However, you can fetch a branch or bookmark from // a remote that has the same name as a local branch or bookmark of the // other type, and end up with a local branch and bookmark with the same // name. We should detect this and treat it as an error. // TODO: In Mercurial, you can create local bookmarks named // "default@default" and similar which do not surive a round trip through // a remote. Possibly, we should disallow interacting with these bookmarks. $markers = $api->newMarkerRefQuery() ->withIsActive(true) ->execute(); $bookmark = null; foreach ($markers as $marker) { if ($marker->isBookmark()) { $bookmark = $marker->getName(); break; } } if ($bookmark !== null) { $log->writeStatus( pht('SOURCE'), pht( 'Landing the active bookmark, "%s".', $bookmark)); return array($bookmark); } $branch = null; foreach ($markers as $marker) { if ($marker->isBranch()) { $branch = $marker->getName(); break; } } if ($branch !== null) { $log->writeStatus( pht('SOURCE'), pht( 'Landing the active branch, "%s".', $branch)); return array($branch); } $commit = $api->getCanonicalRevisionName('.'); $commit = $api->getDisplayHash($commit); $log->writeStatus( pht('SOURCE'), pht( 'Landing the active commit, "%s".', $api->getDisplayHash($commit))); return array($commit); } protected function resolveSymbols(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $marker_types = array( ArcanistMarkerRef::TYPE_BOOKMARK, ArcanistMarkerRef::TYPE_BRANCH, ); $unresolved = $symbols; foreach ($marker_types as $marker_type) { $markers = $api->newMarkerRefQuery() ->withMarkerTypes(array($marker_type)) ->execute(); $markers = mgroup($markers, 'getName'); foreach ($unresolved as $key => $symbol) { $raw_symbol = $symbol->getSymbol(); $named_markers = idx($markers, $raw_symbol); if (!$named_markers) { continue; } if (count($named_markers) > 1) { echo tsprintf( "\n%!\n%W\n\n", pht('AMBIGUOUS SYMBOL'), pht( 'Symbol "%s" is ambiguous: it matches multiple markers '. '(of type "%s"). Use an unambiguous identifier.', $raw_symbol, $marker_type)); foreach ($named_markers as $named_marker) { echo tsprintf('%s', $named_marker->newRefView()); } echo tsprintf("\n"); throw new PhutilArgumentUsageException( pht( 'Symbol "%s" is ambiguous.', $raw_symbol)); } $marker = head($named_markers); $symbol->setCommit($marker->getCommitHash()); unset($unresolved[$key]); } } foreach ($unresolved as $symbol) { $raw_symbol = $symbol->getSymbol(); // TODO: This doesn't have accurate error behavior if the user provides // a revset like "x::y". try { $commit = $api->getCanonicalRevisionName($raw_symbol); } catch (CommandException $ex) { $commit = null; } if ($commit === null) { throw new PhutilArgumentUsageException( pht( 'Symbol "%s" does not identify a bookmark, branch, or commit.', $raw_symbol)); } $symbol->setCommit($commit); } } protected function selectOntoRemote(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $remote = $this->newOntoRemote($symbols); $remote_ref = $api->newRemoteRefQuery() ->withNames(array($remote)) ->executeOne(); if (!$remote_ref) { throw new PhutilArgumentUsageException( pht( 'No remote "%s" exists in this repository.', $remote)); } // TODO: Allow selection of a bare URI. return $remote; } private function newOntoRemote(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); $remote = $this->getOntoRemoteArgument(); if ($remote !== null) { $log->writeStatus( pht('ONTO REMOTE'), pht( 'Remote "%s" was selected with the "--onto-remote" flag.', $remote)); return $remote; } $remote = $this->getOntoRemoteFromConfiguration(); if ($remote !== null) { $remote_key = $this->getOntoRemoteConfigurationKey(); $log->writeStatus( pht('ONTO REMOTE'), pht( 'Remote "%s" was selected by reading "%s" configuration.', $remote, $remote_key)); return $remote; } $api = $this->getRepositoryAPI(); $default_remote = 'default'; $log->writeStatus( pht('ONTO REMOTE'), pht( 'Landing onto remote "%s", the default remote under Mercurial.', $default_remote)); return $default_remote; } protected function selectOntoRefs(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $log = $this->getLogEngine(); $onto = $this->getOntoArguments(); if ($onto) { $log->writeStatus( pht('ONTO TARGET'), pht( 'Refs were selected with the "--onto" flag: %s.', implode(', ', $onto))); return $onto; } $onto = $this->getOntoFromConfiguration(); if ($onto) { $onto_key = $this->getOntoConfigurationKey(); $log->writeStatus( pht('ONTO TARGET'), pht( 'Refs were selected by reading "%s" configuration: %s.', $onto_key, implode(', ', $onto))); return $onto; } $api = $this->getRepositoryAPI(); $default_onto = 'default'; $log->writeStatus( pht('ONTO TARGET'), pht( 'Landing onto target "%s", the default target under Mercurial.', $default_onto)); return array($default_onto); } protected function confirmOntoRefs(array $onto_refs) { $api = $this->getRepositoryAPI(); foreach ($onto_refs as $onto_ref) { if (!strlen($onto_ref)) { throw new PhutilArgumentUsageException( pht( 'Selected "onto" ref "%s" is invalid: the empty string is not '. 'a valid ref.', $onto_ref)); } } $remote_ref = $this->getOntoRemoteRef(); $markers = $api->newMarkerRefQuery() ->withRemotes(array($remote_ref)) ->execute(); $onto_markers = array(); $new_markers = array(); foreach ($onto_refs as $onto_ref) { $matches = array(); foreach ($markers as $marker) { if ($marker->getName() === $onto_ref) { $matches[] = $marker; } } $match_count = count($matches); if ($match_count > 1) { throw new PhutilArgumentUsageException( pht( 'TODO: Ambiguous ref.')); } else if (!$match_count) { $new_bookmark = id(new ArcanistMarkerRef()) ->setMarkerType(ArcanistMarkerRef::TYPE_BOOKMARK) ->setName($onto_ref) ->attachRemoteRef($remote_ref); $onto_markers[] = $new_bookmark; $new_markers[] = $new_bookmark; } else { $onto_markers[] = head($matches); } } $branches = array(); foreach ($onto_markers as $onto_marker) { if ($onto_marker->isBranch()) { $branches[] = $onto_marker; } $branch_count = count($branches); if ($branch_count > 1) { echo tsprintf( "\n%!\n%W\n\n%W\n\n%W\n\n", pht('MULTIPLE "ONTO" BRANCHES'), pht( 'You have selected multiple branches to push changes onto. '. 'Pushing to multiple branches is not supported by "arc land" '. 'in Mercurial: Mercurial commits may only belong to one '. 'branch, so this operation can not be executed atomically.'), pht( 'You may land one branches and any number of bookmarks in a '. 'single operation.'), pht('These branches were selected:')); foreach ($branches as $branch) { echo tsprintf('%s', $branch->newRefView()); } echo tsprintf("\n"); throw new PhutilArgumentUsageException( pht( 'Landing onto multiple branches at once is not supported in '. 'Mercurial.')); } else if ($branch_count) { $this->ontoBranchMarker = head($branches); } } if ($new_markers) { echo tsprintf( "\n%!\n%W\n\n", pht('CREATE %s BOOKMARK(S)', phutil_count($new_markers)), pht( 'These %s symbol(s) do not exist in the remote. They will be '. 'created as new bookmarks:', phutil_count($new_markers))); foreach ($new_markers as $new_marker) { echo tsprintf('%s', $new_marker->newRefView()); } echo tsprintf("\n"); $is_hold = $this->getShouldHold(); if ($is_hold) { echo tsprintf( "%?\n", pht( 'You are using "--hold", so execution will stop before the '. '%s bookmark(s) are actually created. You will be given '. 'instructions to create the bookmarks.', phutil_count($new_markers))); } $query = pht( 'Create %s new remote bookmark(s)?', phutil_count($new_markers)); $this->getWorkflow() ->getPrompt('arc.land.create') ->setQuery($query) ->execute(); } $this->ontoMarkers = $onto_markers; } protected function selectIntoRemote() { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); if ($this->getIntoEmptyArgument()) { $this->setIntoEmpty(true); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into empty state, selected with the "--into-empty" '. 'flag.')); return; } if ($this->getIntoLocalArgument()) { $this->setIntoLocal(true); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into local state, selected with the "--into-local" '. 'flag.')); return; } $into = $this->getIntoRemoteArgument(); if ($into !== null) { $remote_ref = $api->newRemoteRefQuery() ->withNames(array($into)) ->executeOne(); if (!$remote_ref) { throw new PhutilArgumentUsageException( pht( 'No remote "%s" exists in this repository.', $into)); } // TODO: Allow a raw URI. $this->setIntoRemote($into); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into remote "%s", selected with the "--into" flag.', $into)); return; } $onto = $this->getOntoRemote(); $this->setIntoRemote($onto); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into remote "%s" by default, because this is the remote '. 'the change is landing onto.', $onto)); } protected function selectIntoRef() { $log = $this->getLogEngine(); if ($this->getIntoEmptyArgument()) { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into empty state, selected with the "--into-empty" '. 'flag.')); return; } $into = $this->getIntoArgument(); if ($into !== null) { $this->setIntoRef($into); $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s", selected with the "--into" flag.', $into)); return; } $ontos = $this->getOntoRefs(); $onto = head($ontos); $this->setIntoRef($onto); if (count($ontos) > 1) { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s" by default, because this is the first '. '"onto" target.', $onto)); } else { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s" by default, because this is the "onto" '. 'target.', $onto)); } } protected function selectIntoCommit() { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); if ($this->getIntoEmpty()) { // If we're running under "--into-empty", we don't have to do anything. $log->writeStatus( pht('INTO COMMIT'), pht('Preparing merge into the empty state.')); return null; } if ($this->getIntoLocal()) { // If we're running under "--into-local", just make sure that the // target identifies some actual commit. $local_ref = $this->getIntoRef(); // TODO: This error handling could probably be cleaner, it will just // raise an exception without any context. $into_commit = $api->getCanonicalRevisionName($local_ref); $log->writeStatus( pht('INTO COMMIT'), pht( 'Preparing merge into local target "%s", at commit "%s".', $local_ref, $api->getDisplayHash($into_commit))); return $into_commit; } $target = id(new ArcanistLandTarget()) ->setRemote($this->getIntoRemote()) ->setRef($this->getIntoRef()); $commit = $this->fetchTarget($target); if ($commit !== null) { $log->writeStatus( pht('INTO COMMIT'), pht( 'Preparing merge into "%s" from remote "%s", at commit "%s".', $target->getRef(), $target->getRemote(), $api->getDisplayHash($commit))); return $commit; } // If we have no valid target and the user passed "--into" explicitly, // treat this as an error. For example, "arc land --into Q --onto Q", // where "Q" does not exist, is an error. if ($this->getIntoArgument()) { throw new PhutilArgumentUsageException( pht( 'Ref "%s" does not exist in remote "%s".', $target->getRef(), $target->getRemote())); } // Otherwise, treat this as implying "--into-empty". For example, // "arc land --onto Q", where "Q" does not exist, is equivalent to // "arc land --into-empty --onto Q". $this->setIntoEmpty(true); $log->writeStatus( pht('INTO COMMIT'), pht( 'Preparing merge into the empty state to create target "%s" '. 'in remote "%s".', $target->getRef(), $target->getRemote())); return null; } private function fetchTarget(ArcanistLandTarget $target) { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); $target_name = $target->getRef(); $remote_ref = id(new ArcanistRemoteRef()) ->setRemoteName($target->getRemote()); $markers = $api->newMarkerRefQuery() ->withRemotes(array($remote_ref)) ->withNames(array($target_name)) ->execute(); $bookmarks = array(); $branches = array(); foreach ($markers as $marker) { if ($marker->isBookmark()) { $bookmarks[] = $marker; } else { $branches[] = $marker; } } if (!$bookmarks && !$branches) { throw new PhutilArgumentUsageException( pht( 'Remote "%s" has no bookmark or branch named "%s".', $target->getRemote(), $target->getRef())); } if ($bookmarks && $branches) { echo tsprintf( "\n%!\n%W\n\n", pht('AMBIGUOUS MARKER'), pht( 'In remote "%s", the name "%s" identifies one or more branch '. 'heads and one or more bookmarks. Close, rename, or delete all '. 'but one of these markers, or pull the state you want to merge '. 'into and use "--into-local --into " to disambiguate the '. 'desired merge target.', $target->getRemote(), $target->getRef())); throw new PhutilArgumentUsageException( pht('Merge target is ambiguous.')); } if ($bookmarks) { if (count($bookmarks) > 1) { throw new Exception( pht( 'Remote "%s" has multiple bookmarks with name "%s". This '. 'is unexpected.', $target->getRemote(), $target->getRef())); } $bookmark = head($bookmarks); $target_marker = $bookmark; } if ($branches) { if (count($branches) > 1) { echo tsprintf( "\n%!\n%W\n\n", pht('MULTIPLE BRANCH HEADS'), pht( 'Remote "%s" has multiple branch heads named "%s". Close all '. 'but one, or pull the head you want and use "--into-local '. '--into " to specify an explicit merge target.', $target->getRemote(), $target->getRef())); throw new PhutilArgumentUsageException( pht( 'Remote branch has multiple heads.')); } $branch = head($branches); $target_marker = $branch; } if ($target_marker->isBranch()) { $err = $this->newPassthru( 'pull --branch %s -- %s', $target->getRef(), $target->getRemote()); } else { // NOTE: This may have side effects: // // - It can create a "bookmark@remote" bookmark if there is a local // bookmark with the same name that is not an ancestor. // - It can create an arbitrary number of other bookmarks. // // Since these seem to generally be intentional behaviors in Mercurial, // and should theoretically be familiar to Mercurial users, just accept // them as the cost of doing business. $err = $this->newPassthru( 'pull --bookmark %s -- %s', $target->getRef(), $target->getRemote()); } // NOTE: It's possible that between the time we ran "ls-markers" and the // time we ran "pull" that the remote changed. // It may even have been rewound or rewritten, in which case we did not // actually fetch the ref we are about to return as a target. For now, // assume this didn't happen: it's so unlikely that it's probably not // worth spending 100ms to check. // TODO: If the Mercurial command server is revived, this check becomes // more reasonable if it's cheap. return $target_marker->getCommitHash(); } protected function selectCommits($into_commit, array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $commit_map = array(); foreach ($symbols as $symbol) { $symbol_commit = $symbol->getCommit(); $template = '{node}-{parents % \'{node} \'}-{desc|firstline}\\n'; // The returned array of commits is expected to be ordered by max to min // where the max commit has no descendants in the range and the min // commit has no ancestors in the range. Use 'reverse()' in the template // so the output is ordered with the max commit as the first line. The // max/min terms are used in a topological sense as chronological terms // for commits may be misleading or incorrect in some situations. if ($into_commit === null) { list($commits) = $api->execxLocal( 'log --rev %s --template %s --', hgsprintf('reverse(ancestors(%s))', $into_commit), $template); } else { list($commits) = $api->execxLocal( 'log --rev %s --template %s --', hgsprintf( 'reverse(ancestors(%s) - ancestors(%s))', $symbol_commit, $into_commit), $template); } $commits = phutil_split_lines($commits, false); $is_first = true; foreach ($commits as $line) { if (!strlen($line)) { continue; } $parts = explode('-', $line, 3); if (count($parts) < 3) { throw new Exception( pht( 'Unexpected output from "hg log ...": %s', $line)); } $hash = $parts[0]; if (!isset($commit_map[$hash])) { $parents = $parts[1]; $parents = trim($parents); if (strlen($parents)) { $parents = explode(' ', $parents); } else { $parents = array(); } $summary = $parts[2]; $commit_map[$hash] = id(new ArcanistLandCommit()) ->setHash($hash) ->setParents($parents) ->setSummary($summary); } $commit = $commit_map[$hash]; if ($is_first) { $commit->addDirectSymbol($symbol); $is_first = false; } $commit->addIndirectSymbol($symbol); } } return $this->confirmCommits($into_commit, $symbols, $commit_map); } protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { $api = $this->getRepositoryAPI(); if ($this->getStrategy() !== 'squash') { throw new Exception(pht('TODO: Support merge strategies')); } // See PHI1808. When we "hg rebase ..." below, Mercurial will move // bookmarks which point at the old commit range to point at the rebased // commit. This is somewhat surprising and we don't want this to happen: // save the old bookmark state so we can put the bookmarks back before // we continue. $bookmark_refs = $api->newMarkerRefQuery() ->withMarkerTypes( array( ArcanistMarkerRef::TYPE_BOOKMARK, )) ->execute(); // TODO: Add a Mercurial version check requiring 2.1.1 or newer. $api->execxLocal( 'update --rev %s', hgsprintf('%s', $into_commit)); $commits = $set->getCommits(); // confirmCommits() reverses the order of the commits as they're ordered // above in selectCommits(). Now the head of the list is the min commit and // the last is the max commit, where within the range the max commit has no // descendants and the min commit has no ancestors. The min/max terms are // used in a topological sense as chronological terms for commits can be // misleading or incorrect in certain situations. $min_commit = head($commits)->getHash(); $max_commit = last($commits)->getHash(); $revision_ref = $set->getRevisionRef(); $commit_message = $revision_ref->getCommitMessage(); // If we're landing "--onto" a branch, set that as the branch marker // before creating the new commit. // TODO: We could skip this if we know that the "$into_commit" already // has the right branch, which it will if we created it. $branch_marker = $this->ontoBranchMarker; if ($branch_marker) { $api->execxLocal('branch -- %s', $branch_marker->getName()); } try { $argv = array(); $argv[] = '--dest'; $argv[] = hgsprintf('%s', $into_commit); $argv[] = '--rev'; $argv[] = hgsprintf('%s..%s', $min_commit, $max_commit); $argv[] = '--logfile'; $argv[] = '-'; $argv[] = '--keep'; $argv[] = '--collapse'; $future = $api->execFutureLocal('rebase %Ls', $argv); $future->write($commit_message); $future->resolvex(); } catch (CommandException $ex) { // Aborting the rebase should restore the same state prior to running the // rebase command. $api->execManualLocal('rebase --abort'); throw $ex; } // Find all the bookmarks which pointed at commits we just rebased, and // put them back the way they were before rebasing moved them. We aren't // deleting the old commits yet and don't want to move the bookmarks. $obsolete_map = array(); foreach ($set->getCommits() as $commit) { $obsolete_map[$commit->getHash()] = true; } foreach ($bookmark_refs as $bookmark_ref) { $bookmark_hash = $bookmark_ref->getCommitHash(); if (!isset($obsolete_map[$bookmark_hash])) { continue; } $api->execxLocal( 'bookmark --force --rev %s -- %s', $bookmark_hash, $bookmark_ref->getName()); } list($stdout) = $api->execxLocal('log --rev tip --template %s', '{node}'); $new_cursor = trim($stdout); // If any of the commits that were rebased was the active commit before the // workflow started, track the new commit so it can be used as the working // directory after the land has succeeded. if (isset($obsolete_map[$this->getLocalState()->getLocalCommit()])) { $this->rebasedActiveCommit = $new_cursor; } return $new_cursor; } protected function pushChange($into_commit) { $api = $this->getRepositoryAPI(); list($head, $body, $tail_pass, $tail_fail) = $this->newPushCommands( $into_commit); foreach ($head as $command) { $api->execxLocal('%Ls', $command); } try { foreach ($body as $command) { $err = $this->newPassthru('%Ls', $command); if ($err) { throw new ArcanistLandPushFailureException( pht( 'Push failed! Fix the error and run "arc land" again.')); } } foreach ($tail_pass as $command) { $api->execxLocal('%Ls', $command); } } catch (Exception $ex) { foreach ($tail_fail as $command) { $api->execxLocal('%Ls', $command); } throw $ex; } catch (Throwable $ex) { foreach ($tail_fail as $command) { $api->execxLocal('%Ls', $command); } throw $ex; } } private function newPushCommands($into_commit) { $api = $this->getRepositoryAPI(); $head_commands = array(); $body_commands = array(); $tail_pass_commands = array(); $tail_fail_commands = array(); $bookmarks = array(); foreach ($this->ontoMarkers as $onto_marker) { if (!$onto_marker->isBookmark()) { continue; } $bookmarks[] = $onto_marker; } // If we're pushing to bookmarks, move all the bookmarks we want to push // to the merge commit. (There doesn't seem to be any way to specify // "push commit X as bookmark Y" in Mercurial.) $restore_bookmarks = array(); if ($bookmarks) { $markers = $api->newMarkerRefQuery() ->withNames(mpull($bookmarks, 'getName')) ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) ->execute(); $markers = mpull($markers, 'getCommitHash', 'getName'); foreach ($bookmarks as $bookmark) { $bookmark_name = $bookmark->getName(); $old_position = idx($markers, $bookmark_name); $new_position = $into_commit; if ($old_position === $new_position) { continue; } $head_commands[] = array( 'bookmark', '--force', '--rev', hgsprintf('%s', $api->getDisplayHash($new_position)), '--', $bookmark_name, ); $api->execxLocal( 'bookmark --force --rev %s -- %s', hgsprintf('%s', $new_position), $bookmark_name); if ($old_position !== null) { $restore_bookmarks[$bookmark_name] = $old_position; } } } // Now, prepare the actual push. $argv = array(); $argv[] = 'push'; if ($bookmarks) { // If we're pushing at least one bookmark, we can just specify the list // of bookmarks as things we want to push. foreach ($bookmarks as $bookmark) { $argv[] = '--bookmark'; $argv[] = $bookmark->getName(); } } else { // Otherwise, specify the commit itself. $argv[] = '--rev'; $argv[] = hgsprintf('%s', $into_commit); } $argv[] = '--'; $argv[] = $this->getOntoRemote(); $body_commands[] = $argv; // Finally, restore the bookmarks. if ($restore_bookmarks) { // Instead of restoring the previous state, assume landing onto bookmarks // also updates those bookmarks in the remote. After pushing, pull the // latest state of these bookmarks. Mercurial allows pulling multiple // bookmarks in a single pull command which will be faster than pulling // them from a remote individually. $tail = array( 'pull', ); foreach ($restore_bookmarks as $bookmark_name => $old_position) { $tail[] = '--bookmark'; $tail[] = $bookmark_name; // In the failure case restore the state of the bookmark. Mercurial // does not provide a way to move multiple bookmarks in a single // command however these commands do not involve the remote. $tail_fail_commands[] = array( 'bookmark', '--force', '--rev', hgsprintf('%s', $api->getDisplayHash($old_position)), ); } if ($tail) { $tail_pass_commands[] = $tail; } } return array( $head_commands, $body_commands, $tail_pass_commands, $tail_fail_commands, ); } protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); // This has no effect when we're executing a merge strategy. if (!$this->isSquashStrategy()) { return; } $old_commit = last($set->getCommits())->getHash(); $new_commit = $into_commit; list($output) = $api->execxLocal( 'log --rev %s --template %s', hgsprintf('children(%s)', $old_commit), '{node}\n'); $child_hashes = phutil_split_lines($output, false); foreach ($child_hashes as $child_hash) { if (!strlen($child_hash)) { continue; } // TODO: If the only heads which are descendants of this child will // be deleted, we can skip this rebase? try { $api->execxLocal( 'rebase --source %s --dest %s --keep --keepbranches', $child_hash, $new_commit); } catch (CommandException $ex) { // Aborting the rebase should restore the same state prior to running // the rebase command. $api->execManualLocal('rebase --abort'); throw $ex; } } } protected function pruneBranches(array $sets) { assert_instances_of($sets, 'ArcanistLandCommitSet'); $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); // This has no effect when we're executing a merge strategy. if (!$this->isSquashStrategy()) { return; } $revs = array(); $obsolete_map = array(); $using_evolve = $api->getMercurialFeature('evolve'); // We've rebased all descendants already, so we can safely delete all // of these commits. $sets = array_reverse($sets); foreach ($sets as $set) { $commits = $set->getCommits(); // In the commit set the min commit should be the commit with no // ancestors and the max commit should be the commit with no descendants. // The min/max terms are used in a toplogical sense as chronological // terms for commits may be misleading or incorrect in some situations. $min_commit = head($commits)->getHash(); $max_commit = last($commits)->getHash(); if ($using_evolve) { // If non-head series of commits are rebased while the evolve extension // is in use, the rebase leaves behind the entire series of descendants // in which case the entire chain needs removed, not just a section. // Otherwise this results in the prune leaving behind orphaned commits. $revs[] = hgsprintf('%s::', $min_commit); } else { $revs[] = hgsprintf('%s::%s', $min_commit, $max_commit); } foreach ($commits as $commit) { $obsolete_map[$commit->getHash()] = true; } } $rev_set = '('.implode(') or (', $revs).')'; // See PHI45. If we have "hg evolve", get rid of old commits using // "hg prune" instead of "hg strip". // If we "hg strip" a commit which has an obsolete predecessor, it // removes the obsolescence marker and revives the predecessor. This is // not desirable: we want to destroy all predecessors of these commits. // See PHI1808. Both "hg strip" and "hg prune" move bookmarks backwards in // history rather than destroying them. Instead, we want to destroy any // bookmarks which point at these now-obsoleted commits. $bookmark_refs = $api->newMarkerRefQuery() ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) ->execute(); foreach ($bookmark_refs as $bookmark_ref) { $bookmark_hash = $bookmark_ref->getCommitHash(); $bookmark_name = $bookmark_ref->getName(); if (!isset($obsolete_map[$bookmark_hash])) { continue; } $log->writeStatus( pht('CLEANUP'), pht('Deleting bookmark "%s".', $bookmark_name)); $api->execxLocal( 'bookmark --delete -- %s', $bookmark_name); } if ($using_evolve) { $api->execxLocal( 'prune --rev %s', $rev_set); } else { - $api->execxLocal( - '--config extensions.strip= strip --rev %s', + $api->execxLocalWithExtension( + 'strip', + 'strip --rev %s', $rev_set); } } protected function reconcileLocalState( $into_commit, ArcanistRepositoryLocalState $state) { $api = $this->getRepositoryAPI(); // If the starting working state was not part of land process just update // to that original working state. if ($this->rebasedActiveCommit === null) { $update_marker = $this->getLocalState()->getLocalCommit(); if ($this->getLocalState()->getLocalBookmark() !== null) { $update_marker = $this->getLocalState()->getLocalBookmark(); } $api->execxLocal( 'update -- %s', $update_marker); $state->discardLocalState(); return; } // If the working state was landed into multiple destinations then the // resulting working state is ambiguous. if (count($this->ontoMarkers) != 1) { $state->discardLocalState(); return; } // Get the current state of bookmarks $bookmark_refs = $api->newMarkerRefQuery() ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) ->execute(); $update_marker = $this->rebasedActiveCommit; // Find any bookmarks which exist on the commit which is the result of the // starting working directory's rebase. If any of those bookmarks are also // the destination marker then we use that bookmark as the update in order // for it to become active. $onto_marker = $this->ontoMarkers[0]->getName(); foreach ($bookmark_refs as $bookmark_ref) { if ($bookmark_ref->getCommitHash() == $this->rebasedActiveCommit && $bookmark_ref->getName() == $onto_marker) { $update_marker = $onto_marker; break; } } $api->execxLocal( 'update -- %s', $update_marker); $state->discardLocalState(); } protected function didHoldChanges($into_commit) { $log = $this->getLogEngine(); $local_state = $this->getLocalState(); $message = pht( 'Holding changes locally, they have not been pushed.'); list($head, $body, $tail_pass, $tail_fail) = $this->newPushCommands( $into_commit); $commands = array_merge($head, $body, $tail_pass); echo tsprintf( "\n%!\n%s\n\n", pht('HOLD CHANGES'), $message); echo tsprintf( "%s\n\n", pht('To push changes manually, run these %s command(s):', phutil_count($commands))); foreach ($commands as $command) { echo tsprintf('%>', csprintf('hg %Ls', $command)); } echo tsprintf("\n"); $restore_commands = $local_state->getRestoreCommandsForDisplay(); if ($restore_commands) { echo tsprintf( "%s\n\n", pht( 'To go back to how things were before you ran "arc land", run '. 'these %s command(s):', phutil_count($restore_commands))); foreach ($restore_commands as $restore_command) { echo tsprintf('%>', $restore_command); } echo tsprintf("\n"); } echo tsprintf( "%s\n", pht( 'Local branches and bookmarks have not been changed, and are still '. 'in the same state as before.')); } } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index a8e9cea8..9eecf0e2 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,1138 +1,1243 @@ getMercurialEnvironmentVariables(); + $argv[0] = self::ROOT_HG_COMMAND.$argv[0]; - // Mercurial deceptively indicates that the default encoding is UTF-8 - // however the actual default appears to be "something else", at least on - // Windows systems. Force all mercurial commands to use UTF-8 encoding. - $argv[0] = 'hg --encoding utf-8 '.$argv[0]; + return $this->newConfiguredFuture(newv('ExecFuture', $argv)); + } - $future = newv('ExecFuture', $argv) - ->setEnv($env) - ->setCWD($this->getPath()); + public function newPassthru($pattern /* , ... */) { + $args = func_get_args(); + $args[0] = self::ROOT_HG_COMMAND.$args[0]; - return $future; + return $this->newConfiguredFuture(newv('PhutilExecPassthru', $args)); } - public function newPassthru($pattern /* , ... */) { + private function newConfiguredFuture(PhutilExecutableFuture $future) { $args = func_get_args(); $env = $this->getMercurialEnvironmentVariables(); - $args[0] = 'hg '.$args[0]; - - return newv('PhutilExecPassthru', $args) + return $future ->setEnv($env) ->setCWD($this->getPath()); } public function getSourceControlSystemName() { return 'hg'; } public function getMetadataPath() { return $this->getPath('.hg'); } public function getSourceControlBaseRevision() { return $this->getCanonicalRevisionName($this->getBaseCommit()); } public function getCanonicalRevisionName($string) { list($stdout) = $this->execxLocal( 'log -l 1 --template %s -r %s --', '{node}', $string); return $stdout; } public function getSourceControlPath() { return '/'; } public function getBranchName() { if (!$this->branch) { list($stdout) = $this->execxLocal('branch'); $this->branch = trim($stdout); } return $this->branch; } protected function didReloadCommitRange() { $this->localCommitInfo = null; } protected function buildBaseCommit($symbolic_commit) { if ($symbolic_commit !== null) { try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%s,.)', $symbolic_commit)); } catch (Exception $ex) { // Try it as a revset instead of a commit id try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%R,.)', $symbolic_commit)); } catch (Exception $ex) { throw new ArcanistUsageException( pht( "Commit '%s' is not a valid Mercurial commit identifier.", $symbolic_commit)); } } $this->setBaseCommitExplanation( pht( 'it is the greatest common ancestor of the working directory '. 'and the commit you specified explicitly.')); return $commit; } if ($this->getBaseCommitArgumentRules() || $this->getConfigurationManager()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( pht( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly.")); } return $base; } list($err, $stdout) = $this->execManualLocal( 'log --branch %s -r %s --style default', $this->getBranchName(), 'draft()'); if (!$err) { $logs = ArcanistMercurialParser::parseMercurialLog($stdout); } else { // Mercurial (in some versions?) raises an error when there's nothing // outgoing. $logs = array(); } if (!$logs) { $this->setBaseCommitExplanation( pht( 'you have no outgoing commits, so arc assumes you intend to submit '. 'uncommitted changes in the working copy.')); return $this->getWorkingCopyRevision(); } $outgoing_revs = ipull($logs, 'rev'); // This is essentially an implementation of a theoretical `hg merge-base` // command. $against = $this->getWorkingCopyRevision(); while (true) { // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is // new as of July 2011, so do this in a compatible way. Also, "hg log" // and "hg outgoing" don't necessarily show parents (even if given an // explicit template consisting of just the parents token) so we need // to separately execute "hg parents". list($stdout) = $this->execxLocal( 'parents --style default --rev %s', $against); $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout); list($p1, $p2) = array_merge($parents_logs, array(null, null)); if ($p1 && !in_array($p1['rev'], $outgoing_revs)) { $against = $p1['rev']; break; } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) { $against = $p2['rev']; break; } else if ($p1) { $against = $p1['rev']; } else { // This is the case where you have a new repository and the entire // thing is outgoing; Mercurial literally accepts "--rev null" as // meaning "diff against the empty state". $against = 'null'; break; } } if ($against == 'null') { $this->setBaseCommitExplanation( pht('this is a new repository (all changes are outgoing).')); } else { $this->setBaseCommitExplanation( pht( 'it is the first commit reachable from the working copy state '. 'which is not outgoing.')); } return $against; } public function getLocalCommitInformation() { if ($this->localCommitInfo === null) { $base_commit = $this->getBaseCommit(); list($info) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{rev}\1{author}\1". "{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $logs = array_filter(explode("\2", $info)); $last_node = null; $futures = array(); $commits = array(); foreach ($logs as $log) { list($node, $rev, $full_author, $date, $branch, $tag, $parents, $desc) = explode("\1", $log, 9); list($author, $author_email) = $this->parseFullAuthor($full_author); // NOTE: If a commit has only one parent, {parents} returns empty. // If it has two parents, {parents} returns revs and short hashes, not // full hashes. Try to avoid making calls to "hg parents" because it's // relatively expensive. $commit_parents = null; if (!$parents) { if ($last_node) { $commit_parents = array($last_node); } } if (!$commit_parents) { // We didn't get a cheap hit on previous commit, so do the full-cost // "hg parents" call. We can run these in parallel, at least. $futures[$node] = $this->execFutureLocal( 'parents --template %s --rev %s', '{node}\n', $node); } $commits[$node] = array( 'author' => $author, 'time' => strtotime($date), 'branch' => $branch, 'tag' => $tag, 'commit' => $node, 'rev' => $node, // TODO: Remove eventually. 'local' => $rev, 'parents' => $commit_parents, 'summary' => head(explode("\n", $desc)), 'message' => $desc, 'authorEmail' => $author_email, ); $last_node = $node; } $futures = id(new FutureIterator($futures)) ->limit(4); foreach ($futures as $node => $future) { list($parents) = $future->resolvex(); $parents = array_filter(explode("\n", $parents)); $commits[$node]['parents'] = $parents; } // Put commits in newest-first order, to be consistent with Git and the // expected order of "hg log" and "git log" under normal circumstances. // The order of ancestors() is oldest-first. $commits = array_reverse($commits); $this->localCommitInfo = $commits; } return $this->localCommitInfo; } public function getAllFiles() { // TODO: Handle paths with newlines. $future = $this->buildLocalFuture(array('manifest')); return new LinesOfALargeExecFuture($future); } public function getChangedFiles($since_commit) { list($stdout) = $this->execxLocal( 'status --rev %s', $since_commit); return ArcanistMercurialParser::parseMercurialStatus($stdout); } public function getBlame($path) { list($stdout) = $this->execxLocal( 'annotate -u -v -c --rev %s -- %s', $this->getBaseCommit(), $path); $lines = phutil_split_lines($stdout, $retain_line_endings = true); $blame = array(); foreach ($lines as $line) { if (!strlen($line)) { continue; } $matches = null; $ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches); if (!$ok) { throw new Exception( pht( 'Unable to parse Mercurial blame line: %s', $line)); } $revision = $matches[2]; $author = trim($matches[1]); $blame[] = array($author, $revision); } return $blame; } protected function buildUncommittedStatus() { list($stdout) = $this->execxLocal('status'); $results = new PhutilArrayWithDefaultValue(); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $mask) { if (!($mask & parent::FLAG_UNTRACKED)) { // Mark tracked files as uncommitted. $mask |= self::FLAG_UNCOMMITTED; } $results[$path] |= $mask; } return $results->toArray(); } protected function buildCommitRangeStatus() { list($stdout) = $this->execxLocal( 'status --rev %s --rev tip', $this->getBaseCommit()); $results = new PhutilArrayWithDefaultValue(); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $mask) { $results[$path] |= $mask; } return $results->toArray(); } protected function didReloadWorkingCopy() { // Diffs are against ".", so we need to drop the cache if we change the // working copy. $this->rawDiffCache = array(); $this->branch = null; } private function getDiffOptions() { $options = array( '--git', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getRawDiffText($path) { $options = $this->getDiffOptions(); $range = $this->getBaseCommit(); $raw_diff_cache_key = $options.' '.$range.' '.$path; if (idx($this->rawDiffCache, $raw_diff_cache_key)) { return idx($this->rawDiffCache, $raw_diff_cache_key); } list($stdout) = $this->execxLocal( 'diff %C --rev %s -- %s', $options, $range, $path); $this->rawDiffCache[$raw_diff_cache_key] = $stdout; return $stdout; } public function getFullMercurialDiff() { return $this->getRawDiffText(''); } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getBaseCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision( $path, $this->getWorkingCopyRevision()); } public function getBulkOriginalFileData($paths) { return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit()); } public function getBulkCurrentFileData($paths) { return $this->getBulkFileDataAtRevision( $paths, $this->getWorkingCopyRevision()); } private function getBulkFileDataAtRevision($paths, $revision) { // Calling 'hg cat' on each file individually is slow (1 second per file // on a large repo) because mercurial has to decompress and parse the // entire manifest every time. Do it in one large batch instead. // hg cat will write the file data to files in a temp directory $tmpdir = Filesystem::createTemporaryDirectory(); // Mercurial doesn't create the directories for us :( foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; Filesystem::createDirectory(dirname($tmppath), 0755, true); } // NOTE: The "%s%%p" construction passes a literal "%p" to Mercurial, // which is a formatting directive for a repo-relative filepath. The // particulars of the construction avoid Windows escaping issues. See // PHI904. list($err, $stdout) = $this->execManualLocal( 'cat --rev %s --output %s%%p -- %Ls', $revision, $tmpdir.DIRECTORY_SEPARATOR, $paths); $filedata = array(); foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; if (Filesystem::pathExists($tmppath)) { $filedata[$path] = Filesystem::readFile($tmppath); } } Filesystem::remove($tmpdir); return $filedata; } private function getFileDataAtRevision($path, $revision) { list($err, $stdout) = $this->execManualLocal( 'cat --rev %s -- %s', $revision, $path); if ($err) { // Assume this is "no file at revision", i.e. a deleted or added file. return null; } else { return $stdout; } } public function getWorkingCopyRevision() { return '.'; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { list($err, $stdout) = $this->execManualLocal('help commit'); if ($err) { return false; } else { return (strpos($stdout, 'amend') !== false); } } public function supportsCommitRanges() { return true; } public function supportsLocalCommits() { return true; } public function getBaseCommitRef() { $base_commit = $this->getBaseCommit(); if ($base_commit === 'null') { return null; } $base_message = $this->getCommitMessage($base_commit); return $this->newCommitRef() ->setCommitHash($base_commit) ->attachMessage($base_message); } public function hasLocalCommit($commit) { try { $this->getCanonicalRevisionName($commit); return true; } catch (Exception $ex) { return false; } } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log --template={desc} --rev %s', $commit); return $message; } public function getAllLocalChanges() { $diff = $this->getFullMercurialDiff(); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function getFinalizedRevisionMessage() { return pht( "You may now push this commit upstream, as appropriate (e.g. with ". "'%s' or by printing and faxing it).", 'hg push'); } public function getCommitMessageLog() { $base_commit = $this->getBaseCommit(); list($stdout) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $map = array(); $logs = explode("\2", trim($stdout)); foreach (array_filter($logs) as $log) { list($node, $desc) = explode("\1", $log); $map[$node] = $desc; } return array_reverse($map); } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getCommitMessageLog(); $parser = new ArcanistDiffParser(); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $node_id => $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $node_id; } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = pht( "Commit message for '%s' has explicit 'Differential Revision'.", $hash); } return $results; } // Try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('hgcm', $commit['commit']); } if ($hashes) { // NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working // copy with dirty changes, there may be no local commits. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $hash) { $results[$key]['why'] = pht( 'A mercurial commit hash in the commit range is already attached '. 'to the Differential revision.'); } return $results; } return array(); } public function updateWorkingCopy() { $this->execxLocal('up'); $this->reloadWorkingCopy(); } private function getMercurialConfig($key, $default = null) { list($stdout) = $this->execxLocal('showconfig %s', $key); if ($stdout == '') { return $default; } return rtrim($stdout); } public function getAuthor() { $full_author = $this->getMercurialConfig('ui.username'); list($author, $author_email) = $this->parseFullAuthor($full_author); return $author; } /** * Parse the Mercurial author field. * * Not everyone enters their email address as a part of the username * field. Try to make it work when it's obvious. * * @param string $full_author * @return array */ protected function parseFullAuthor($full_author) { if (strpos($full_author, '@') === false) { $author = $full_author; $author_email = null; } else { $email = new PhutilEmailAddress($full_author); $author = $email->getDisplayName(); $author_email = $email->getAddress(); } return array($author, $author_email); } public function addToCommit(array $paths) { $this->execxLocal( 'addremove -- %Ls', $paths); $this->reloadWorkingCopy(); } public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal('commit --logfile %s', $tmp_file); $this->reloadWorkingCopy(); } public function amendCommit($message = null) { $path_statuses = $this->buildUncommittedStatus(); if ($message === null) { if (empty($path_statuses)) { // If there are no changes to the working directory and the message is // not being changed then there's nothing to amend. Notably Mercurial // will return an error code if trying to amend a commit with no change // to the commit metadata or file changes. return; } $message = $this->getCommitMessage('.'); } $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); if ($this->getMercurialFeature('evolve')) { $this->execxLocal('amend --logfile %s --', $tmp_file); try { $this->execxLocal('evolve --all --'); } catch (CommandException $ex) { $this->execxLocal('evolve --abort --'); throw $ex; } $this->reloadWorkingCopy(); return; } // Get the child nodes of the current changeset. list($children) = $this->execxLocal( 'log --template %s --rev %s --', '{node} ', 'children(.)'); $child_nodes = array_filter(explode(' ', $children)); // For a head commit we can simply use `commit --amend` for both new commit // message and amending changes from the working directory. if (empty($child_nodes)) { $this->execxLocal('commit --amend --logfile %s --', $tmp_file); } else { $this->amendNonHeadCommit($child_nodes, $tmp_file); } $this->reloadWorkingCopy(); } /** * Amends a non-head commit with a new message and file changes. This * strategy is for Mercurial repositories without the evolve extension. * * 1. Run 'arc-amend' which uses Mercurial internals to amend the current * commit with updated message/file-changes. It results in a new commit * from the right parent * 2. For each branch from the original commit, rebase onto the new commit, * removing the original branch. Note that there is potential for this to * cause a conflict but this is something the user has to address. * 3. Strip the original commit. * * @param array The list of child changesets off the original commit. * @param file The file containing the new commit message. */ private function amendNonHeadCommit($child_nodes, $tmp_file) { list($current) = $this->execxLocal( 'log --template %s --rev . --', '{node}'); - $argv = array(); - foreach ($this->getMercurialExtensionArguments() as $arg) { - $argv[] = $arg; - } - $argv[] = 'arc-amend'; - $argv[] = '--logfile'; - $argv[] = $tmp_file; - $this->execxLocal('%Ls', $argv); + $this->execxLocalWithExtension( + 'arc-hg', + 'arc-amend --logfile %s', + $tmp_file); list($new_commit) = $this->execxLocal( 'log --rev tip --template %s --', '{node}'); try { $rebase_args = array( '--dest', $new_commit, ); foreach ($child_nodes as $child) { $rebase_args[] = '--source'; $rebase_args[] = $child; } - $this->execxLocal('rebase %Ls --', $rebase_args); + $this->execxLocalWithExtension( + 'rebase', + 'rebase %Ls --', + $rebase_args); } catch (CommandException $ex) { - $this->execxLocal('rebase --abort --'); + $this->execxLocalWithExtension( + 'rebase', + 'rebase --abort --'); throw $ex; } - $this->execxLocal('--config extensions.strip= strip --rev %s --', + $this->execxLocalWithExtension( + 'strip', + 'strip --rev %s --', $current); } public function getCommitSummary($commit) { if ($commit == 'null') { return pht('(The Empty Void)'); } list($summary) = $this->execxLocal( 'log --template {desc} --limit 1 --rev %s', $commit); $summary = head(explode("\n", $summary)); return trim($summary); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); // NOTE: This function MUST return node hashes or symbolic commits (like // branch names or the word "tip"), not revsets. This includes ".^" and // similar, which a revset, not a symbolic commit identifier. If you return // a revset it will be escaped later and looked up literally. switch ($type) { case 'hg': $matches = null; if (preg_match('/^gca\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'log --template={node} --rev %s', sprintf('ancestor(., %s)', $matches[1])); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the greatest common ancestor of '%s' and %s, as ". "specified by '%s' in your %s 'base' configuration.", $matches[1], '.', $rule, $source)); return trim($merge_base); } } else { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', hgsprintf('%s', $name)); if ($err) { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', $name); } if (!$err) { $this->setBaseCommitExplanation( pht( "it is specified by '%s' in your %s 'base' configuration.", $rule, $source)); return trim($commit); } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return 'null'; case 'outgoing': list($err, $outgoing_base) = $this->execManualLocal( 'log --template={node} --rev %s', 'limit(reverse(ancestors(.) - outgoing()), 1)'); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of the working copy that is not ". "outgoing, and it matched the rule %s in your %s ". "'base' configuration.", $rule, $source)); return trim($outgoing_base); } case 'amended': $text = $this->getCommitMessage('.'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "'%s' has been amended with 'Differential Revision:', ". "as specified by '%s' in your %s 'base' configuration.", '.', $rule, $source)); // NOTE: This should be safe because Mercurial doesn't support // amend until 2.2. return $this->getCanonicalRevisionName('.^'); } break; case 'bookmark': $revset = 'limit('. ' sort('. ' (ancestors(.) and bookmark() - .) or'. ' (ancestors(.) - outgoing()), '. ' -rev),'. '1)'; list($err, $bookmark_base) = $this->execManualLocal( 'log --template={node} --rev %s', $revset); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that either has a bookmark, ". "or is already in the remote and it matched the rule %s in ". "your %s 'base' configuration", '.', $rule, $source)); return trim($bookmark_base); } break; case 'this': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return $this->getCanonicalRevisionName('.^'); default: if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) { list($results) = $this->execxLocal( 'log --template %s --rev %s', "{node}\1{desc}\2", sprintf('ancestor(.,%s)::.^', $matches[1])); $results = array_reverse(explode("\2", trim($results))); foreach ($results as $result) { if (empty($result)) { continue; } list($node, $desc) = explode("\1", $result, 2); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $desc); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that has a diff and is ". "the gca or a descendant of the gca with '%s', ". "specified by '%s' in your %s 'base' configuration.", '.', $matches[1], $rule, $source)); return $node; } } } break; } break; default: return null; } return null; } public function getSubversionInfo() { $info = array(); $base_path = null; $revision = null; list($err, $raw_info) = $this->execManualLocal('svn info'); if (!$err) { foreach (explode("\n", trim($raw_info)) as $line) { list($key, $value) = explode(': ', $line, 2); switch ($key) { case 'URL': $info['base_path'] = $value; $base_path = $value; break; case 'Repository UUID': $info['uuid'] = $value; break; case 'Revision': $revision = $value; break; default: break; } } if ($base_path && $revision) { $info['base_revision'] = $base_path.'@'.$revision; } } return $info; } public function getActiveBookmark() { $bookmark = $this->newMarkerRefQuery() ->withMarkerTypes( array( ArcanistMarkerRef::TYPE_BOOKMARK, )) ->withIsActive(true) ->executeOne(); if (!$bookmark) { return null; } return $bookmark->getName(); } public function getRemoteURI() { // TODO: Remove this method in favor of RemoteRefQuery. list($stdout) = $this->execxLocal('paths default'); $stdout = trim($stdout); if (strlen($stdout)) { return $stdout; } return null; } private function getMercurialEnvironmentVariables() { $env = array(); // Mercurial has a "defaults" feature which basically breaks automation by // allowing the user to add random flags to any command. This feature is // "deprecated" and "a bad idea" that you should "forget ... existed" // according to project lead Matt Mackall: // // http://markmail.org/message/hl3d6eprubmkkqh5 // // There is an HGPLAIN environmental variable which enables "plain mode" // and hopefully disables this stuff. $env['HGPLAIN'] = 1; return $env; } protected function newLandEngine() { return new ArcanistMercurialLandEngine(); } protected function newWorkEngine() { return new ArcanistMercurialWorkEngine(); } public function newLocalState() { return id(new ArcanistMercurialLocalState()) ->setRepositoryAPI($this); } public function willTestMercurialFeature($feature) { $this->executeMercurialFeatureTest($feature, false); return $this; } public function getMercurialFeature($feature) { return $this->executeMercurialFeatureTest($feature, true); } + /** + * Returns the necessary flag for using a Mercurial extension. This will + * enable Mercurial built-in extensions and the "arc-hg" extension that is + * included with Arcanist. This will not enable other extensions, e.g. + * "evolve". + * + * @param string The name of the extension to enable. + * @return string A new command pattern that includes the necessary flags to + * enable the specified extension. + */ + private function getMercurialExtensionFlag($extension) { + switch ($extension) { + case 'arc-hg': + $path = phutil_get_library_root('arcanist'); + $path = dirname($path); + $path = $path.'/support/hg/arc-hg.py'; + $ext_config = 'extensions.arg-hg='.$path; + break; + case 'rebase': + $ext_config = 'extensions.rebase='; + break; + case 'shelve': + $ext_config = 'extensions.shelve='; + break; + case 'strip': + $ext_config = 'extensions.strip='; + break; + default: + throw new Exception( + pht('Unknown Mercurial Extension: "%s".', $extension)); + } + + return csprintf('--config %s', $ext_config); + } + + /** + * Produces the arguments that should be passed to Mercurial command + * execution that enables a desired extension. + * + * @param string The name of the extension to enable. + * @param string The command pattern that will be run with the extension + * enabled. + * @param array Parameters for the command pattern argument. + * @return array An array where the first item is a Mercurial command + * pattern that includes the necessary flag for enabling the + * desired extension, and all remaining items are parameters + * to that command pattern. + */ + private function buildMercurialExtensionCommand( + $extension, + $pattern /* , ... */) { + + $args = func_get_args(); + + $pattern_args = array_slice($args, 2); + + $ext_flag = $this->getMercurialExtensionFlag($extension); + + $full_cmd = $ext_flag.' '.$pattern; + + $args = array_merge( + array($full_cmd), + $pattern_args); + + return $args; + } + + public function execxLocalWithExtension( + $extension, + $pattern /* , ... */) { + + $args = func_get_args(); + $extended_args = call_user_func_array( + array($this, 'buildMercurialExtensionCommand'), + $args); + + return call_user_func_array( + array($this, 'execxLocal'), + $extended_args); + } + + public function execFutureLocalWithExtension( + $extension, + $pattern /* , ... */) { + + $args = func_get_args(); + $extended_args = call_user_func_array( + array($this, 'buildMercurialExtensionCommand'), + $args); + + return call_user_func_array( + array($this, 'execFutureLocal'), + $extended_args); + } + + public function execPassthruWithExtension( + $extension, + $pattern /* , ... */) { + + $args = func_get_args(); + $extended_args = call_user_func_array( + array($this, 'buildMercurialExtensionCommand'), + $args); + + return call_user_func_array( + array($this, 'execPassthru'), + $extended_args); + } + private function executeMercurialFeatureTest($feature, $resolve) { if (array_key_exists($feature, $this->featureResults)) { return $this->featureResults[$feature]; } if (!array_key_exists($feature, $this->featureFutures)) { $future = $this->newMercurialFeatureFuture($feature); $future->start(); $this->featureFutures[$feature] = $future; } if (!$resolve) { return; } $future = $this->featureFutures[$feature]; $result = $this->resolveMercurialFeatureFuture($feature, $future); $this->featureResults[$feature] = $result; return $result; } private function newMercurialFeatureFuture($feature) { switch ($feature) { case 'shelve': - return $this->execFutureLocal( - '--config extensions.shelve= shelve --help --'); + return $this->execFutureLocalWithExtension( + 'shelve', + 'shelve --help --'); case 'evolve': return $this->execFutureLocal('prune --help --'); default: throw new Exception( pht( 'Unknown Mercurial feature "%s".', $feature)); } } private function resolveMercurialFeatureFuture($feature, $future) { // By default, assume the feature is a simple capability test and the // capability is present if the feature resolves without an error. list($err) = $future->resolve(); return !$err; } protected function newSupportedMarkerTypes() { return array( ArcanistMarkerRef::TYPE_BRANCH, ArcanistMarkerRef::TYPE_BOOKMARK, ); } protected function newMarkerRefQueryTemplate() { return new ArcanistMercurialRepositoryMarkerQuery(); } protected function newRemoteRefQueryTemplate() { return new ArcanistMercurialRepositoryRemoteQuery(); } - public function getMercurialExtensionArguments() { - $path = phutil_get_library_root('arcanist'); - $path = dirname($path); - $path = $path.'/support/hg/arc-hg.py'; - - return array( - '--config', - 'extensions.arc-hg='.$path, - ); - } - protected function newNormalizedURI($uri) { return new ArcanistRepositoryURINormalizer( ArcanistRepositoryURINormalizer::TYPE_MERCURIAL, $uri); } protected function newCommitGraphQueryTemplate() { return new ArcanistMercurialCommitGraphQuery(); } protected function newPublishedCommitHashes() { $future = $this->newFuture( 'log --rev %s --template %s', hgsprintf('parents(draft()) - draft()'), '{node}\n'); list($lines) = $future->resolve(); $lines = phutil_split_lines($lines, false); $hashes = array(); foreach ($lines as $line) { if (!strlen(trim($line))) { continue; } $hashes[] = $line; } return $hashes; } } diff --git a/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php index fd12e9aa..998df0dc 100644 --- a/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php @@ -1,122 +1,115 @@ newMarkers(); } protected function newRemoteRefMarkers(ArcanistRemoteRef $remote = null) { return $this->newMarkers($remote); } private function newMarkers(ArcanistRemoteRef $remote = null) { $api = $this->getRepositoryAPI(); // In native Mercurial it is difficult to identify remote markers, and // complicated to identify local markers efficiently. We use an extension // to provide a command which works like "git for-each-ref" locally and // "git ls-remote" when given a remote. - $argv = array(); - foreach ($api->getMercurialExtensionArguments() as $arg) { - $argv[] = $arg; - } - $argv[] = 'arc-ls-markers'; - // NOTE: In remote mode, we're using passthru and a tempfile on this // because it's a remote command and may prompt the user to provide // credentials interactively. In local mode, we can just read stdout. if ($remote !== null) { $tmpfile = new TempFile(); Filesystem::remove($tmpfile); + $argv = array(); $argv[] = '--output'; $argv[] = phutil_string_cast($tmpfile); - } - - $argv[] = '--'; - - if ($remote !== null) { + $argv[] = '--'; $argv[] = $remote->getRemoteName(); - } - if ($remote !== null) { - $passthru = $api->newPassthru('%Ls', $argv); + $err = $api->execPassthruWithExtension( + 'arc-hg', + 'arc-ls-markers %Ls', + $argv); - $err = $passthru->execute(); if ($err) { throw new Exception( pht( 'Call to "hg arc-ls-markers" failed with error "%s".', $err)); } $raw_data = Filesystem::readFile($tmpfile); unset($tmpfile); } else { - $future = $api->newFuture('%Ls', $argv); - list($raw_data) = $future->resolve(); + $future = $api->execFutureLocalWithExtension( + 'arc-hg', + 'arc-ls-markers --'); + list($err, $raw_data) = $future->resolve(); } $items = phutil_json_decode($raw_data); $markers = array(); foreach ($items as $item) { if (!empty($item['isClosed'])) { // NOTE: For now, we ignore closed branch heads. continue; } $node = $item['node']; switch ($item['type']) { case 'branch': $marker_type = ArcanistMarkerRef::TYPE_BRANCH; break; case 'bookmark': $marker_type = ArcanistMarkerRef::TYPE_BOOKMARK; break; case 'commit-state': $marker_type = ArcanistMarkerRef::TYPE_COMMIT_STATE; break; case 'branch-state': $marker_type = ArcanistMarkerRef::TYPE_BRANCH_STATE; break; default: throw new Exception( pht( 'Call to "hg arc-ls-markers" returned marker of unknown '. 'type "%s".', $item['type'])); } $commit_ref = $api->newCommitRef() ->setCommitHash($node); $marker_ref = id(new ArcanistMarkerRef()) ->setName($item['name']) ->setCommitHash($node) ->attachCommitRef($commit_ref); if (isset($item['description'])) { $description = $item['description']; $commit_ref->attachMessage($description); $description_lines = phutil_split_lines($description, false); $marker_ref->setSummary(head($description_lines)); } $marker_ref ->setMarkerType($marker_type) ->setIsActive(!empty($item['isActive'])); $markers[] = $marker_ref; } return $markers; } } diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php index dae4127e..02d1d8d2 100644 --- a/src/repository/state/ArcanistMercurialLocalState.php +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -1,195 +1,198 @@ localCommit; } public function getLocalBookmark() { return $this->localBookmark; } protected function executeSaveLocalState() { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); $markers = $api->newMarkerRefQuery() ->execute(); $local_commit = null; foreach ($markers as $marker) { if ($marker->isCommitState()) { $local_commit = $marker->getCommitHash(); } } if ($local_commit === null) { throw new Exception( pht( 'Unable to identify the current commit in the working copy.')); } $this->localCommit = $local_commit; $local_branch = null; foreach ($markers as $marker) { if ($marker->isBranchState()) { $local_branch = $marker->getName(); break; } } if ($local_branch === null) { throw new Exception( pht( 'Unable to identify the current branch in the working copy.')); } if ($local_branch !== null) { $this->localBranch = $local_branch; } $local_bookmark = null; foreach ($markers as $marker) { if ($marker->isBookmark()) { if ($marker->getIsActive()) { $local_bookmark = $marker->getName(); break; } } } if ($local_bookmark !== null) { $this->localBookmark = $local_bookmark; } $has_bookmark = ($this->localBookmark !== null); if ($has_bookmark) { $location = pht( 'Saving local state (at "%s" on branch "%s", bookmarked as "%s").', $api->getDisplayHash($this->localCommit), $this->localBranch, $this->localBookmark); } else { $location = pht( 'Saving local state (at "%s" on branch "%s").', $api->getDisplayHash($this->localCommit), $this->localBranch); } $log->writeTrace(pht('SAVE STATE'), $location); } protected function executeRestoreLocalState() { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); if ($this->localBookmark !== null) { $location = pht( 'Restoring local state (at "%s" on branch "%s", bookmarked as "%s").', $api->getDisplayHash($this->localCommit), $this->localBranch, $this->localBookmark); } else { $location = pht( 'Restoring local state (at "%s" on branch "%s").', $api->getDisplayHash($this->localCommit), $this->localBranch); } $log->writeStatus(pht('LOAD STATE'), $location); $api->execxLocal('update -- %s', $this->localCommit); $api->execxLocal('branch --force -- %s', $this->localBranch); if ($this->localBookmark !== null) { $api->execxLocal('bookmark --force -- %s', $this->localBookmark); } } protected function executeDiscardLocalState() { return; } protected function canStashChanges() { $api = $this->getRepositoryAPI(); return $api->getMercurialFeature('shelve'); } protected function getIgnoreHints() { return array( pht( 'To configure Mercurial to ignore certain files in the working '. 'copy, add them to ".hgignore".'), ); } protected function newRestoreCommandsForDisplay() { $api = $this->getRepositoryAPI(); $commands = array(); $commands[] = csprintf( 'hg update -- %s', $api->getDisplayHash($this->localCommit)); $commands[] = csprintf( 'hg branch --force -- %s', $this->localBranch); if ($this->localBookmark !== null) { $commands[] = csprintf( 'hg bookmark --force -- %s', $this->localBookmark); } return $commands; } protected function saveStash() { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); $stash_ref = sprintf( 'arc-%s', Filesystem::readRandomCharacters(12)); - $api->execxLocal( - '--config extensions.shelve= shelve --unknown --name %s --', + $api->execxLocalWithExtension( + 'shelve', + 'shelve --unknown --name %s --', $stash_ref); $log->writeStatus( pht('SHELVE'), pht('Shelving uncommitted changes from working copy.')); return $stash_ref; } protected function restoreStash($stash_ref) { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); $log->writeStatus( pht('UNSHELVE'), pht('Restoring uncommitted changes to working copy.')); - $api->execxLocal( - '--config extensions.shelve= unshelve --keep --name %s --', + $api->execxLocalWithExtension( + 'shelve', + 'unshelve --keep --name %s --', $stash_ref); } protected function discardStash($stash_ref) { $api = $this->getRepositoryAPI(); - $api->execxLocal( - '--config extensions.shelve= shelve --delete %s --', + $api->execxLocalWithExtension( + 'shelve', + 'shelve --delete %s --', $stash_ref); } }