diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php index 37e79801..eeba0122 100644 --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -1,991 +1,1083 @@ 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) { + 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( + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS SYMBOL'), pht( 'Symbol "%s" is ambiguous: it matches multiple markers '. '(of type "%s"). Use an unambiguous identifier.', $raw_symbol, $marker_type)); + + foreach ($named_markers as $named_marker) { + echo tsprintf('%s', $named_marker->newDisplayRef()); + } + + echo tsprintf("\n"); + + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" is ambiguous.', + $symbol)); } $marker = head($named_markers); $symbol->setCommit($marker->getCommitHash()); unset($unresolved[$key]); } } foreach ($unresolved as $symbol) { $raw_symbol = $symbol->getSymbol(); // TODO: This doesn't have accurate error behavior if the user provides // a revset like "x::y". try { $commit = $api->getCanonicalRevisionName($raw_symbol); } catch (CommandException $ex) { $commit = null; } if ($commit === null) { throw new PhutilArgumentUsageException( pht( 'Symbol "%s" does not identify a bookmark, branch, or commit.', $raw_symbol)); } $symbol->setCommit($commit); } } protected function selectOntoRemote(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $remote = $this->newOntoRemote($symbols); $remote_ref = $api->newRemoteRefQuery() ->withNames(array($remote)) ->executeOne(); if (!$remote_ref) { throw new PhutilArgumentUsageException( pht( 'No remote "%s" exists in this repository.', $remote)); } // TODO: Allow selection of a bare URI. return $remote; } private function newOntoRemote(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); $remote = $this->getOntoRemoteArgument(); if ($remote !== null) { $log->writeStatus( pht('ONTO REMOTE'), pht( 'Remote "%s" was selected with the "--onto-remote" flag.', $remote)); return $remote; } $remote = $this->getOntoRemoteFromConfiguration(); if ($remote !== null) { $remote_key = $this->getOntoRemoteConfigurationKey(); $log->writeStatus( pht('ONTO REMOTE'), pht( 'Remote "%s" was selected by reading "%s" configuration.', $remote, $remote_key)); return $remote; } $api = $this->getRepositoryAPI(); $default_remote = 'default'; $log->writeStatus( pht('ONTO REMOTE'), pht( 'Landing onto remote "%s", the default remote under Mercurial.', $default_remote)); return $default_remote; } protected function selectOntoRefs(array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $log = $this->getLogEngine(); $onto = $this->getOntoArguments(); if ($onto) { $log->writeStatus( pht('ONTO TARGET'), pht( 'Refs were selected with the "--onto" flag: %s.', implode(', ', $onto))); return $onto; } $onto = $this->getOntoFromConfiguration(); if ($onto) { $onto_key = $this->getOntoConfigurationKey(); $log->writeStatus( pht('ONTO TARGET'), pht( 'Refs were selected by reading "%s" configuration: %s.', $onto_key, implode(', ', $onto))); return $onto; } $api = $this->getRepositoryAPI(); $default_onto = 'default'; $log->writeStatus( pht('ONTO TARGET'), pht( 'Landing onto target "%s", the default target under Mercurial.', $default_onto)); return array($default_onto); } protected function confirmOntoRefs(array $onto_refs) { $api = $this->getRepositoryAPI(); foreach ($onto_refs as $onto_ref) { if (!strlen($onto_ref)) { throw new PhutilArgumentUsageException( pht( 'Selected "onto" ref "%s" is invalid: the empty string is not '. 'a valid ref.', $onto_ref)); } } $remote_ref = 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[] = head($matches); } } $branches = array(); foreach ($onto_markers as $onto_marker) { if ($onto_marker->isBranch()) { $branches[] = $onto_marker; } $branch_count = count($branches); if ($branch_count > 1) { + echo tsprintf( + "\n%!\n%W\n\n%W\n\n%W\n\n", + pht('MULTIPLE "ONTO" BRANCHES'), + pht( + 'You have selected multiple branches to push changes onto. '. + 'Pushing to multiple branches is not supported by "arc land" '. + 'in Mercurial: Mercurial commits may only belong to one '. + 'branch, so this operation can not be executed atomically.'), + pht( + 'You may land one branches and any number of bookmarks in a '. + 'single operation.'), + pht('These branches were selected:')); + + foreach ($branches as $branch) { + echo tsprintf('%s', $branch->newDisplayRef()); + } + + echo tsprintf("\n"); + throw new PhutilArgumentUsageException( pht( - 'TODO: You can not push onto multiple branches in Mercurial.')); + 'Landing onto multiple branches at once is not supported in '. + 'Mercurial.')); } else if ($branch_count) { $this->ontoBranchMarker = head($branches); } } if ($new_markers) { - // TODO: If we're creating bookmarks, ask the user to confirm. + echo tsprintf( + "\n%!\n%W\n\n", + pht('CREATE %s BOOKMARK(S)', phutil_count($new_markers)), + pht( + 'These %s symbol(s) do not exist in the remote. They will be created '. + 'as new bookmarks:', + phutil_count($new_markers))); + + + foreach ($new_markers as $new_marker) { + echo tsprintf('%s', $new_marker->newDisplayRef()); + } + + echo tsprintf("\n"); + + $query = pht( + 'Create %s new remote bookmark(s)?', + phutil_count($new_markers)); + + $this->getWorkflow() + ->getPrompt('arc.land.create') + ->setQuery($query) + ->execute(); } $this->ontoMarkers = $onto_markers; } protected function selectIntoRemote() { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); if ($this->getIntoEmptyArgument()) { $this->setIntoEmpty(true); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into empty state, selected with the "--into-empty" '. 'flag.')); return; } if ($this->getIntoLocalArgument()) { $this->setIntoLocal(true); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into local state, selected with the "--into-local" '. 'flag.')); return; } $into = $this->getIntoRemoteArgument(); if ($into !== null) { $remote_ref = $api->newRemoteRefQuery() ->withNames(array($into)) ->executeOne(); if (!$remote_ref) { throw new PhutilArgumentUsageException( pht( 'No remote "%s" exists in this repository.', $into)); } // TODO: Allow a raw URI. $this->setIntoRemote($into); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into remote "%s", selected with the "--into" flag.', $into)); return; } $onto = $this->getOntoRemote(); $this->setIntoRemote($onto); $log->writeStatus( pht('INTO REMOTE'), pht( 'Will merge into remote "%s" by default, because this is the remote '. 'the change is landing onto.', $onto)); } protected function selectIntoRef() { $log = $this->getLogEngine(); if ($this->getIntoEmptyArgument()) { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into empty state, selected with the "--into-empty" '. 'flag.')); return; } $into = $this->getIntoArgument(); if ($into !== null) { $this->setIntoRef($into); $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s", selected with the "--into" flag.', $into)); return; } $ontos = $this->getOntoRefs(); $onto = head($ontos); $this->setIntoRef($onto); if (count($ontos) > 1) { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s" by default, because this is the first '. '"onto" target.', $onto)); } else { $log->writeStatus( pht('INTO TARGET'), pht( 'Will merge into target "%s" by default, because this is the "onto" '. 'target.', $onto)); } } protected function selectIntoCommit() { $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, 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(); $target_name = $target->getRef(); $remote_ref = id(new ArcanistRemoteRef()) ->setRemoteName($target->getRemote()); $markers = $api->newMarkerRefQuery() ->withRemotes(array($remote_ref)) ->withNames(array($target_name)) ->execute(); $bookmarks = array(); $branches = array(); foreach ($markers as $marker) { if ($marker->isBookmark()) { $bookmarks[] = $marker; } else { $branches[] = $marker; } } if (!$bookmarks && !$branches) { throw new PhutilArgumentUsageException( pht( 'Remote "%s" has no bookmark or branch named "%s".', $target->getRemote(), $target->getRef())); } if ($bookmarks && $branches) { echo tsprintf( "\n%!\n%W\n\n", pht('AMBIGUOUS MARKER'), pht( 'In remote "%s", the name "%s" identifies one or more branch '. 'heads and one or more bookmarks. Close, rename, or delete all '. 'but one of these markers, or pull the state you want to merge '. 'into and use "--into-local --into " to disambiguate the '. 'desired merge target.', $target->getRemote(), $target->getRef())); throw new PhutilArgumentUsageException( pht('Merge target is ambiguous.')); } if ($bookmarks) { if (count($bookmarks) > 1) { throw new Exception( pht( 'Remote "%s" has multiple bookmarks with name "%s". This '. 'is unexpected.', $target->getRemote(), $target->getRef())); } $bookmark = head($bookmarks); $target_marker = $bookmark; } if ($branches) { if (count($branches) > 1) { echo tsprintf( "\n%!\n%W\n\n", pht('MULTIPLE BRANCH HEADS'), pht( 'Remote "%s" has multiple branch heads named "%s". Close all '. 'but one, or pull the head you want and use "--into-local '. '--into " to specify an explicit merge target.', $target->getRemote(), $target->getRef())); throw new PhutilArgumentUsageException( pht( 'Remote branch has multiple heads.')); } $branch = head($branches); $target_marker = $branch; } if ($target_marker->isBranch()) { $err = $this->newPassthru( 'pull --branch %s -- %s', $target->getRef(), $target->getRemote()); } else { // NOTE: This may have side effects: // // - It can create a "bookmark@remote" bookmark if there is a local // bookmark with the same name that is not an ancestor. // - It can create an arbitrary number of other bookmarks. // // Since these seem to generally be intentional behaviors in Mercurial, // and should theoretically be familiar to Mercurial users, just accept // them as the cost of doing business. $err = $this->newPassthru( 'pull --bookmark %s -- %s', $target->getRef(), $target->getRemote()); } // NOTE: It's possible that between the time we ran "ls-markers" and the // time we ran "pull" that the remote changed. // It may even have been rewound or rewritten, in which case we did not // actually fetch the ref we are about to return as a target. For now, // assume this didn't happen: it's so unlikely that it's probably not // worth spending 100ms to check. // TODO: If the Mercurial command server is revived, this check becomes // more reasonable if it's cheap. return $target_marker->getCommitHash(); } protected function selectCommits($into_commit, array $symbols) { assert_instances_of($symbols, 'ArcanistLandSymbol'); $api = $this->getRepositoryAPI(); $commit_map = array(); foreach ($symbols as $symbol) { $symbol_commit = $symbol->getCommit(); $template = '{node}-{parents}-'; 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->getName()); } try { $argv = array(); $argv[] = '--dest'; $argv[] = hgsprintf('%s', $into_commit); $argv[] = '--rev'; $argv[] = hgsprintf('%s..%s', $min_commit, $max_commit); $argv[] = '--logfile'; $argv[] = '-'; $argv[] = '--keep'; $argv[] = '--collapse'; $future = $api->execFutureLocal('rebase %Ls', $argv); $future->write($commit_message); $future->resolvex(); } catch (CommandException $ex) { // 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(); + list($head, $body, $tail) = $this->newPushCommands($into_commit); + + foreach ($head as $command) { + $api->execxLocal('%Ls', $command); + } + + try { + foreach ($body as $command) { + $this->newPasthru('%Ls', $command); + } + } finally { + foreach ($tail as $command) { + $api->execxLocal('%Ls', $command); + } + } + } + + private function newPushCommands($into_commit) { + $api = $this->getRepositoryAPI(); + + $head_commands = array(); + $body_commands = array(); + $tail_commands = array(); + $bookmarks = array(); foreach ($this->ontoMarkers as $onto_marker) { if (!$onto_marker->isBookmark()) { continue; } $bookmarks[] = $onto_marker; } // If we're pushing to bookmarks, move all the bookmarks we want to push // to the merge commit. (There doesn't seem to be any way to specify // "push commit X as bookmark Y" in Mercurial.) $restore = array(); if ($bookmarks) { $markers = $api->newMarkerRefQuery() - ->withNames(array(mpull($bookmarks, 'getName'))) - ->withTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) + ->withNames(mpull($bookmarks, 'getName')) + ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) ->execute(); $markers = mpull($markers, 'getCommitHash', 'getName'); foreach ($bookmarks as $bookmark) { $bookmark_name = $bookmark->getName(); $old_position = idx($markers, $bookmark_name); $new_position = $into_commit; if ($old_position === $new_position) { continue; } + $head_commands[] = array( + 'bookmark', + '--force', + '--rev', + hgsprintf('%s', $this->getDisplayHash($new_position)), + '--', + $bookmark_name, + ); + $api->execxLocal( 'bookmark --force --rev %s -- %s', hgsprintf('%s', $new_position), $bookmark_name); $restore[$bookmark_name] = $old_position; } } - // Now, do the actual push. + // Now, prepare the actual push. $argv = array(); $argv[] = 'push'; if ($bookmarks) { // If we're pushing at least one bookmark, we can just specify the list // of bookmarks as things we want to push. foreach ($bookmarks as $bookmark) { $argv[] = '--bookmark'; $argv[] = $bookmark->getName(); } } else { // Otherwise, specify the commit itself. $argv[] = '--rev'; $argv[] = hgsprintf('%s', $into_commit); } $argv[] = '--'; $argv[] = $this->getOntoRemote(); - 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); - } + $body_commands[] = $argv; + + // Finally, restore the bookmarks. + + foreach ($restore as $bookmark_name => $old_position) { + $tail = array(); + $tail[] = 'bookmark'; + + if ($old_position === null) { + $tail[] = '--delete'; + } else { + $tail[] = '--force'; + $tail[] = '--rev'; + $tail[] = hgsprintf('%s', $this->getDisplayHash($old_position)); } + + $tail[] = '--'; + $tail[] = $bookmark_name; + + $tail_commands[] = $tail; } + return array( + $head_commands, + $body_commands, + $tail_commands, + ); } protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { $api = $this->getRepositoryAPI(); $log = $this->getLogEngine(); // This has no effect when we're executing a merge strategy. if (!$this->isSquashStrategy()) { return; } $old_commit = last($set->getCommits())->getHash(); $new_commit = $into_commit; list($output) = $api->execxLocal( 'log --rev %s --template %s', hgsprintf('children(%s)', $old_commit), '{node}\n'); $child_hashes = phutil_split_lines($output, false); foreach ($child_hashes as $child_hash) { if (!strlen($child_hash)) { continue; } // TODO: If the only heads which are descendants of this child will // be deleted, we can skip this rebase? try { $api->execxLocal( 'rebase --source %s --dest %s --keep --keepbranches', $child_hash, $new_commit); } catch (CommandException $ex) { // 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; } $revs = 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(); $revs[] = hgsprintf('%s::%s', $min_commit, $max_commit); } $rev_set = '('.implode(') or (', $revs).')'; // See PHI45. If we have "hg evolve", get rid of old commits using // "hg prune" instead of "hg strip". // If we "hg strip" a commit which has an obsolete predecessor, it // removes the obsolescence marker and revives the predecessor. This is // not desirable: we want to destroy all predecessors of these commits. if ($api->getMercurialFeature('evolve')) { $api->execxLocal( 'prune --rev %s', $rev_set); } else { $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()); + list($head, $body, $tail) = $this->newPushCommands($into_commit); + $commands = array_merge($head, $body, $tail); 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); + "%s\n\n", + pht('To push changes manually, run these %s command(s):', + phutil_count($commands))); + + foreach ($commands as $command) { + echo tsprintf('%>', csprintf('hg %Ls', $command)); + } + + echo tsprintf("\n"); $restore_commands = $local_state->getRestoreCommandsForDisplay(); if ($restore_commands) { echo tsprintf( "%s\n\n", pht( 'To go back to how things were before you ran "arc land", run '. 'these %s command(s):', phutil_count($restore_commands))); foreach ($restore_commands as $restore_command) { - echo tsprintf(" **%s**\n", $restore_command); + echo tsprintf('%>', $restore_command); } echo tsprintf("\n"); } echo tsprintf( "%s\n", pht( 'Local branches and bookmarks have not been changed, and are still '. 'in the same state as before.')); } - - 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/state/ArcanistGitLocalState.php b/src/repository/state/ArcanistGitLocalState.php index 30d29d3a..61a70e0d 100644 --- a/src/repository/state/ArcanistGitLocalState.php +++ b/src/repository/state/ArcanistGitLocalState.php @@ -1,170 +1,165 @@ localRef; } public function getLocalPath() { return $this->localPath; } protected function executeSaveLocalState() { $api = $this->getRepositoryAPI(); $commit = $api->getWorkingCopyRevision(); list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD'); $ref = trim($ref); if ($ref === 'HEAD') { $ref = null; $where = pht( 'Saving local state (at detached commit "%s").', $this->getDisplayHash($commit)); } else { $where = pht( 'Saving local state (on ref "%s" at commit "%s").', $ref, $this->getDisplayHash($commit)); } $this->localRef = $ref; $this->localCommit = $commit; if ($ref !== null) { $this->localPath = $api->getPathToUpstream($ref); } $log = $this->getWorkflow()->getLogEngine(); $log->writeTrace(pht('SAVE STATE'), $where); } protected function executeRestoreLocalState() { $api = $this->getRepositoryAPI(); - $log = $this->getWorkflow()->getLogEngine(); $ref = $this->localRef; $commit = $this->localCommit; if ($ref !== null) { $where = pht( 'Restoring local state (to ref "%s" at commit "%s").', $ref, $this->getDisplayHash($commit)); } else { $where = pht( 'Restoring local state (to detached commit "%s").', $this->getDisplayHash($commit)); } $log->writeStatus(pht('LOAD STATE'), $where); if ($ref !== null) { $api->execxLocal('checkout -B %s %s --', $ref, $commit); // TODO: We save, but do not restore, the upstream configuration of // this branch. } else { $api->execxLocal('checkout %s --', $commit); } $api->execxLocal('submodule update --init --recursive'); } protected function executeDiscardLocalState() { // We don't have anything to clean up in Git. return; } protected function newRestoreCommandsForDisplay() { $ref = $this->localRef; $commit = $this->localCommit; $commands = array(); if ($ref !== null) { $commands[] = csprintf( 'git checkout -B %s %s --', $ref, $this->getDisplayHash($commit)); } else { $commands[] = csprintf( 'git checkout %s --', $this->getDisplayHash($commit)); } // NOTE: We run "submodule update" in the real restore workflow, but // assume users can reasonably figure that out on their own. return $commands; } protected function canStashChanges() { return true; } protected function getIgnoreHints() { return array( pht( 'To configure Git to ignore certain files in this working copy, '. 'add the file paths to "%s".', '.git/info/exclude'), ); } protected function saveStash() { $api = $this->getRepositoryAPI(); // NOTE: We'd prefer to "git stash create" here, because using "push" // and "pop" means we're affecting the stash list as a side effect. // However, under Git 2.21.1, "git stash create" exits with no output, // no error, and no effect if the working copy contains only untracked // files. For now, accept mutations to the stash list. $api->execxLocal('stash push --include-untracked --'); $log = $this->getWorkflow()->getLogEngine(); $log->writeStatus( pht('SAVE STASH'), pht('Saved uncommitted changes from working copy.')); return true; } protected function restoreStash($stash_ref) { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); $log->writeStatus( pht('LOAD STASH'), pht('Restoring uncommitted changes to working copy.')); // NOTE: Under Git 2.21.1, "git stash apply" does not accept "--". $api->execxLocal('stash apply'); } protected function discardStash($stash_ref) { $api = $this->getRepositoryAPI(); // NOTE: Under Git 2.21.1, "git stash drop" does not accept "--". $api->execxLocal('stash drop'); } private function getDisplayStashRef($stash_ref) { return substr($stash_ref, 0, 12); } - private function getDisplayHash($hash) { - return substr($hash, 0, 12); - } - } diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php index 0bcbd65d..ea24e070 100644 --- a/src/repository/state/ArcanistMercurialLocalState.php +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -1,98 +1,115 @@ localRef; - } - - public function getLocalPath() { - return $this->localPath; - } + private $localBranch; protected function executeSaveLocalState() { $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + // TODO: Both of these can be pulled from "hg arc-ls-markers" more + // efficiently. + + $this->localCommit = $api->getCanonicalRevisionName('.'); - // 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". + list($branch) = $api->execxLocal('branch'); + $this->localBranch = trim($branch); + $log->writeTrace( + pht('SAVE STATE'), + pht( + 'Saving local state (at "%s" on branch "%s").', + $this->getDisplayHash($this->localCommit), + $this->localBranch)); } protected function executeRestoreLocalState() { $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); - // TODO: In Mercurial, we may want to discard commits we've created. - // $repository_api->execxLocal( - // '--config extensions.mq= strip %s', - // $this->onto); + $log->writeStatus( + pht('LOAD STATE'), + pht( + 'Restoring local state (at "%s" on branch "%s").', + $this->getDisplayHash($this->localCommit), + $this->localBranch)); + $api->execxLocal('update -- %s', $this->localCommit); + $api->execxLocal('branch --force -- %s', $this->localBranch); } protected function executeDiscardLocalState() { - // TODO: Fix this. + return; } protected function canStashChanges() { $api = $this->getRepositoryAPI(); return $api->getMercurialFeature('shelve'); } protected function getIgnoreHints() { return array( pht( 'To configure Mercurial to ignore certain files in the working '. 'copy, add them to ".hgignore".'), ); } protected function newRestoreCommandsForDisplay() { - // TODO: Provide this. - return array(); + $commands = array(); + + $commands[] = csprintf( + 'hg update -- %s', + $this->getDisplayHash($this->localCommit)); + + $commands[] = csprintf( + 'hg branch --force -- %s', + $this->localBranch); + + return $commands; } protected function saveStash() { $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); $stash_ref = sprintf( 'arc-%s', Filesystem::readRandomCharacters(12)); $api->execxLocal( '--config extensions.shelve= shelve --unknown --name %s --', $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/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php index 1526d50d..42138456 100644 --- a/src/repository/state/ArcanistRepositoryLocalState.php +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -1,259 +1,263 @@ workflow = $workflow; return $this; } final public function getWorkflow() { return $this->workflow; } final public function setRepositoryAPI(ArcanistRepositoryAPI $api) { $this->repositoryAPI = $api; return $this; } final public function getRepositoryAPI() { return $this->repositoryAPI; } final public function saveLocalState() { $api = $this->getRepositoryAPI(); $working_copy_display = tsprintf( " %s: %s\n", pht('Working Copy'), $api->getPath()); $conflicts = $api->getMergeConflicts(); if ($conflicts) { echo tsprintf( "\n%!\n%W\n\n%s\n", pht('MERGE CONFLICTS'), pht('You have merge conflicts in this working copy.'), $working_copy_display); $lists = array(); $lists[] = $this->newDisplayFileList( pht('Merge conflicts in working copy:'), $conflicts); $this->printFileLists($lists); throw new PhutilArgumentUsageException( pht( 'Resolve merge conflicts before proceeding.')); } $externals = $api->getDirtyExternalChanges(); if ($externals) { $message = pht( '%s submodule(s) have uncommitted or untracked changes:', new PhutilNumber(count($externals))); $prompt = pht( 'Ignore the changes to these %s submodule(s) and continue?', new PhutilNumber(count($externals))); $list = id(new PhutilConsoleList()) ->setWrap(false) ->addItems($externals); id(new PhutilConsoleBlock()) ->addParagraph($message) ->addList($list) ->draw(); $ok = phutil_console_confirm($prompt, $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } $uncommitted = $api->getUncommittedChanges(); $unstaged = $api->getUnstagedChanges(); $untracked = $api->getUntrackedChanges(); // We already dealt with externals. $unstaged = array_diff($unstaged, $externals); // We only want files which are purely uncommitted. $uncommitted = array_diff($uncommitted, $unstaged); $uncommitted = array_diff($uncommitted, $externals); if ($untracked || $unstaged || $uncommitted) { echo tsprintf( "\n%!\n%W\n\n%s\n", pht('UNCOMMITTED CHANGES'), pht('You have uncommitted changes in this working copy.'), $working_copy_display); $lists = array(); $lists[] = $this->newDisplayFileList( pht('Untracked changes in working copy:'), $untracked); $lists[] = $this->newDisplayFileList( pht('Unstaged changes in working copy:'), $unstaged); $lists[] = $this->newDisplayFileList( pht('Uncommitted changes in working copy:'), $uncommitted); $this->printFileLists($lists); if ($untracked) { $hints = $this->getIgnoreHints(); foreach ($hints as $hint) { echo tsprintf("%?\n", $hint); } } if ($this->canStashChanges()) { $query = pht('Stash these changes and continue?'); $this->getWorkflow() ->getPrompt('arc.state.stash') ->setQuery($query) ->execute(); $stash_ref = $this->saveStash(); if ($stash_ref === null) { throw new Exception( pht( 'Expected a non-null return from call to "%s->saveStash()".', get_class($this))); } $this->stashRef = $stash_ref; } else { throw new PhutilArgumentUsageException( pht( 'You can not continue with uncommitted changes. Commit or '. 'discard them before proceeding.')); } } $this->executeSaveLocalState(); $this->shouldRestore = true; // TODO: Detect when we're in the middle of a rebase. // TODO: Detect when we're in the middle of a cherry-pick. return $this; } final public function restoreLocalState() { $this->shouldRestore = false; $this->executeRestoreLocalState(); $this->applyStash(); $this->executeDiscardLocalState(); return $this; } final public function discardLocalState() { $this->shouldRestore = false; $this->applyStash(); $this->executeDiscardLocalState(); return $this; } final public function __destruct() { if ($this->shouldRestore) { $this->restoreLocalState(); } else { $this->discardLocalState(); } } final public function getRestoreCommandsForDisplay() { return $this->newRestoreCommandsForDisplay(); } protected function canStashChanges() { return false; } protected function saveStash() { throw new PhutilMethodNotImplementedException(); } protected function restoreStash($ref) { throw new PhutilMethodNotImplementedException(); } protected function discardStash($ref) { throw new PhutilMethodNotImplementedException(); } private function applyStash() { if ($this->stashRef === null) { return; } $stash_ref = $this->stashRef; $this->stashRef = null; $this->restoreStash($stash_ref); $this->discardStash($stash_ref); } abstract protected function executeSaveLocalState(); abstract protected function executeRestoreLocalState(); abstract protected function executeDiscardLocalState(); abstract protected function newRestoreCommandsForDisplay(); protected function getIgnoreHints() { return array(); } final protected function newDisplayFileList($title, array $files) { if (!$files) { return null; } $items = array(); $items[] = tsprintf("%s\n\n", $title); foreach ($files as $file) { $items[] = tsprintf( " %s\n", $file); } return $items; } final protected function printFileLists(array $lists) { $lists = array_filter($lists); $last_key = last_key($lists); foreach ($lists as $key => $list) { foreach ($list as $item) { echo tsprintf('%B', $item); } if ($key !== $last_key) { echo tsprintf("\n\n"); } } echo tsprintf("\n"); } + final protected function getDisplayHash($hash) { + return substr($hash, 0, 12); + } + } diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php index 8a6f9f17..72f1ae99 100644 --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -1,342 +1,347 @@ newWorkflowInformation() ->setSynopsis(pht('Publish reviewed changes.')) ->addExample(pht('**land** [__options__] -- [__ref__ ...]')) ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('hold') ->setHelp( pht( 'Prepare the changes to be pushed, but do not actually push '. 'them.')), $this->newWorkflowArgument('keep-branches') ->setHelp( pht( 'Keep local branches around after changes are pushed. By '. 'default, local branches are deleted after the changes they '. 'contain are published.')), $this->newWorkflowArgument('onto-remote') ->setParameter('remote-name') ->setHelp(pht('Push to a remote other than the default.')) ->addRelatedConfig('arc.land.onto-remote'), $this->newWorkflowArgument('onto') ->setParameter('branch-name') ->setRepeatable(true) ->addRelatedConfig('arc.land.onto') ->setHelp( array( pht( 'After merging, push changes onto a specified branch.'), pht( 'Specifying this flag multiple times will push to multiple '. 'branches.'), )), $this->newWorkflowArgument('strategy') ->setParameter('strategy-name') ->addRelatedConfig('arc.land.strategy') ->setHelp( array( pht( 'Merge using a particular strategy. Supported strategies are '. '"squash" and "merge".'), pht( 'The "squash" strategy collapses multiple local commits into '. 'a single commit when publishing. It produces a linear '. 'published history (but discards local checkpoint commits). '. 'This is the default strategy.'), pht( 'The "merge" strategy generates a merge commit when publishing '. 'that retains local checkpoint commits (but produces a '. 'nonlinear published history). Select this strategy if you do '. 'not want "arc land" to discard checkpoint commits.'), )), $this->newWorkflowArgument('revision') ->setParameter('revision-identifier') ->setHelp( pht( 'Land a specific revision, rather than determining revisions '. 'automatically from the commits that are landing.')), $this->newWorkflowArgument('preview') ->setHelp( pht( 'Show the changes that will land. Does not modify the working '. 'copy or the remote.')), $this->newWorkflowArgument('into') ->setParameter('commit-ref') ->setHelp( pht( 'Specify the state to merge into. By default, this is the same '. 'as the "onto" ref.')), $this->newWorkflowArgument('into-remote') ->setParameter('remote-name') ->setHelp( pht( 'Specifies the remote to fetch the "into" ref from. By '. 'default, this is the same as the "onto" remote.')), $this->newWorkflowArgument('into-local') ->setHelp( pht( 'Use the local "into" ref state instead of fetching it from '. 'a remote.')), $this->newWorkflowArgument('into-empty') ->setHelp( pht( 'Merge into the empty state instead of an existing state. This '. 'mode is primarily useful when creating a new repository, and '. 'selected automatically if the "onto" ref does not exist and the '. '"into" state is not specified.')), $this->newWorkflowArgument('incremental') ->setHelp( array( pht( 'When landing multiple revisions at once, push and rebase '. 'after each merge completes instead of waiting until all '. 'merges are completed to push.'), pht( 'This is slower than the default behavior and not atomic, '. 'but may make it easier to resolve conflicts and land '. 'complicated changes by allowing you to make progress one '. 'step at a time.'), )), $this->newWorkflowArgument('pick') ->setHelp( pht( 'Land only the changes directly named by arguments, instead '. 'of all reachable ancestors.')), $this->newWorkflowArgument('ref') ->setWildcard(true), ); } protected function newPrompts() { return array( $this->newPrompt('arc.land.large-working-set') ->setDescription( pht( 'Confirms landing more than %s commit(s) in a single operation.', new PhutilNumber($this->getLargeWorkingSetLimit()))), $this->newPrompt('arc.land.confirm') ->setDescription( pht( 'Confirms that the correct changes have been selected to '. 'land.')), $this->newPrompt('arc.land.implicit') ->setDescription( pht( 'Confirms that local commits which are not associated with '. 'a revision have been associated correctly and should land.')), $this->newPrompt('arc.land.unauthored') ->setDescription( pht( 'Confirms that revisions you did not author should land.')), $this->newPrompt('arc.land.changes-planned') ->setDescription( pht( 'Confirms that revisions with changes planned should land.')), $this->newPrompt('arc.land.published') ->setDescription( pht( 'Confirms that revisions that are already published should land.')), $this->newPrompt('arc.land.not-accepted') ->setDescription( pht( 'Confirms that revisions that are not accepted should land.')), $this->newPrompt('arc.land.open-parents') ->setDescription( pht( 'Confirms that revisions with open parent revisions should '. 'land.')), $this->newPrompt('arc.land.failed-builds') ->setDescription( pht( 'Confirms that revisions with failed builds should land.')), $this->newPrompt('arc.land.ongoing-builds') ->setDescription( pht( 'Confirms that revisions with ongoing builds should land.')), + $this->newPrompt('arc.land.create') + ->setDescription( + pht( + 'Confirms that new branches or bookmarks should be created '. + 'in the remote.')), ); } public function getLargeWorkingSetLimit() { return 50; } public function runWorkflow() { $working_copy = $this->getWorkingCopy(); $repository_api = $working_copy->getRepositoryAPI(); $land_engine = $repository_api->getLandEngine(); if (!$land_engine) { throw new PhutilArgumentUsageException( pht( '"arc land" must be run in a Git or Mercurial working copy.')); } $is_incremental = $this->getArgument('incremental'); $source_refs = $this->getArgument('ref'); $onto_remote_arg = $this->getArgument('onto-remote'); $onto_args = $this->getArgument('onto'); $into_remote = $this->getArgument('into-remote'); $into_empty = $this->getArgument('into-empty'); $into_local = $this->getArgument('into-local'); $into = $this->getArgument('into'); $is_preview = $this->getArgument('preview'); $should_hold = $this->getArgument('hold'); $should_keep = $this->getArgument('keep-branches'); $revision = $this->getArgument('revision'); $strategy = $this->getArgument('strategy'); $pick = $this->getArgument('pick'); $land_engine ->setViewer($this->getViewer()) ->setWorkflow($this) ->setLogEngine($this->getLogEngine()) ->setSourceRefs($source_refs) ->setShouldHold($should_hold) ->setShouldKeep($should_keep) ->setStrategyArgument($strategy) ->setShouldPreview($is_preview) ->setOntoRemoteArgument($onto_remote_arg) ->setOntoArguments($onto_args) ->setIntoRemoteArgument($into_remote) ->setIntoEmptyArgument($into_empty) ->setIntoLocalArgument($into_local) ->setIntoArgument($into) ->setPickArgument($pick) ->setIsIncremental($is_incremental) ->setRevisionSymbol($revision); $land_engine->execute(); } }