diff --git a/.gitignore b/.gitignore index 096c30cb..d40646a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,38 @@ # NOTE: Thinking about adding files created by your operating system, IDE, # or text editor here? Don't! Add them to your per-user .gitignore instead. # Diviner /docs/ /.divinercache/ # libphutil /src/.phutil_module_cache # User extensions /externals/includes/* /src/extensions/* # XHPAST /support/xhpast/*.a /support/xhpast/*.o /support/xhpast/parser.yacc.output /support/xhpast/node_names.hpp /support/xhpast/xhpast /support/xhpast/xhpast.exe /src/parser/xhpast/bin/xhpast ## NOTE: Don't .gitignore these files! Even though they're build artifacts, we ## want to check them in so users can build xhpast without flex/bison. # /support/xhpast/parser.yacc.cpp # /support/xhpast/parser.yacc.hpp # /support/xhpast/scanner.lex.cpp # /support/xhpast/scanner.lex.hpp # This is an OS X build artifact. /support/xhpast/xhpast.dSYM # Generated shell completion rulesets. /support/shell/rules/ + +# Python extension compiled files. +/support/hg/arc-hg.pyc diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php index 241015e7..68dd20f7 100644 --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -1,733 +1,872 @@ 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) { 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)); } } } 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. $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(); - // TODO: Support bookmarks. - // TODO: Deal with bookmark save/restore behavior. - // TODO: Raise a good error message when the ref does not exist. + // 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. + + // TODO: This is fairly silly/confusing to show to users in the common + // case where it does not require credentials, particularly because the + // actual command line is full of nonsense. + + $tmpfile = new TempFile(); + Filesystem::remove($tmpfile); $err = $this->newPassthru( - 'pull -b %s -- %s', - $target->getRef(), + '%Ls arc-ls-remote --output %s -- %s', + $api->getMercurialExtensionArguments(), + phutil_string_cast($tmpfile), $target->getRemote()); + if ($err) { + throw new Exception( + pht( + 'Call to "hg arc-ls-remote" failed with error "%s".', + $err)); + } + + $raw_data = Filesystem::readFile($tmpfile); + unset($tmpfile); + + $markers = phutil_json_decode($raw_data); + + $target_name = $target->getRef(); + + $bookmarks = array(); + $branches = array(); + foreach ($markers as $marker) { + if ($marker['name'] !== $target_name) { + continue; + } + + if ($marker['type'] === 'bookmark') { + $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; + } + + 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())); - // TODO: Deal with errors. - // TODO: Deal with multiple branch heads. + throw new PhutilArgumentUsageException( + pht( + 'Remote branch has multiple heads.')); + } + + $branch = head($branches); - list($stdout) = $api->execxLocal( - 'log --rev %s --template %s --', - hgsprintf( - 'last(ancestors(%s) and !outgoing(%s))', + $target_hash = $branch['node']; + $is_branch = true; + } + + if ($is_branch) { + $err = $this->newPassthru( + 'pull -b %s -- %s', $target->getRef(), - $target->getRemote()), - '{node}'); + $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', + $target->getRef(), + $target->getRemote()); + } + + // NOTE: It's possible that between the time we ran "ls-remote" 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 trim($stdout); + return $target_hash; } 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(); 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. $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.')); } } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index 12b78bf4..4756d068 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,1015 +1,1027 @@ getMercurialEnvironmentVariables(); $argv[0] = 'hg '.$argv[0]; $future = newv('ExecFuture', $argv) ->setEnv($env) ->setCWD($this->getPath()); return $future; } public function newPassthru($pattern /* , ... */) { $args = func_get_args(); $env = $this->getMercurialEnvironmentVariables(); $args[0] = 'hg '.$args[0]; return newv('PhutilExecPassthru', $args) ->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 -l %s', $tmp_file); $this->reloadWorkingCopy(); } public function amendCommit($message = null) { if ($message === null) { $message = $this->getCommitMessage('.'); } $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); try { $this->execxLocal( 'commit --amend -l %s', $tmp_file); } catch (CommandException $ex) { if (preg_match('/nothing changed/', $ex->getStdout())) { // NOTE: Mercurial considers it an error to make a no-op amend. Although // we generally defer to the underlying VCS to dictate behavior, this // one seems a little goofy, and we use amend as part of various // workflows under the assumption that no-op amends are fine. If this // amend failed because it's a no-op, just continue. } else { throw $ex; } } $this->reloadWorkingCopy(); } 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(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); } 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'); 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, + ); + } + } diff --git a/support/hg/arc-hg.py b/support/hg/arc-hg.py new file mode 100644 index 00000000..5a2e52f9 --- /dev/null +++ b/support/hg/arc-hg.py @@ -0,0 +1,90 @@ +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", + [('', 'output', '', + _('file to output refs to'), _('FILE')), + ] + cmdutil.remoteopts, + _('[--output FILENAME] [SOURCE]')) +def lsremote(ui, repo, source="default", **opts): + """list markers in a remote + + Show the current branch heads and bookmarks in a specified path/URL or the + default pull location. + + Markers are printed to stdout in JSON. + + (This is an Arcanist extension to Mercurial.) + + Returns 0 if listing the markers succeeds, 1 otherwise. + """ + + # Disable status output from fetching a remote. + ui.quiet = True + + 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