diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php index fec60cdd..25a3fe33 100644 --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -1,874 +1,994 @@ 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 = $this->getDisplayHash($commit); $log->writeStatus( pht('SOURCE'), pht( 'Landing the active commit, "%s".', $this->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) { throw new PhutilArgumentUsageException( pht( 'Symbol "%s" is ambiguous: it matches multiple markers '. '(of type "%s"). Use an unambiguous identifier.', $raw_symbol, $marker_type)); } $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 = id(new ArcanistRemoteRef()) + ->setRemoteName($this->getOntoRemote()); + + $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[] = $marker; + } + } + + $branches = array(); + foreach ($onto_markers as $onto_marker) { + if ($onto_marker->isBranch()) { + $branches[] = $onto_marker; + } + + $branch_count = count($branches); + if ($branch_count > 1) { + throw new PhutilArgumentUsageException( + pht( + 'TODO: You can not push onto multiple branches in Mercurial.')); + } else if ($branch_count) { + $this->ontoBranchMarker = head($branches); + } + } + + if ($new_markers) { + // TODO: If we're creating bookmarks, ask the user to confirm. + } + + $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() { - // Make sure that our "into" target is valid. $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. $api = $this->getRepositoryAPI(); $local_ref = $this->getIntoRef(); - // TODO: This error handling could probably be cleaner. + // 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, $this->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(), $this->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(); - // See T9948. If the user specified "--into X", we don't know if it's a - // branch, a bookmark, or a symbol which doesn't exist yet. - - // In native Mercurial it is difficult to figure this out, so we use - // an extension to provide a command which works like "git ls-remote". - - // NOTE: We're using passthru on this because it's a remote command and - // may prompt the user for credentials. - - $tmpfile = new TempFile(); - Filesystem::remove($tmpfile); - - $command = $this->newPassthruCommand( - '%Ls arc-ls-remote --output %s -- %s', - $api->getMercurialExtensionArguments(), - phutil_string_cast($tmpfile), - $target->getRemote()); - - $command->setDisplayCommand( - 'hg ls-remote -- %s', - $target->getRemote()); - - $err = $command->execute(); - if ($err) { - throw new Exception( - pht( - 'Call to "hg arc-ls-remote" failed with error "%s".', - $err)); - } - - $raw_data = Filesystem::readFile($tmpfile); - unset($tmpfile); + $target_name = $target->getRef(); - $markers = phutil_json_decode($raw_data); + $remote_ref = id(new ArcanistRemoteRef()) + ->setRemoteName($target->getRemote()); - $target_name = $target->getRef(); + $markers = $api->newMarkerRefQuery() + ->withRemotes(array($remote_ref)) + ->withNames(array($target_name)) + ->execute(); $bookmarks = array(); $branches = array(); foreach ($markers as $marker) { - if ($marker['name'] !== $target_name) { - continue; - } - - if ($marker['type'] === 'bookmark') { + 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.')); } $is_bookmark = false; $is_branch = false; 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_hash = $bookmark['node']; - $is_bookmark = true; + $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_hash = $branch['node']; - $is_branch = true; + $target_marker = $branch; } if ($is_branch) { $err = $this->newPassthru( - 'pull -b %s -- %s', + '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 -B %s -- %s', + 'pull --bookmark %s -- %s', $target->getRef(), $target->getRemote()); } - // NOTE: It's possible that between the time we ran "ls-remote" and the + // 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_hash; + 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}-'; 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')); } // TODO: Add a Mercurial version check requiring 2.1.1 or newer. $api->execxLocal( 'update --rev %s', hgsprintf('%s', $into_commit)); $commits = $set->getCommits(); $min_commit = last($commits)->getHash(); $max_commit = head($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); + } + 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) { // TODO // $api->execManualLocal('rebase --abort'); throw $ex; } list($stdout) = $api->execxLocal('log --rev tip --template %s', '{node}'); $new_cursor = trim($stdout); return $new_cursor; } protected function pushChange($into_commit) { $api = $this->getRepositoryAPI(); - // TODO: This does not respect "--into" or "--onto" properly. + $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 = array(); + if ($bookmarks) { + $markers = $api->newMarkerRefQuery() + ->withNames(array(mpull($bookmarks, 'getName'))) + ->withTypes(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; + } + + $api->execxLocal( + 'bookmark --force --rev %s -- %s', + hgsprintf('%s', $new_position), + $bookmark_name); + + $restore[$bookmark_name] = $old_position; + } + } + + // Now, do 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(); + + try { + $this->newPassthru('%Ls', $argv); + } finally { + foreach ($restore as $bookmark_name => $old_position) { + if ($old_position === null) { + $api->execxLocal( + 'bookmark --delete -- %s', + $bookmark_name); + } else { + $api->execxLocal( + 'bookmark --force --rev %s -- %s', + hgsprintf('%s', $old_position), + $bookmark_name); + } + } + } - $this->newPassthru( - 'push --rev %s -- %s', - hgsprintf('%s', $into_commit), - $this->getOntoRemote()); } 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) { // TODO: Recover state. 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; } $strip = array(); // 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(); $min_commit = head($commits)->getHash(); $max_commit = last($commits)->getHash(); $strip[] = hgsprintf('%s::%s', $min_commit, $max_commit); } $rev_set = '('.implode(') or (', $strip).')'; // 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. try { $api->execxLocal( '--config extensions.evolve= prune --rev %s', $rev_set); } catch (CommandException $ex) { $api->execxLocal( '--config extensions.strip= strip --rev %s', $rev_set); } } protected function reconcileLocalState( $into_commit, ArcanistRepositoryLocalState $state) { // TODO: For now, just leave users wherever they ended up. $state->discardLocalState(); } protected function didHoldChanges($into_commit) { $log = $this->getLogEngine(); $local_state = $this->getLocalState(); $message = pht( 'Holding changes locally, they have not been pushed.'); // TODO: This is only vaguely correct. $push_command = csprintf( '$ hg push --rev %s -- %s', hgsprintf('%s', $this->getDisplayHash($into_commit)), $this->getOntoRemote()); echo tsprintf( "\n%!\n%s\n\n", pht('HOLD CHANGES'), $message); echo tsprintf( "%s\n\n **%s**\n\n", pht('To push changes manually, run this command:'), $push_command); $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(" **%s**\n", $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.')); } + + + private function newRemoteMarkers($remote) { + // See T9948. If the user specified "--into X" or "--onto X", we don't know + // if it's a branch, a bookmark, or a symbol which doesn't exist yet. + + + } + } diff --git a/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php index 0c0ede61..5e185767 100644 --- a/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php @@ -1,125 +1,128 @@ getRepositoryAPI(); $future = $this->newCurrentBranchNameFuture()->start(); $field_list = array( '%(refname)', '%(objectname)', '%(committerdate:raw)', '%(tree)', '%(*objectname)', '%(subject)', '%(subject)%0a%0a%(body)', '%02', ); $expect_count = count($field_list); $branch_prefix = 'refs/heads/'; $branch_length = strlen($branch_prefix); // NOTE: Since we only return branches today, we restrict this operation // to branches. list($stdout) = $api->newFuture( 'for-each-ref --format %s -- refs/heads/', implode('%01', $field_list))->resolve(); $markers = array(); $lines = explode("\2", $stdout); foreach ($lines as $line) { $line = trim($line); if (!strlen($line)) { continue; } $fields = explode("\1", $line, $expect_count); $actual_count = count($fields); if ($actual_count !== $expect_count) { throw new Exception( pht( 'Unexpected field count when parsing line "%s", got %s but '. 'expected %s.', $line, new PhutilNumber($actual_count), new PhutilNumber($expect_count))); } list($ref, $hash, $epoch, $tree, $dst_hash, $summary, $text) = $fields; if (!strncmp($ref, $branch_prefix, $branch_length)) { $type = ArcanistMarkerRef::TYPE_BRANCH; $name = substr($ref, $branch_length); } else { // For now, discard other refs. continue; } $marker = id(new ArcanistMarkerRef()) ->setName($name) ->setMarkerType($type) ->setEpoch((int)$epoch) ->setMarkerHash($hash) ->setTreeHash($tree) ->setSummary($summary) ->setMessage($text); if (strlen($dst_hash)) { $commit_hash = $dst_hash; } else { $commit_hash = $hash; } $marker->setCommitHash($commit_hash); $commit_ref = $api->newCommitRef() ->setCommitHash($commit_hash) ->attachMessage($text); $marker->attachCommitRef($commit_ref); $markers[] = $marker; } $current = $this->resolveCurrentBranchNameFuture($future); if ($current !== null) { foreach ($markers as $marker) { if ($marker->getName() === $current) { $marker->setIsActive(true); } } } return $markers; } private function newCurrentBranchNameFuture() { $api = $this->getRepositoryAPI(); return $api->newFuture('symbolic-ref --quiet HEAD --') ->setResolveOnError(true); } private function resolveCurrentBranchNameFuture($future) { list($err, $stdout) = $future->resolve(); if ($err) { return null; } $matches = null; if (!preg_match('(^refs/heads/(.*)\z)', trim($stdout), $matches)) { return null; } return $matches[1]; } + protected function newRemoteRefMarkers(ArcanistRemoteRef $remote) { + throw new PhutilMethodNotImplementedException(); + } + } diff --git a/src/repository/marker/ArcanistMarkerRef.php b/src/repository/marker/ArcanistMarkerRef.php index e3b27a96..77112b1e 100644 --- a/src/repository/marker/ArcanistMarkerRef.php +++ b/src/repository/marker/ArcanistMarkerRef.php @@ -1,170 +1,180 @@ getDisplayRefObjectName(); } public function getDisplayRefObjectName() { switch ($this->getMarkerType()) { case self::TYPE_BRANCH: return pht('Branch "%s"', $this->getName()); case self::TYPE_BOOKMARK: return pht('Bookmark "%s"', $this->getName()); default: return pht('Marker "%s"', $this->getName()); } } public function getDisplayRefTitle() { return pht( '%s %s', $this->getDisplayHash(), $this->getSummary()); } protected function newHardpoints() { return array( $this->newHardpoint(self::HARDPOINT_COMMITREF), $this->newHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF), + $this->newHardpoint(self::HARDPOINT_REMOTEREF), ); } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setMarkerType($marker_type) { $this->markerType = $marker_type; return $this; } public function getMarkerType() { return $this->markerType; } public function setEpoch($epoch) { $this->epoch = $epoch; return $this; } public function getEpoch() { return $this->epoch; } public function setMarkerHash($marker_hash) { $this->markerHash = $marker_hash; return $this; } public function getMarkerHash() { return $this->markerHash; } public function setDisplayHash($display_hash) { $this->displayHash = $display_hash; return $this; } public function getDisplayHash() { return $this->displayHash; } public function setCommitHash($commit_hash) { $this->commitHash = $commit_hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function setTreeHash($tree_hash) { $this->treeHash = $tree_hash; return $this; } public function getTreeHash() { return $this->treeHash; } public function setSummary($summary) { $this->summary = $summary; return $this; } public function getSummary() { return $this->summary; } public function setMessage($message) { $this->message = $message; return $this; } public function getMessage() { return $this->message; } public function setIsActive($is_active) { $this->isActive = $is_active; return $this; } public function getIsActive() { return $this->isActive; } public function isBookmark() { return ($this->getMarkerType() === self::TYPE_BOOKMARK); } public function isBranch() { return ($this->getMarkerType() === self::TYPE_BRANCH); } public function attachCommitRef(ArcanistCommitRef $ref) { return $this->attachHardpoint(self::HARDPOINT_COMMITREF, $ref); } public function getCommitRef() { return $this->getHardpoint(self::HARDPOINT_COMMITREF); } public function attachWorkingCopyStateRef(ArcanistWorkingCopyStateRef $ref) { return $this->attachHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF, $ref); } public function getWorkingCopyStateRef() { return $this->getHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF); } + public function attachRemoteRef(ArcanistRemoteRef $ref = null) { + return $this->attachHardpoint(self::HARDPOINT_REMOTEREF, $ref); + } + + public function getRemoteRef() { + return $this->getHardpoint(self::HARDPOINT_REMOTEREF); + } + } diff --git a/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php index 7d4b98a2..0cca7d76 100644 --- a/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php @@ -1,149 +1,128 @@ newMarkers(); + } - if ($this->shouldQueryMarkerType(ArcanistMarkerRef::TYPE_BRANCH)) { - $markers[] = $this->newBranchOrBookmarkMarkers(false); - } + 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. - if ($this->shouldQueryMarkerType(ArcanistMarkerRef::TYPE_BOOKMARK)) { - $markers[] = $this->newBranchOrBookmarkMarkers(true); + $argv = array(); + foreach ($api->getMercurialExtensionArguments() as $arg) { + $argv[] = $arg; } + $argv[] = 'arc-ls-markers'; - return array_mergev($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. - private function newBranchOrBookmarkMarkers($is_bookmarks) { - $api = $this->getRepositoryAPI(); + if ($remote !== null) { + $tmpfile = new TempFile(); + Filesystem::remove($tmpfile); - $is_branches = !$is_bookmarks; + $argv[] = '--output'; + $argv[] = phutil_string_cast($tmpfile); + } - // NOTE: This is a bit clumsy, but it allows us to get most bookmark and - // branch information in a single command, including full hashes, without - // using "--debug" or matching any human readable strings in the output. + $argv[] = '--'; - // NOTE: We can't get branches and bookmarks together in a single command - // because if we query for "heads() + bookmark()", we can't tell if a - // bookmarked result is a branch head or not. + if ($remote !== null) { + $argv[] = $remote->getRemoteName(); + } - $template_fields = array( - '{node}', - '{branch}', - '{join(bookmarks, "\3")}', - '{activebookmark}', - '{desc}', - ); - $expect_fields = count($template_fields); + if ($remote !== null) { + $passthru = $api->newPassthru('%Ls', $argv); - $template = implode('\2', $template_fields).'\1'; + $err = $passthru->execute(); + if ($err) { + throw new Exception( + pht( + 'Call to "hg arc-ls-markers" failed with error "%s".', + $err)); + } - if ($is_bookmarks) { - $query = hgsprintf('bookmark()'); + $raw_data = Filesystem::readFile($tmpfile); + unset($tmpfile); } else { - $query = hgsprintf('head()'); + $future = $api->newFuture('%Ls', $argv); + list($raw_data) = $future->resolve(); } - $future = $api->newFuture( - 'log --rev %s --template %s --', - $query, - $template); - - list($lines) = $future->resolve(); + $items = phutil_json_decode($raw_data); $markers = array(); - - $lines = explode("\1", $lines); - foreach ($lines as $line) { - if (!strlen(trim($line))) { + foreach ($items as $item) { + if (!empty($item['isClosed'])) { + // NOTE: For now, we ignore closed branch heads. continue; } - $fields = explode("\2", $line, $expect_fields); - $actual_fields = count($fields); - if ($actual_fields !== $expect_fields) { - throw new Exception( - pht( - 'Unexpected number of fields in line "%s", expected %s but '. - 'found %s.', - $line, - new PhutilNumber($expect_fields), - new PhutilNumber($actual_fields))); + $node = $item['node']; + if (!$node) { + // NOTE: For now, we ignore the virtual "current branch" marker. + continue; } - $node = $fields[0]; - - $branch = $fields[1]; - if (!strlen($branch)) { - $branch = 'default'; + switch ($item['type']) { + case 'branch': + $marker_type = ArcanistMarkerRef::TYPE_BRANCH; + break; + case 'bookmark': + $marker_type = ArcanistMarkerRef::TYPE_BOOKMARK; + break; + case 'commit': + $marker_type = null; + break; + default: + throw new Exception( + pht( + 'Call to "hg arc-ls-markers" returned marker of unknown '. + 'type "%s".', + $item['type'])); } - if ($is_bookmarks) { - $bookmarks = $fields[2]; - if (strlen($bookmarks)) { - $bookmarks = explode("\3", $fields[2]); - } else { - $bookmarks = array(); - } - - if (strlen($fields[3])) { - $active_bookmark = $fields[3]; - } else { - $active_bookmark = null; - } - } else { - $bookmarks = array(); - $active_bookmark = null; + if ($marker_type === null) { + // NOTE: For now, we ignore the virtual "head" marker. + continue; } - $message = $fields[4]; - $message_lines = phutil_split_lines($message, false); - $commit_ref = $api->newCommitRef() - ->setCommitHash($node) - ->attachMessage($message); + ->setCommitHash($node); - $template = id(new ArcanistMarkerRef()) + $marker_ref = id(new ArcanistMarkerRef()) + ->setName($item['name']) ->setCommitHash($node) - ->setSummary(head($message_lines)) ->attachCommitRef($commit_ref); - if ($is_bookmarks) { - foreach ($bookmarks as $bookmark) { - $is_active = ($bookmark === $active_bookmark); + if (isset($item['description'])) { + $description = $item['description']; + $commit_ref->attachMessage($description); - $markers[] = id(clone $template) - ->setMarkerType(ArcanistMarkerRef::TYPE_BOOKMARK) - ->setName($bookmark) - ->setIsActive($is_active); - } + $description_lines = phutil_split_lines($description, false); + $marker_ref->setSummary(head($description_lines)); } - if ($is_branches) { - $markers[] = id(clone $template) - ->setMarkerType(ArcanistMarkerRef::TYPE_BRANCH) - ->setName($branch); - } - } + $marker_ref + ->setMarkerType($marker_type) + ->setIsActive(!empty($item['isActive'])); - if ($is_branches) { - $current_hash = $api->getCanonicalRevisionName('.'); - - foreach ($markers as $marker) { - if ($marker->getMarkerType() !== ArcanistMarkerRef::TYPE_BRANCH) { - continue; - } - - if ($marker->getCommitHash() === $current_hash) { - $marker->setIsActive(true); - } - } + $markers[] = $marker_ref; } return $markers; } } diff --git a/src/repository/marker/ArcanistRepositoryMarkerQuery.php b/src/repository/marker/ArcanistRepositoryMarkerQuery.php index c969923f..b39c232b 100644 --- a/src/repository/marker/ArcanistRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistRepositoryMarkerQuery.php @@ -1,95 +1,120 @@ markerTypes = array_fuse($types); return $this; } final public function withNames(array $names) { $this->names = array_fuse($names); return $this; } + final public function withRemotes(array $remotes) { + assert_instances_of($remotes, 'ArcanistRemoteRef'); + $this->remotes = $remotes; + return $this; + } + final public function withIsActive($active) { $this->isActive = $active; return $this; } final public function execute() { - $markers = $this->newRefMarkers(); + $remotes = $this->remotes; + if ($remotes !== null) { + $marker_lists = array(); + foreach ($remotes as $remote) { + $marker_list = $this->newRemoteRefMarkers($remote); + foreach ($marker_list as $marker) { + $marker->attachRemoteRef($remote); + } + $marker_lists[] = $marker_list; + } + $markers = array_mergev($marker_lists); + } else { + $markers = $this->newLocalRefMarkers(); + foreach ($markers as $marker) { + $marker->attachRemoteRef(null); + } + } $api = $this->getRepositoryAPI(); foreach ($markers as $marker) { $state_ref = id(new ArcanistWorkingCopyStateRef()) ->setCommitRef($marker->getCommitRef()); $marker->attachWorkingCopyStateRef($state_ref); $hash = $marker->getCommitHash(); $hash = $api->getDisplayHash($hash); $marker->setDisplayHash($hash); } $types = $this->markerTypes; if ($types !== null) { foreach ($markers as $key => $marker) { if (!isset($types[$marker->getMarkerType()])) { unset($markers[$key]); } } } $names = $this->names; if ($names !== null) { foreach ($markers as $key => $marker) { if (!isset($names[$marker->getName()])) { unset($markers[$key]); } } } if ($this->isActive !== null) { foreach ($markers as $key => $marker) { if ($marker->getIsActive() !== $this->isActive) { unset($markers[$key]); } } } - return $this->sortMarkers($markers); } private function sortMarkers(array $markers) { // Sort the list in natural order. If we apply a stable sort later, // markers will sort in "feature1", "feature2", etc., order if they // don't otherwise have a unique position. // This can improve behavior if two branches were updated at the same // time, as is common when cascading rebases after changes land. $map = mpull($markers, 'getName'); natcasesort($map); $markers = array_select_keys($markers, array_keys($map)); return $markers; } final protected function shouldQueryMarkerType($marker_type) { if ($this->markerTypes === null) { return true; } return isset($this->markerTypes[$marker_type]); } + abstract protected function newLocalRefMarkers(); + abstract protected function newRemoteRefMarkers(ArcanistRemoteRef $remote); + } diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php index 338971c8..0bcbd65d 100644 --- a/src/repository/state/ArcanistMercurialLocalState.php +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -1,96 +1,98 @@ localRef; } public function getLocalPath() { return $this->localPath; } protected function executeSaveLocalState() { $api = $this->getRepositoryAPI(); - // TODO: Fix this. + // TODO: We need to save the position of "." and the current active + // branch, which may be any symbol at all. Both of these can be pulled + // from "hg arc-ls-markers". + } protected function executeRestoreLocalState() { $api = $this->getRepositoryAPI(); - // TODO: Fix this. // TODO: In Mercurial, we may want to discard commits we've created. // $repository_api->execxLocal( // '--config extensions.mq= strip %s', // $this->onto); } protected function executeDiscardLocalState() { // TODO: Fix this. } 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() { // TODO: Provide this. return array(); } 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 --', $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 --', $stash_ref); } protected function discardStash($stash_ref) { $api = $this->getRepositoryAPI(); $api->execxLocal( '--config extensions.shelve= shelve --delete %s --', $stash_ref); } } diff --git a/support/hg/arc-hg.py b/support/hg/arc-hg.py index 5a2e52f9..70dad19f 100644 --- a/support/hg/arc-hg.py +++ b/support/hg/arc-hg.py @@ -1,90 +1,190 @@ from __future__ import absolute_import import os import json from mercurial import ( cmdutil, bookmarks, bundlerepo, error, hg, i18n, node, registrar, ) _ = i18n._ cmdtable = {} command = registrar.command(cmdtable) @command( - "arc-ls-remote", + "arc-ls-markers", [('', 'output', '', _('file to output refs to'), _('FILE')), ] + cmdutil.remoteopts, _('[--output FILENAME] [SOURCE]')) -def lsremote(ui, repo, source="default", **opts): - """list markers in a remote +def lsmarkers(ui, repo, source=None, **opts): + """list markers - Show the current branch heads and bookmarks in a specified path/URL or the - default pull location. + Show the current branch heads and bookmarks in the local working copy, or + a specified path/URL. Markers are printed to stdout in JSON. (This is an Arcanist extension to Mercurial.) Returns 0 if listing the markers succeeds, 1 otherwise. """ + if source is None: + markers = localmarkers(ui, repo) + else: + markers = remotemarkers(ui, repo, source, opts) + + json_opts = { + 'indent': 2, + 'sort_keys': True, + } + + output_file = opts.get('output') + if output_file: + if os.path.exists(output_file): + raise error.Abort(_('File "%s" already exists.' % output_file)) + with open(output_file, 'w+') as f: + json.dump(markers, f, **json_opts) + else: + print json.dumps(markers, output_file, **json_opts) + + return 0 + +def localmarkers(ui, repo): + markers = [] + + active_node = repo['.'].node() + all_heads = set(repo.heads()) + current_name = repo.dirstate.branch() + saw_current = False + saw_active = False + + branch_list = repo.branchmap().iterbranches() + for branch_name, branch_heads, tip_node, is_closed in branch_list: + for head_node in branch_heads: + is_active = (head_node == active_node) + is_tip = (head_node == tip_node) + is_current = (branch_name == current_name) + + if is_current: + saw_current = True + + if is_active: + saw_active = True + + if is_closed: + head_closed = True + else: + head_closed = bool(head_node not in all_heads) + + description = repo[head_node].description() + + markers.append({ + 'type': 'branch', + 'name': branch_name, + 'node': node.hex(head_node), + 'isActive': is_active, + 'isClosed': head_closed, + 'isTip': is_tip, + 'isCurrent': is_current, + 'description': description, + }) + + # If the current branch (selected with "hg branch X") is not reflected in + # the list of heads we selected, add a virtual head for it so callers get + # a complete picture of repository marker state. + + if not saw_current: + markers.append({ + 'type': 'branch', + 'name': current_name, + 'node': None, + 'isActive': False, + 'isClosed': False, + 'isTip': False, + 'isCurrent': True, + 'description': None, + }) + + bookmarks = repo._bookmarks + active_bookmark = repo._activebookmark + + for bookmark_name, bookmark_node in bookmarks.iteritems(): + is_active = (active_bookmark == bookmark_name) + description = repo[bookmark_node].description() + + if is_active: + saw_active = True + + markers.append({ + 'type': 'bookmark', + 'name': bookmark_name, + 'node': node.hex(bookmark_node), + 'isActive': is_active, + 'description': description, + }) + + # If the current working copy state is not the head of a branch and there is + # also no active bookmark, add a virtual marker for it so callers can figure + # out exactly where we are. + + if not saw_active: + markers.append({ + 'type': 'commit', + 'name': None, + 'node': node.hex(active_node), + 'isActive': False, + 'isClosed': False, + 'isTip': False, + 'isCurrent': True, + 'description': repo['.'].description(), + }) + + return markers + +def remotemarkers(ui, repo, source, opts): # Disable status output from fetching a remote. ui.quiet = True + markers = [] + source, branches = hg.parseurl(ui.expandpath(source)) remote = hg.peer(repo, opts, source) - markers = [] - bundle, remotebranches, cleanup = bundlerepo.getremotechanges( ui, repo, remote) try: for n in remotebranches: ctx = bundle[n] markers.append({ 'type': 'branch', 'name': ctx.branch(), 'node': node.hex(ctx.node()), }) finally: cleanup() with remote.commandexecutor() as e: remotemarks = bookmarks.unhexlifybookmarks(e.callcommand('listkeys', { 'namespace': 'bookmarks', }).result()) for mark in remotemarks: markers.append({ 'type': 'bookmark', 'name': mark, 'node': node.hex(remotemarks[mark]), }) - json_opts = { - 'indent': 2, - 'sort_keys': True, - } - - output_file = opts.get('output') - if output_file: - if os.path.exists(output_file): - raise error.Abort(_('File "%s" already exists.' % output_file)) - with open(output_file, 'w+') as f: - json.dump(markers, f, **json_opts) - else: - print json.dumps(markers, output_file, **json_opts) - - return 0 + return markers