Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F14912461
D21315.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
212 KB
Referenced Files
None
Subscribers
None
D21315.diff
View Options
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -218,7 +218,7 @@
'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php',
'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php',
'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php',
- 'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php',
+ 'ArcanistGitLandEngine' => 'land/engine/ArcanistGitLandEngine.php',
'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php',
'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php',
'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php',
@@ -288,7 +288,11 @@
'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistKeywordCasingXHPASTLinterRuleTestCase.php',
'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php',
'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase.php',
- 'ArcanistLandEngine' => 'land/ArcanistLandEngine.php',
+ 'ArcanistLandCommit' => 'land/ArcanistLandCommit.php',
+ 'ArcanistLandCommitSet' => 'land/ArcanistLandCommitSet.php',
+ 'ArcanistLandEngine' => 'land/engine/ArcanistLandEngine.php',
+ 'ArcanistLandSymbol' => 'land/ArcanistLandSymbol.php',
+ 'ArcanistLandTarget' => 'land/ArcanistLandTarget.php',
'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php',
'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php',
'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase.php',
@@ -320,9 +324,13 @@
'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php',
'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase.php',
'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php',
+ 'ArcanistMercurialCommitMessageHardpointQuery' => 'query/ArcanistMercurialCommitMessageHardpointQuery.php',
+ 'ArcanistMercurialLandEngine' => 'land/engine/ArcanistMercurialLandEngine.php',
+ 'ArcanistMercurialLocalState' => 'repository/state/ArcanistMercurialLocalState.php',
'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php',
'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php',
'ArcanistMercurialWorkingCopy' => 'workingcopy/ArcanistMercurialWorkingCopy.php',
+ 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php',
'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php',
'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php',
'ArcanistMessageRevisionHardpointQuery' => 'query/ArcanistMessageRevisionHardpointQuery.php',
@@ -524,6 +532,7 @@
'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php',
'ArcanistWorkflowGitHardpointQuery' => 'query/ArcanistWorkflowGitHardpointQuery.php',
'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php',
+ 'ArcanistWorkflowMercurialHardpointQuery' => 'query/ArcanistWorkflowMercurialHardpointQuery.php',
'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php',
'ArcanistWorkingCopyCommitHardpointQuery' => 'query/ArcanistWorkingCopyCommitHardpointQuery.php',
'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php',
@@ -1298,8 +1307,12 @@
'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
+ 'ArcanistLandCommit' => 'Phobject',
+ 'ArcanistLandCommitSet' => 'Phobject',
'ArcanistLandEngine' => 'Phobject',
- 'ArcanistLandWorkflow' => 'ArcanistWorkflow',
+ 'ArcanistLandSymbol' => 'Phobject',
+ 'ArcanistLandTarget' => 'Phobject',
+ 'ArcanistLandWorkflow' => 'ArcanistArcWorkflow',
'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistLesscLinter' => 'ArcanistExternalLinter',
@@ -1330,9 +1343,13 @@
'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI',
+ 'ArcanistMercurialCommitMessageHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery',
+ 'ArcanistMercurialLandEngine' => 'ArcanistLandEngine',
+ 'ArcanistMercurialLocalState' => 'ArcanistRepositoryLocalState',
'ArcanistMercurialParser' => 'Phobject',
'ArcanistMercurialParserTestCase' => 'PhutilTestCase',
'ArcanistMercurialWorkingCopy' => 'ArcanistWorkingCopy',
+ 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery',
'ArcanistMergeConflictLinter' => 'ArcanistLinter',
'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase',
'ArcanistMessageRevisionHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
@@ -1544,6 +1561,7 @@
'ArcanistWorkflowArgument' => 'Phobject',
'ArcanistWorkflowGitHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkflowInformation' => 'Phobject',
+ 'ArcanistWorkflowMercurialHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkingCopy' => 'Phobject',
'ArcanistWorkingCopyCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource',
diff --git a/src/config/arc/ArcanistArcConfigurationEngineExtension.php b/src/config/arc/ArcanistArcConfigurationEngineExtension.php
--- a/src/config/arc/ArcanistArcConfigurationEngineExtension.php
+++ b/src/config/arc/ArcanistArcConfigurationEngineExtension.php
@@ -136,19 +136,15 @@
array(
'origin',
)),
- id(new ArcanistBoolConfigOption())
- ->setKey('history.immutable')
+ id(new ArcanistStringConfigOption())
+ ->setKey('arc.land.strategy')
->setSummary(
pht(
- 'Configure use of history mutation operations like amends '.
- 'and rebases.'))
+ 'Configure a default merge strategy for "arc land".'))
->setHelp(
pht(
- 'If this option is set to "true", Arcanist will treat the '.
- 'repository history as immutable and will never issue '.
- 'commands which rewrite repository history (like amends or '.
- 'rebases). This option defaults to "true" in Mercurial, '.
- '"false" in Git, and has no effect in Subversion.')),
+ 'Specifies the default behavior when "arc land" is run with '.
+ 'no "--strategy" flag.')),
);
}
diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php
deleted file mode 100644
--- a/src/land/ArcanistGitLandEngine.php
+++ /dev/null
@@ -1,913 +0,0 @@
-<?php
-
-final class ArcanistGitLandEngine
- extends ArcanistLandEngine {
-
- private $localRef;
- private $localCommit;
- private $sourceCommit;
- private $mergedRef;
- private $restoreWhenDestroyed;
- private $isGitPerforce;
-
- private function setIsGitPerforce($is_git_perforce) {
- $this->isGitPerforce = $is_git_perforce;
- return $this;
- }
-
- private function getIsGitPerforce() {
- return $this->isGitPerforce;
- }
-
- public function parseArguments() {
- $api = $this->getRepositoryAPI();
-
- $onto = $this->getEngineOnto();
- $this->setTargetOnto($onto);
-
- $remote = $this->getEngineRemote();
-
- $is_pushable = $api->isPushableRemote($remote);
- $is_perforce = $api->isPerforceRemote($remote);
-
- if (!$is_pushable && !$is_perforce) {
- throw new PhutilArgumentUsageException(
- pht(
- 'No pushable remote "%s" exists. Use the "--remote" flag to choose '.
- 'a valid, pushable remote to land changes onto.',
- $remote));
- }
-
- if ($is_perforce) {
- $this->setIsGitPerforce(true);
- $this->writeWarn(
- pht('P4 MODE'),
- pht(
- 'Operating in Git/Perforce mode after selecting a Perforce '.
- 'remote.'));
-
- if (!$this->getShouldSquash()) {
- throw new PhutilArgumentUsageException(
- pht(
- 'Perforce mode does not support the "merge" land strategy. '.
- 'Use the "squash" land strategy when landing to a Perforce '.
- 'remote (you can use "--squash" to select this strategy).'));
- }
- }
-
- $this->setTargetRemote($remote);
- }
-
- public function execute() {
- $this->verifySourceAndTargetExist();
- $this->fetchTarget();
-
- $this->printLandingCommits();
-
- if ($this->getShouldPreview()) {
- $this->writeInfo(
- pht('PREVIEW'),
- pht('Completed preview of operation.'));
- return;
- }
-
- $this->saveLocalState();
-
- try {
- $this->identifyRevision();
- $this->updateWorkingCopy();
-
- if ($this->getShouldHold()) {
- $this->didHoldChanges();
- } else {
- $this->pushChange();
- $this->reconcileLocalState();
-
- $api = $this->getRepositoryAPI();
- $api->execxLocal('submodule update --init --recursive');
-
- if ($this->getShouldKeep()) {
- echo tsprintf(
- "%s\n",
- pht('Keeping local branch.'));
- } else {
- $this->destroyLocalBranch();
- }
-
- $this->writeOkay(
- pht('DONE'),
- pht('Landed changes.'));
- }
-
- $this->restoreWhenDestroyed = false;
- } catch (Exception $ex) {
- $this->restoreLocalState();
- throw $ex;
- }
- }
-
- public function __destruct() {
- if ($this->restoreWhenDestroyed) {
- $this->writeWarn(
- pht('INTERRUPTED!'),
- pht('Restoring working copy to its original state.'));
-
- $this->restoreLocalState();
- }
- }
-
- protected function getLandingCommits() {
- $api = $this->getRepositoryAPI();
-
- list($out) = $api->execxLocal(
- 'log --oneline %s..%s --',
- $this->getTargetFullRef(),
- $this->sourceCommit);
-
- $out = trim($out);
-
- if (!strlen($out)) {
- return array();
- } else {
- return phutil_split_lines($out, false);
- }
- }
-
- private function identifyRevision() {
- $api = $this->getRepositoryAPI();
- $api->execxLocal('checkout %s --', $this->getSourceRef());
- call_user_func($this->getBuildMessageCallback(), $this);
- }
-
- private function verifySourceAndTargetExist() {
- $api = $this->getRepositoryAPI();
-
- list($err) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $this->getTargetFullRef());
-
- if ($err) {
- $this->writeWarn(
- pht('TARGET'),
- pht(
- 'No local ref exists for branch "%s" in remote "%s", attempting '.
- 'fetch...',
- $this->getTargetOnto(),
- $this->getTargetRemote()));
-
- $api->execManualLocal(
- 'fetch %s %s --',
- $this->getTargetRemote(),
- $this->getTargetOnto());
-
- list($err) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $this->getTargetFullRef());
- if ($err) {
- throw new Exception(
- pht(
- 'Branch "%s" does not exist in remote "%s".',
- $this->getTargetOnto(),
- $this->getTargetRemote()));
- }
-
- $this->writeInfo(
- pht('FETCHED'),
- pht(
- 'Fetched branch "%s" from remote "%s".',
- $this->getTargetOnto(),
- $this->getTargetRemote()));
- }
-
- list($err, $stdout) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $this->getSourceRef());
-
- if ($err) {
- throw new Exception(
- pht(
- 'Branch "%s" does not exist in the local working copy.',
- $this->getSourceRef()));
- }
-
- $this->sourceCommit = trim($stdout);
- }
-
- private function fetchTarget() {
- $api = $this->getRepositoryAPI();
-
- $ref = $this->getTargetFullRef();
-
- // NOTE: Although this output isn't hugely useful, we need to passthru
- // instead of using a subprocess here because `git fetch` may prompt the
- // user to enter a password if they're fetching over HTTP with basic
- // authentication. See T10314.
-
- if ($this->getIsGitPerforce()) {
- $this->writeInfo(
- pht('P4 SYNC'),
- pht('Synchronizing "%s" from Perforce...', $ref));
-
- $sync_ref = sprintf(
- 'refs/remotes/%s/%s',
- $this->getTargetRemote(),
- $this->getTargetOnto());
-
- $err = $api->execPassthru(
- 'p4 sync --silent --branch %R --',
- $sync_ref);
-
- if ($err) {
- throw new ArcanistUsageException(
- pht(
- 'Perforce sync failed! Fix the error and run "arc land" again.'));
- }
- } else {
- $this->writeInfo(
- pht('FETCH'),
- pht('Fetching "%s"...', $ref));
-
- $err = $api->execPassthru(
- 'fetch --quiet -- %s %s',
- $this->getTargetRemote(),
- $this->getTargetOnto());
-
- if ($err) {
- throw new ArcanistUsageException(
- pht(
- 'Fetch failed! Fix the error and run "arc land" again.'));
- }
- }
- }
-
- private function updateWorkingCopy() {
- $api = $this->getRepositoryAPI();
- $source = $this->sourceCommit;
-
- $api->execxLocal(
- 'checkout %s --',
- $this->getTargetFullRef());
-
- list($original_author, $original_date) = $this->getAuthorAndDate($source);
-
- try {
- if ($this->getShouldSquash()) {
- // NOTE: We're explicitly specifying "--ff" to override the presence
- // of "merge.ff" options in user configuration.
-
- $api->execxLocal(
- 'merge --no-stat --no-commit --ff --squash -- %s',
- $source);
- } else {
- $api->execxLocal(
- 'merge --no-stat --no-commit --no-ff -- %s',
- $source);
- }
- } catch (Exception $ex) {
- $api->execManualLocal('merge --abort');
- $api->execManualLocal('reset --hard HEAD --');
-
- throw new Exception(
- pht(
- 'Local "%s" does not merge cleanly into "%s". Merge or rebase '.
- 'local changes so they can merge cleanly.',
- $this->getSourceRef(),
- $this->getTargetFullRef()));
- }
-
- // TODO: This could probably be cleaner by asking the API a question
- // about working copy status instead of running a raw diff command. See
- // discussion in T11435.
- list($changes) = $api->execxLocal('diff --no-ext-diff HEAD --');
- $changes = trim($changes);
- if (!strlen($changes)) {
- throw new Exception(
- pht(
- 'Merging local "%s" into "%s" produces an empty diff. '.
- 'This usually means these changes have already landed.',
- $this->getSourceRef(),
- $this->getTargetFullRef()));
- }
-
- $api->execxLocal(
- 'commit --author %s --date %s -F %s --',
- $original_author,
- $original_date,
- $this->getCommitMessageFile());
-
- $this->getWorkflow()->didCommitMerge();
-
- list($stdout) = $api->execxLocal(
- 'rev-parse --verify %s',
- 'HEAD');
- $this->mergedRef = trim($stdout);
- }
-
- private function pushChange() {
- $api = $this->getRepositoryAPI();
-
- if ($this->getIsGitPerforce()) {
- $this->writeInfo(
- pht('SUBMITTING'),
- pht('Submitting changes to "%s".', $this->getTargetFullRef()));
-
- $config_argv = array();
-
- // Skip the "git p4 submit" interactive editor workflow. We expect
- // the commit message that "arc land" has built to be satisfactory.
- $config_argv[] = '-c';
- $config_argv[] = 'git-p4.skipSubmitEdit=true';
-
- // Skip the "git p4 submit" confirmation prompt if the user does not edit
- // the submit message.
- $config_argv[] = '-c';
- $config_argv[] = 'git-p4.skipSubmitEditCheck=true';
-
- $flags_argv = array();
-
- // Disable implicit "git p4 rebase" as part of submit. We're allowing
- // the implicit "git p4 sync" to go through since this puts us in a
- // state which is generally similar to the state after "git push", with
- // updated remotes.
-
- // We could do a manual "git p4 sync" with a more narrow "--branch"
- // instead, but it's not clear that this is beneficial.
- $flags_argv[] = '--disable-rebase';
-
- // Detect moves and submit them to Perforce as move operations.
- $flags_argv[] = '-M';
-
- // If we run into a conflict, abort the operation. We expect users to
- // fix conflicts and run "arc land" again.
- $flags_argv[] = '--conflict=quit';
-
- $err = $api->execPassthru(
- '%LR p4 submit %LR --commit %R --',
- $config_argv,
- $flags_argv,
- $this->mergedRef);
-
- if ($err) {
- throw new ArcanistUsageException(
- pht(
- 'Submit failed! Fix the error and run "arc land" again.'));
- }
- } else {
- $this->writeInfo(
- pht('PUSHING'),
- pht('Pushing changes to "%s".', $this->getTargetFullRef()));
-
- $err = $api->execPassthru(
- 'push -- %s %s:%s',
- $this->getTargetRemote(),
- $this->mergedRef,
- $this->getTargetOnto());
-
- if ($err) {
- throw new ArcanistUsageException(
- pht(
- 'Push failed! Fix the error and run "arc land" again.'));
- }
- }
- }
-
- private function reconcileLocalState() {
- $api = $this->getRepositoryAPI();
-
- // Try to put the user into the best final state we can. This is very
- // complicated because users are incredibly creative and their local
- // branches may have the same names as branches in the remote but no
- // relationship to them.
-
- if ($this->localRef != $this->getSourceRef()) {
- // The user ran `arc land X` but was on a different branch, so just put
- // them back wherever they were before.
- $this->writeInfo(
- pht('RESTORE'),
- pht('Switching back to "%s".', $this->localRef));
- $this->restoreLocalState();
- return;
- }
-
- // We're going to try to find a path to the upstream target branch. We
- // try in two different ways:
- //
- // - follow the source branch directly along tracking branches until
- // we reach the upstream; or
- // - follow a local branch with the same name as the target branch until
- // we reach the upstream.
-
- // First, get the path from whatever we landed to wherever it goes.
- $local_branch = $this->getSourceRef();
-
- $path = $api->getPathToUpstream($local_branch);
- if ($path->getLength()) {
- // We may want to discard the thing we landed from the path, if we're
- // going to delete it. In this case, we don't want to update it or worry
- // if it's dirty.
- if ($this->getSourceRef() == $this->getTargetOnto()) {
- // In this case, we've done something like land "master" onto itself,
- // so we do want to update the actual branch. We're going to use the
- // entire path.
- } else {
- // Otherwise, we're going to delete the branch at the end of the
- // workflow, so throw it away the most-local branch that isn't long
- // for this world.
- $path->removeUpstream($local_branch);
-
- if (!$path->getLength()) {
- // The local branch tracked upstream directly; however, it
- // may not be the only one to do so. If there's a local
- // branch of the same name that tracks the remote, try
- // switching to that.
- $local_branch = $this->getTargetOnto();
- list($err) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $local_branch);
- if (!$err) {
- $path = $api->getPathToUpstream($local_branch);
- }
- if (!$path->isConnectedToRemote()) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local branch "%s" directly tracks remote, staying on '.
- 'detached HEAD.',
- $local_branch));
- return;
- }
- }
-
- $local_branch = head($path->getLocalBranches());
- }
- } else {
- // The source branch has no upstream, so look for a local branch with
- // the same name as the target branch. This corresponds to the common
- // case where you have "master" and checkout local branches from it
- // with "git checkout -b feature", then land onto "master".
-
- $local_branch = $this->getTargetOnto();
-
- list($err) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $local_branch);
- if ($err) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local branch "%s" does not exist, staying on detached HEAD.',
- $local_branch));
- return;
- }
-
- $path = $api->getPathToUpstream($local_branch);
- }
-
- if ($path->getCycle()) {
- $this->writeWarn(
- pht('LOCAL CYCLE'),
- pht(
- 'Local branch "%s" tracks an upstream but following it leads to '.
- 'a local cycle, staying on detached HEAD.',
- $local_branch));
- return;
- }
-
- $is_perforce = $this->getIsGitPerforce();
-
- if ($is_perforce) {
- // If we're in Perforce mode, we don't expect to have a meaningful
- // path to the remote: the "p4" remote is not a real remote, and
- // "git p4" commands do not configure branch upstreams to provide
- // a path.
-
- // Just pretend the target branch is connected directly to the remote,
- // since this is effectively the behavior of Perforce and appears to
- // do the right thing.
- $cascade_branches = array($local_branch);
- } else {
- if (!$path->isConnectedToRemote()) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local branch "%s" is not connected to a remote, staying on '.
- 'detached HEAD.',
- $local_branch));
- return;
- }
-
- $remote_remote = $path->getRemoteRemoteName();
- $remote_branch = $path->getRemoteBranchName();
-
- $remote_actual = $remote_remote.'/'.$remote_branch;
- $remote_expect = $this->getTargetFullRef();
- if ($remote_actual != $remote_expect) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local branch "%s" is connected to a remote ("%s") other than '.
- 'the target remote ("%s"), staying on detached HEAD.',
- $local_branch,
- $remote_actual,
- $remote_expect));
- return;
- }
-
- // If we get this far, we have a sequence of branches which ultimately
- // connect to the remote. We're going to try to update them all in reverse
- // order, from most-upstream to most-local.
-
- $cascade_branches = $path->getLocalBranches();
- $cascade_branches = array_reverse($cascade_branches);
- }
-
- // First, check if any of them are ahead of the remote.
-
- $ahead_of_remote = array();
- foreach ($cascade_branches as $cascade_branch) {
- list($stdout) = $api->execxLocal(
- 'log %s..%s --',
- $this->mergedRef,
- $cascade_branch);
- $stdout = trim($stdout);
-
- if (strlen($stdout)) {
- $ahead_of_remote[$cascade_branch] = $cascade_branch;
- }
- }
-
- // We're going to handle the last branch (the thing we ultimately intend
- // to check out) differently. It's OK if it's ahead of the remote, as long
- // as we just landed it.
-
- $local_ahead = isset($ahead_of_remote[$local_branch]);
- unset($ahead_of_remote[$local_branch]);
- $land_self = ($this->getTargetOnto() === $this->getSourceRef());
-
- // We aren't going to pull anything if anything upstream from us is ahead
- // of the remote, or the local is ahead of the remote and we didn't land
- // it onto itself.
- $skip_pull = ($ahead_of_remote || ($local_ahead && !$land_self));
-
- if ($skip_pull) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local "%s" is ahead of remote "%s". Checking out "%s" but '.
- 'not pulling changes.',
- nonempty(head($ahead_of_remote), $local_branch),
- $this->getTargetFullRef(),
- $local_branch));
-
- $this->writeInfo(
- pht('CHECKOUT'),
- pht(
- 'Checking out "%s".',
- $local_branch));
-
- $api->execxLocal('checkout %s --', $local_branch);
-
- return;
- }
-
- // If nothing upstream from our nearest branch is ahead of the remote,
- // pull it all.
-
- $cascade_targets = array();
- if (!$ahead_of_remote) {
- foreach ($cascade_branches as $cascade_branch) {
- if ($local_ahead && ($local_branch == $cascade_branch)) {
- continue;
- }
- $cascade_targets[] = $cascade_branch;
- }
- }
-
- if ($is_perforce) {
- // In Perforce, we've already set the remote to the right state with an
- // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a
- // meaningful operation. We're going to skip this step and jump down to
- // the "git reset --hard" below to get everything into the right state.
- } else if ($cascade_targets) {
- $this->writeInfo(
- pht('UPDATE'),
- pht(
- 'Local "%s" tracks target remote "%s", checking out and '.
- 'pulling changes.',
- $local_branch,
- $this->getTargetFullRef()));
-
- foreach ($cascade_targets as $cascade_branch) {
- $this->writeInfo(
- pht('PULL'),
- pht(
- 'Checking out and pulling "%s".',
- $cascade_branch));
-
- $api->execxLocal('checkout %s --', $cascade_branch);
- $api->execxLocal(
- 'pull %s %s --',
- $this->getTargetRemote(),
- $cascade_branch);
- }
-
- if (!$local_ahead) {
- return;
- }
- }
-
- // In this case, the user did something like land a branch onto itself,
- // and the branch is tracking the correct remote. We're going to discard
- // the local state and reset it to the state we just pushed.
-
- $this->writeInfo(
- pht('RESET'),
- pht(
- 'Local "%s" landed into remote "%s", resetting local branch to '.
- 'remote state.',
- $this->getTargetOnto(),
- $this->getTargetFullRef()));
-
- $api->execxLocal('checkout %s --', $local_branch);
- $api->execxLocal('reset --hard %s --', $this->getTargetFullRef());
-
- return;
- }
-
- private function destroyLocalBranch() {
- $api = $this->getRepositoryAPI();
- $source_ref = $this->getSourceRef();
-
- if ($source_ref == $this->getTargetOnto()) {
- // If we landed a branch into a branch with the same name, so don't
- // destroy it. This prevents us from cleaning up "master" if you're
- // landing master into itself.
- return;
- }
-
- // TODO: Maybe this should also recover the proper upstream?
-
- // See T10321. If we were not landing a branch, don't try to clean it up.
- // This happens most often when landing from a detached HEAD.
- $is_branch = $this->isBranch($source_ref);
- if (!$is_branch) {
- echo tsprintf(
- "%s\n",
- pht(
- '(Source "%s" is not a branch, leaving working copy as-is.)',
- $source_ref));
- return;
- }
-
- $recovery_command = csprintf(
- 'git checkout -b %R %R',
- $source_ref,
- $this->sourceCommit);
-
- echo tsprintf(
- "%s\n",
- pht('Cleaning up branch "%s"...', $source_ref));
-
- echo tsprintf(
- "%s\n",
- pht('(Use `%s` if you want it back.)', $recovery_command));
-
- $api->execxLocal('branch -D -- %s', $source_ref);
- }
-
- /**
- * Save the local working copy state so we can restore it later.
- */
- private function saveLocalState() {
- $api = $this->getRepositoryAPI();
-
- $this->localCommit = $api->getWorkingCopyRevision();
-
- list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD');
- $ref = trim($ref);
- if ($ref === 'HEAD') {
- $ref = $this->localCommit;
- }
-
- $this->localRef = $ref;
-
- $this->restoreWhenDestroyed = true;
- }
-
- /**
- * Restore the working copy to the state it was in before we started
- * performing writes.
- */
- private function restoreLocalState() {
- $api = $this->getRepositoryAPI();
-
- $api->execxLocal('checkout %s --', $this->localRef);
- $api->execxLocal('reset --hard %s --', $this->localCommit);
- $api->execxLocal('submodule update --init --recursive');
-
- $this->restoreWhenDestroyed = false;
- }
-
- private function getTargetFullRef() {
- return $this->getTargetRemote().'/'.$this->getTargetOnto();
- }
-
- private function getAuthorAndDate($commit) {
- $api = $this->getRepositoryAPI();
-
- // TODO: This is working around Windows escaping problems, see T8298.
-
- list($info) = $api->execxLocal(
- 'log -n1 --format=%C %s --',
- '%aD%n%an%n%ae',
- $commit);
-
- $info = trim($info);
- list($date, $author, $email) = explode("\n", $info, 3);
-
- return array(
- "$author <{$email}>",
- $date,
- );
- }
-
- private function didHoldChanges() {
- if ($this->getIsGitPerforce()) {
- $this->writeInfo(
- pht('HOLD'),
- pht(
- 'Holding change locally, it has not been submitted.'));
-
- $push_command = csprintf(
- '$ git p4 submit -M --commit %R --',
- $this->mergedRef);
- } else {
- $this->writeInfo(
- pht('HOLD'),
- pht(
- 'Holding change locally, it has not been pushed.'));
-
- $push_command = csprintf(
- '$ git push -- %R %R:%R',
- $this->getTargetRemote(),
- $this->mergedRef,
- $this->getTargetOnto());
- }
-
- $restore_command = csprintf(
- '$ git checkout %R --',
- $this->localRef);
-
- echo tsprintf(
- "\n%s\n\n".
- "%s\n\n".
- " **%s**\n\n".
- "%s\n\n".
- " **%s**\n\n".
- "%s\n",
- pht(
- 'This local working copy now contains the merged changes in a '.
- 'detached state.'),
- pht('You can push the changes manually with this command:'),
- $push_command,
- pht(
- 'You can go back to how things were before you ran "arc land" with '.
- 'this command:'),
- $restore_command,
- pht(
- 'Local branches have not been changed, and are still in exactly the '.
- 'same state as before.'));
- }
-
- private function isBranch($ref) {
- $api = $this->getRepositoryAPI();
-
- list($err) = $api->execManualLocal(
- 'show-ref --verify --quiet -- %R',
- 'refs/heads/'.$ref);
-
- return !$err;
- }
-
- private function getEngineOnto() {
- $source_ref = $this->getSourceRef();
-
- $onto = $this->getOntoArgument();
- if ($onto !== null) {
- $this->writeInfo(
- pht('TARGET'),
- pht(
- 'Landing onto "%s", selected with the "--onto" flag.',
- $onto));
- return $onto;
- }
-
- $api = $this->getRepositoryAPI();
- $path = $api->getPathToUpstream($source_ref);
-
- if ($path->getLength()) {
- $cycle = $path->getCycle();
- if ($cycle) {
- $this->writeWarn(
- pht('LOCAL CYCLE'),
- pht(
- 'Local branch tracks an upstream, but following it leads to a '.
- 'local cycle; ignoring branch upstream.'));
-
- echo tsprintf(
- "\n %s\n\n",
- implode(' -> ', $cycle));
-
- } else {
- if ($path->isConnectedToRemote()) {
- $onto = $path->getRemoteBranchName();
- $this->writeInfo(
- pht('TARGET'),
- pht(
- 'Landing onto "%s", selected by following tracking branches '.
- 'upstream to the closest remote.',
- $onto));
- return $onto;
- } else {
- $this->writeInfo(
- pht('NO PATH TO UPSTREAM'),
- pht(
- 'Local branch tracks an upstream, but there is no path '.
- 'to a remote; ignoring branch upstream.'));
- }
- }
- }
-
- $workflow = $this->getWorkflow();
-
- $config_key = 'arc.land.onto.default';
- $onto = $workflow->getConfigFromAnySource($config_key);
- if ($onto !== null) {
- $this->writeInfo(
- pht('TARGET'),
- pht(
- 'Landing onto "%s", selected by "%s" configuration.',
- $onto,
- $config_key));
- return $onto;
- }
-
- $onto = 'master';
- $this->writeInfo(
- pht('TARGET'),
- pht(
- 'Landing onto "%s", the default target under git.',
- $onto));
-
- return $onto;
- }
-
- private function getEngineRemote() {
- $source_ref = $this->getSourceRef();
-
- $remote = $this->getRemoteArgument();
- if ($remote !== null) {
- $this->writeInfo(
- pht('REMOTE'),
- pht(
- 'Using remote "%s", selected with the "--remote" flag.',
- $remote));
- return $remote;
- }
-
- $api = $this->getRepositoryAPI();
- $path = $api->getPathToUpstream($source_ref);
-
- $remote = $path->getRemoteRemoteName();
- if ($remote !== null) {
- $this->writeInfo(
- pht('REMOTE'),
- pht(
- 'Using remote "%s", selected by following tracking branches '.
- 'upstream to the closest remote.',
- $remote));
- return $remote;
- }
-
- $remote = 'p4';
- if ($api->isPerforceRemote($remote)) {
- $this->writeInfo(
- pht('REMOTE'),
- pht(
- 'Using Perforce remote "%s". The existence of this remote implies '.
- 'this working copy was synchronized from a Perforce repository.',
- $remote));
- return $remote;
- }
-
- $remote = 'origin';
- $this->writeInfo(
- pht('REMOTE'),
- pht(
- 'Using remote "%s", the default remote under Git.',
- $remote));
-
- return $remote;
- }
-
-}
diff --git a/src/land/ArcanistLandCommit.php b/src/land/ArcanistLandCommit.php
new file mode 100644
--- /dev/null
+++ b/src/land/ArcanistLandCommit.php
@@ -0,0 +1,149 @@
+<?php
+
+final class ArcanistLandCommit
+ extends Phobject {
+
+ private $hash;
+ private $summary;
+ private $displaySummary;
+ private $parents;
+ private $symbols = array();
+ private $explicitRevisionRef;
+ private $revisionRef = false;
+ private $parentCommits;
+ private $isHeadCommit;
+ private $isImplicitCommit;
+
+ public function setHash($hash) {
+ $this->hash = $hash;
+ return $this;
+ }
+
+ public function getHash() {
+ return $this->hash;
+ }
+
+ public function setSummary($summary) {
+ $this->summary = $summary;
+ return $this;
+ }
+
+ public function getSummary() {
+ return $this->summary;
+ }
+
+ public function getDisplaySummary() {
+ if ($this->displaySummary === null) {
+ $this->displaySummary = id(new PhutilUTF8StringTruncator())
+ ->setMaximumGlyphs(64)
+ ->truncateString($this->getSummary());
+ }
+ return $this->displaySummary;
+ }
+
+ public function setParents(array $parents) {
+ $this->parents = $parents;
+ return $this;
+ }
+
+ public function getParents() {
+ return $this->parents;
+ }
+
+ public function addSymbol(ArcanistLandSymbol $symbol) {
+ $this->symbols[] = $symbol;
+ return $this;
+ }
+
+ public function getSymbols() {
+ return $this->symbols;
+ }
+
+ public function setExplicitRevisionref(ArcanistRevisionRef $ref) {
+ $this->explicitRevisionRef = $ref;
+ return $this;
+ }
+
+ public function getExplicitRevisionref() {
+ return $this->explicitRevisionRef;
+ }
+
+ public function setParentCommits(array $parent_commits) {
+ $this->parentCommits = $parent_commits;
+ return $this;
+ }
+
+ public function getParentCommits() {
+ return $this->parentCommits;
+ }
+
+ public function setIsHeadCommit($is_head_commit) {
+ $this->isHeadCommit = $is_head_commit;
+ return $this;
+ }
+
+ public function getIsHeadCommit() {
+ return $this->isHeadCommit;
+ }
+
+ public function setIsImplicitCommit($is_implicit_commit) {
+ $this->isImplicitCommit = $is_implicit_commit;
+ return $this;
+ }
+
+ public function getIsImplicitCommit() {
+ return $this->isImplicitCommit;
+ }
+
+ public function getAncestorRevisionPHIDs() {
+ $phids = array();
+
+ foreach ($this->getParentCommits() as $parent_commit) {
+ $phids += $parent_commit->getAncestorRevisionPHIDs();
+ }
+
+ $revision_ref = $this->getRevisionRef();
+ if ($revision_ref) {
+ $phids[$revision_ref->getPHID()] = $revision_ref->getPHID();
+ }
+
+ return $phids;
+ }
+
+ public function getRevisionRef() {
+ if ($this->revisionRef === false) {
+ $this->revisionRef = $this->newRevisionRef();
+ }
+
+ return $this->revisionRef;
+ }
+
+ private function newRevisionRef() {
+ $revision_ref = $this->getExplicitRevisionRef();
+ if ($revision_ref) {
+ return $revision_ref;
+ }
+
+ $parent_refs = array();
+ foreach ($this->getParentCommits() as $parent_commit) {
+ $parent_ref = $parent_commit->getRevisionRef();
+ if ($parent_ref) {
+ $parent_refs[$parent_ref->getPHID()] = $parent_ref;
+ }
+ }
+
+ if (count($parent_refs) > 1) {
+ throw new Exception(
+ pht(
+ 'Too many distinct parent refs!'));
+ }
+
+ if ($parent_refs) {
+ return head($parent_refs);
+ }
+
+ return null;
+ }
+
+
+}
diff --git a/src/land/ArcanistLandCommitSet.php b/src/land/ArcanistLandCommitSet.php
new file mode 100644
--- /dev/null
+++ b/src/land/ArcanistLandCommitSet.php
@@ -0,0 +1,52 @@
+<?php
+
+final class ArcanistLandCommitSet
+ extends Phobject {
+
+ private $revisionRef;
+ private $commits;
+
+ public function setRevisionRef(ArcanistRevisionRef $revision_ref) {
+ $this->revisionRef = $revision_ref;
+ return $this;
+ }
+
+ public function getRevisionRef() {
+ return $this->revisionRef;
+ }
+
+ public function setCommits(array $commits) {
+ assert_instances_of($commits, 'ArcanistLandCommit');
+ $this->commits = $commits;
+
+ $revision_phid = $this->getRevisionRef()->getPHID();
+ foreach ($commits as $commit) {
+ $revision_ref = $commit->getExplicitRevisionRef();
+
+ if ($revision_ref) {
+ if ($revision_ref->getPHID() === $revision_phid) {
+ continue;
+ }
+ }
+
+ $commit->setIsImplicitCommit(true);
+ }
+
+ return $this;
+ }
+
+ public function getCommits() {
+ return $this->commits;
+ }
+
+ public function hasImplicitCommits() {
+ foreach ($this->commits as $commit) {
+ if ($commit->getIsImplicitCommit()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/land/ArcanistLandEngine.php b/src/land/ArcanistLandEngine.php
deleted file mode 100644
--- a/src/land/ArcanistLandEngine.php
+++ /dev/null
@@ -1,182 +0,0 @@
-<?php
-
-abstract class ArcanistLandEngine extends Phobject {
-
- private $workflow;
- private $repositoryAPI;
- private $targetRemote;
- private $targetOnto;
- private $sourceRef;
- private $commitMessageFile;
- private $shouldHold;
- private $shouldKeep;
- private $shouldSquash;
- private $shouldDeleteRemote;
- private $shouldPreview;
- private $remoteArgument;
- private $ontoArgument;
-
- // TODO: This is really grotesque.
- private $buildMessageCallback;
-
- final public function setWorkflow(ArcanistWorkflow $workflow) {
- $this->workflow = $workflow;
- return $this;
- }
-
- final public function getWorkflow() {
- return $this->workflow;
- }
-
- final public function setRepositoryAPI(
- ArcanistRepositoryAPI $repository_api) {
- $this->repositoryAPI = $repository_api;
- return $this;
- }
-
- final public function getRepositoryAPI() {
- return $this->repositoryAPI;
- }
-
- final public function setShouldHold($should_hold) {
- $this->shouldHold = $should_hold;
- return $this;
- }
-
- final public function getShouldHold() {
- return $this->shouldHold;
- }
-
- final public function setShouldKeep($should_keep) {
- $this->shouldKeep = $should_keep;
- return $this;
- }
-
- final public function getShouldKeep() {
- return $this->shouldKeep;
- }
-
- final public function setShouldSquash($should_squash) {
- $this->shouldSquash = $should_squash;
- return $this;
- }
-
- final public function getShouldSquash() {
- return $this->shouldSquash;
- }
-
- final public function setShouldPreview($should_preview) {
- $this->shouldPreview = $should_preview;
- return $this;
- }
-
- final public function getShouldPreview() {
- return $this->shouldPreview;
- }
-
- final public function setTargetRemote($target_remote) {
- $this->targetRemote = $target_remote;
- return $this;
- }
-
- final public function getTargetRemote() {
- return $this->targetRemote;
- }
-
- final public function setTargetOnto($target_onto) {
- $this->targetOnto = $target_onto;
- return $this;
- }
-
- final public function getTargetOnto() {
- return $this->targetOnto;
- }
-
- final public function setSourceRef($source_ref) {
- $this->sourceRef = $source_ref;
- return $this;
- }
-
- final public function getSourceRef() {
- return $this->sourceRef;
- }
-
- final public function setBuildMessageCallback($build_message_callback) {
- $this->buildMessageCallback = $build_message_callback;
- return $this;
- }
-
- final public function getBuildMessageCallback() {
- return $this->buildMessageCallback;
- }
-
- final public function setCommitMessageFile($commit_message_file) {
- $this->commitMessageFile = $commit_message_file;
- return $this;
- }
-
- final public function getCommitMessageFile() {
- return $this->commitMessageFile;
- }
-
- final public function setRemoteArgument($remote_argument) {
- $this->remoteArgument = $remote_argument;
- return $this;
- }
-
- final public function getRemoteArgument() {
- return $this->remoteArgument;
- }
-
- final public function setOntoArgument($onto_argument) {
- $this->ontoArgument = $onto_argument;
- return $this;
- }
-
- final public function getOntoArgument() {
- return $this->ontoArgument;
- }
-
- abstract public function parseArguments();
- abstract public function execute();
-
- abstract protected function getLandingCommits();
-
- protected function printLandingCommits() {
- $logs = $this->getLandingCommits();
-
- if (!$logs) {
- throw new ArcanistUsageException(
- pht(
- 'There are no commits on "%s" which are not already present on '.
- 'the target.',
- $this->getSourceRef()));
- }
-
- $list = id(new PhutilConsoleList())
- ->setWrap(false)
- ->addItems($logs);
-
- id(new PhutilConsoleBlock())
- ->addParagraph(
- pht(
- 'These %s commit(s) will be landed:',
- new PhutilNumber(count($logs))))
- ->addList($list)
- ->draw();
- }
-
- protected function writeWarn($title, $message) {
- return $this->getWorkflow()->writeWarn($title, $message);
- }
-
- protected function writeInfo($title, $message) {
- return $this->getWorkflow()->writeInfo($title, $message);
- }
-
- protected function writeOkay($title, $message) {
- return $this->getWorkflow()->writeOkay($title, $message);
- }
-
-
-}
diff --git a/src/land/ArcanistLandSymbol.php b/src/land/ArcanistLandSymbol.php
new file mode 100644
--- /dev/null
+++ b/src/land/ArcanistLandSymbol.php
@@ -0,0 +1,27 @@
+<?php
+
+final class ArcanistLandSymbol
+ extends Phobject {
+
+ private $symbol;
+ private $commit;
+
+ public function setSymbol($symbol) {
+ $this->symbol = $symbol;
+ return $this;
+ }
+
+ public function getSymbol() {
+ return $this->symbol;
+ }
+
+ public function setCommit($commit) {
+ $this->commit = $commit;
+ return $this;
+ }
+
+ public function getCommit() {
+ return $this->commit;
+ }
+
+}
diff --git a/src/land/ArcanistLandTarget.php b/src/land/ArcanistLandTarget.php
new file mode 100644
--- /dev/null
+++ b/src/land/ArcanistLandTarget.php
@@ -0,0 +1,41 @@
+<?php
+
+final class ArcanistLandTarget
+ extends Phobject {
+
+ private $remote;
+ private $ref;
+ private $commit;
+
+ public function setRemote($remote) {
+ $this->remote = $remote;
+ return $this;
+ }
+
+ public function getRemote() {
+ return $this->remote;
+ }
+
+ public function setRef($ref) {
+ $this->ref = $ref;
+ return $this;
+ }
+
+ public function getRef() {
+ return $this->ref;
+ }
+
+ public function getLandTargetKey() {
+ return sprintf('%s/%s', $this->getRemote(), $this->getRef());
+ }
+
+ public function setLandTargetCommit($commit) {
+ $this->commit = $commit;
+ return $this;
+ }
+
+ public function getLandTargetCommit() {
+ return $this->commit;
+ }
+
+}
diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php
new file mode 100644
--- /dev/null
+++ b/src/land/engine/ArcanistGitLandEngine.php
@@ -0,0 +1,1362 @@
+<?php
+
+final class ArcanistGitLandEngine
+ extends ArcanistLandEngine {
+
+ private $isGitPerforce;
+ private $landTargetCommitMap = array();
+ private $deletedBranches = array();
+
+ private function setIsGitPerforce($is_git_perforce) {
+ $this->isGitPerforce = $is_git_perforce;
+ return $this;
+ }
+
+ private function getIsGitPerforce() {
+ return $this->isGitPerforce;
+ }
+
+ protected function pruneBranches(array $sets) {
+ $old_commits = array();
+ foreach ($sets as $set) {
+ $hash = last($set->getCommits())->getHash();
+ $old_commits[] = $hash;
+ }
+
+ $branch_map = $this->getBranchesForCommits(
+ $old_commits,
+ $is_contains = false);
+
+ $api = $this->getRepositoryAPI();
+ foreach ($branch_map as $branch_name => $branch_hash) {
+ $recovery_command = csprintf(
+ 'git checkout -b %s %s',
+ $branch_name,
+ $this->getDisplayHash($branch_hash));
+
+ echo tsprintf(
+ "%s\n",
+ pht('Cleaning up branch "%s"...', $branch_name));
+
+ echo tsprintf(
+ "%s\n",
+ pht('(Use `%s` if you want it back.)', $recovery_command));
+
+ $api->execxLocal('branch -D -- %s', $branch_name);
+ $this->deletedBranches[$branch_name] = true;
+ }
+ }
+
+ private function getBranchesForCommits(array $hashes, $is_contains) {
+ $api = $this->getRepositoryAPI();
+
+ $format = '%(refname) %(objectname)';
+
+ $result = array();
+ foreach ($hashes as $hash) {
+ if ($is_contains) {
+ $command = csprintf(
+ 'for-each-ref --contains %s --format %s --',
+ $hash,
+ $format);
+ } else {
+ $command = csprintf(
+ 'for-each-ref --points-at %s --format %s --',
+ $hash,
+ $format);
+ }
+
+ list($foreach_lines) = $api->execxLocal('%C', $command);
+ $foreach_lines = phutil_split_lines($foreach_lines, false);
+
+ foreach ($foreach_lines as $line) {
+ if (!strlen($line)) {
+ continue;
+ }
+
+ $expect_parts = 2;
+ $parts = explode(' ', $line, $expect_parts);
+ if (count($parts) !== $expect_parts) {
+ throw new Exception(
+ pht(
+ 'Failed to explode line "%s".',
+ $line));
+ }
+
+ $ref_name = $parts[0];
+ $ref_hash = $parts[1];
+
+ $matches = null;
+ $ok = preg_match('(^refs/heads/(.*)\z)', $ref_name, $matches);
+ if ($ok === false) {
+ throw new Exception(
+ pht(
+ 'Failed to match against branch pattern "%s".',
+ $line));
+ }
+
+ if (!$ok) {
+ continue;
+ }
+
+ $result[$matches[1]] = $ref_hash;
+ }
+ }
+
+ return $result;
+ }
+
+ 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;
+
+ $branch_map = $this->getBranchesForCommits(
+ array($old_commit),
+ $is_contains = true);
+
+ $log = $this->getLogEngine();
+ foreach ($branch_map as $branch_name => $branch_head) {
+ // If this branch just points at the old state, don't bother rebasing
+ // it. We'll update or delete it later.
+ if ($branch_head === $old_commit) {
+ continue;
+ }
+
+ $log->writeStatus(
+ pht('CASCADE'),
+ pht(
+ 'Rebasing "%s" onto landed state...',
+ $branch_name));
+
+ try {
+ $api->execxLocal(
+ 'rebase --onto %s -- %s %s',
+ $new_commit,
+ $old_commit,
+ $branch_name);
+ } catch (CommandException $ex) {
+ // TODO: If we have a stashed state or are not running in incremental
+ // mode: abort the rebase, restore the local state, and pop the stash.
+ // Otherwise, drop the user out here.
+ throw $ex;
+ }
+ }
+ }
+
+ private function fetchTarget(ArcanistLandTarget $target) {
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+
+ // NOTE: Although this output isn't hugely useful, we need to passthru
+ // instead of using a subprocess here because `git fetch` may prompt the
+ // user to enter a password if they're fetching over HTTP with basic
+ // authentication. See T10314.
+
+ if ($this->getIsGitPerforce()) {
+ $log->writeStatus(
+ pht('P4 SYNC'),
+ pht(
+ 'Synchronizing "%s" from Perforce...',
+ $target->getRef()));
+
+ $err = $api->execPassthru(
+ 'p4 sync --silent --branch %s --',
+ $target->getRemote().'/'.$target->getRef());
+
+ if ($err) {
+ throw new ArcanistUsageException(
+ pht(
+ 'Perforce sync failed! Fix the error and run "arc land" again.'));
+ }
+
+ return $this->getLandTargetLocalCommit($target);
+ }
+
+ $exists = $this->getLandTargetLocalExists($target);
+ if (!$exists) {
+ $log->writeWarning(
+ pht('TARGET'),
+ pht(
+ 'No local copy of ref "%s" in remote "%s" exists, attempting '.
+ 'fetch...',
+ $target->getRef(),
+ $target->getRemote()));
+
+ $this->fetchLandTarget($target, $ignore_failure = true);
+
+ $exists = $this->getLandTargetLocalExists($target);
+ if (!$exists) {
+ return null;
+ }
+
+ $log->writeStatus(
+ pht('FETCHED'),
+ pht(
+ 'Fetched ref "%s" from remote "%s".',
+ $target->getRef(),
+ $target->getRemote()));
+
+ return $this->getLandTargetLocalCommit($target);
+ }
+
+ $log->writeStatus(
+ pht('FETCH'),
+ pht(
+ 'Fetching "%s" from remote "%s"...',
+ $target->getRef(),
+ $target->getRemote()));
+
+ $this->fetchLandTarget($target, $ignore_failure = false);
+
+ return $this->getLandTargetLocalCommit($target);
+ }
+
+ private function updateWorkingCopy($into_commit) {
+ $api = $this->getRepositoryAPI();
+ if ($into_commit === null) {
+ throw new Exception('TODO: Author a new empty state.');
+ } else {
+ $api->execxLocal('checkout %s --', $into_commit);
+ }
+ }
+
+ protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) {
+ $api = $this->getRepositoryAPI();
+
+ $this->updateWorkingCopy($into_commit);
+
+ $commits = $set->getCommits();
+ $source_commit = last($commits)->getHash();
+
+ // NOTE: See T11435 for some history. See PHI1727 for a case where a user
+ // modified their working copy while running "arc land". This attempts to
+ // resist incorrectly detecting simultaneous working copy modifications
+ // as changes.
+
+ list($changes) = $api->execxLocal(
+ 'diff --no-ext-diff HEAD..%s --',
+ $source_commit);
+ $changes = trim($changes);
+ if (!strlen($changes)) {
+
+ // TODO: We could make a more significant effort to identify the
+ // human-readable symbol which led us to try to land this ref.
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Merging local "%s" into "%s" produces an empty diff. '.
+ 'This usually means these changes have already landed.',
+ $this->getDisplayHash($source_commit),
+ $this->getDisplayHash($into_commit)));
+ }
+
+ list($original_author, $original_date) = $this->getAuthorAndDate(
+ $source_commit);
+
+ try {
+ if ($this->isSquashStrategy()) {
+ // NOTE: We're explicitly specifying "--ff" to override the presence
+ // of "merge.ff" options in user configuration.
+
+ $api->execxLocal(
+ 'merge --no-stat --no-commit --ff --squash -- %s',
+ $source_commit);
+ } else {
+ $api->execxLocal(
+ 'merge --no-stat --no-commit --no-ff -- %s',
+ $source_commit);
+ }
+ } catch (CommandException $ex) {
+ $api->execManualLocal('merge --abort');
+ $api->execManualLocal('reset --hard HEAD --');
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Local "%s" does not merge cleanly into "%s". Merge or rebase '.
+ 'local changes so they can merge cleanly.',
+ $source_commit,
+ $into_commit));
+ }
+
+ $revision_ref = $set->getRevisionRef();
+ $commit_message = $revision_ref->getCommitMessage();
+
+ $future = $api->execFutureLocal(
+ 'commit --author %s --date %s -F - --',
+ $original_author,
+ $original_date);
+ $future->write($commit_message);
+ $future->resolvex();
+
+ list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD');
+ $new_cursor = trim($stdout);
+
+ if ($into_commit === null) {
+ if ($this->isSquashStrategy()) {
+ throw new Exception(
+ pht('TODO: Rewrite HEAD to have no parents.'));
+ } else {
+ throw new Exception(
+ pht('TODO: Rewrite HEAD to have only source as a parent.'));
+ }
+ }
+
+ return $new_cursor;
+ }
+
+ protected function pushChange($into_commit) {
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+
+ if ($this->getIsGitPerforce()) {
+
+ // TODO: Specifying "--onto" more than once is almost certainly an error
+ // in Perforce.
+
+ $log->writeStatus(
+ pht('SUBMITTING'),
+ pht(
+ 'Submitting changes to "%s".',
+ $this->getOntoRemote()));
+
+ $config_argv = array();
+
+ // Skip the "git p4 submit" interactive editor workflow. We expect
+ // the commit message that "arc land" has built to be satisfactory.
+ $config_argv[] = '-c';
+ $config_argv[] = 'git-p4.skipSubmitEdit=true';
+
+ // Skip the "git p4 submit" confirmation prompt if the user does not edit
+ // the submit message.
+ $config_argv[] = '-c';
+ $config_argv[] = 'git-p4.skipSubmitEditCheck=true';
+
+ $flags_argv = array();
+
+ // Disable implicit "git p4 rebase" as part of submit. We're allowing
+ // the implicit "git p4 sync" to go through since this puts us in a
+ // state which is generally similar to the state after "git push", with
+ // updated remotes.
+
+ // We could do a manual "git p4 sync" with a more narrow "--branch"
+ // instead, but it's not clear that this is beneficial.
+ $flags_argv[] = '--disable-rebase';
+
+ // Detect moves and submit them to Perforce as move operations.
+ $flags_argv[] = '-M';
+
+ // If we run into a conflict, abort the operation. We expect users to
+ // fix conflicts and run "arc land" again.
+ $flags_argv[] = '--conflict=quit';
+
+ $err = $api->execPassthru(
+ '%LR p4 submit %LR --commit %R --',
+ $config_argv,
+ $flags_argv,
+ $into_commit);
+
+ if ($err) {
+ throw new ArcanistUsageException(
+ pht(
+ 'Submit failed! Fix the error and run "arc land" again.'));
+ }
+
+ return;
+ }
+
+ $log->writeStatus(
+ pht('PUSHING'),
+ pht('Pushing changes to "%s".', $this->getOntoRemote()));
+
+ $refspecs = array();
+ foreach ($this->getOntoRefs() as $onto_ref) {
+ $refspecs[] = sprintf(
+ '%s:%s',
+ $into_commit,
+ $onto_ref);
+ }
+
+ $err = $api->execPassthru(
+ 'push -- %s %Ls',
+ $this->getOntoRemote(),
+ $refspecs);
+
+ if ($err) {
+ throw new ArcanistUsageException(
+ pht(
+ 'Push failed! Fix the error and run "arc land" again.'));
+ }
+
+ // TODO
+ // if ($this->isGitSvn) {
+ // $err = phutil_passthru('git svn dcommit');
+ // $cmd = 'git svn dcommit';
+
+ }
+
+ protected function reconcileLocalState(
+ $into_commit,
+ ArcanistRepositoryLocalState $state) {
+
+ $api = $this->getRepositoryAPI();
+ $log = $this->getWorkflow()->getLogEngine();
+
+ // Try to put the user into the best final state we can. This is very
+ // complicated because users are incredibly creative and their local
+ // branches may, for example, have the same names as branches in the
+ // remote but no relationship to them.
+
+ // First, we're going to try to update these local branches:
+ //
+ // - the branch we started on originally; and
+ // - the local upstreams of the branch we started on originally; and
+ // - the local branch with the same name as the "into" ref; and
+ // - the local branch with the same name as the "onto" ref.
+ //
+ // These branches may not all exist and may not all be unique.
+ //
+ // To be updated, these branches must:
+ //
+ // - exist;
+ // - have not been deleted; and
+ // - be connected to the remote we pushed into.
+
+ $update_branches = array();
+
+ $local_ref = $state->getLocalRef();
+ if ($local_ref !== null) {
+ $update_branches[] = $local_ref;
+ }
+
+ $local_path = $state->getLocalPath();
+ if ($local_path) {
+ foreach ($local_path->getLocalBranches() as $local_branch) {
+ $update_branches[] = $local_branch;
+ }
+ }
+
+ if (!$this->getIntoEmpty() && !$this->getIntoLocal()) {
+ $update_branches[] = $this->getIntoRef();
+ }
+
+ foreach ($this->getOntoRefs() as $onto_ref) {
+ $update_branches[] = $onto_ref;
+ }
+
+ $update_branches = array_fuse($update_branches);
+
+ // Remove any branches we know we deleted.
+ foreach ($update_branches as $key => $update_branch) {
+ if (isset($this->deletedBranches[$update_branch])) {
+ unset($update_branches[$key]);
+ }
+ }
+
+ // Now, remove any branches which don't actually exist.
+ foreach ($update_branches as $key => $update_branch) {
+ list($err) = $api->execManualLocal(
+ 'rev-parse --verify %s',
+ $update_branch);
+ if ($err) {
+ unset($update_branches[$key]);
+ }
+ }
+
+ $is_perforce = $this->getIsGitPerforce();
+ if ($is_perforce) {
+ // If we're in Perforce mode, we don't expect to have a meaningful
+ // path to the remote: the "p4" remote is not a real remote, and
+ // "git p4" commands do not configure branch upstreams to provide
+ // a path.
+
+ // Additionally, we've already set the remote to the right state with an
+ // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a
+ // meaningful operation.
+
+ // We're going to skip everything here and just switch to the most
+ // desirable branch (if we can find one), then reset the state (if that
+ // operation is safe).
+
+ if (!$update_branches) {
+ $log->writeStatus(
+ pht('DETACHED HEAD'),
+ pht(
+ 'Unable to find any local branches to update, staying on '.
+ 'detached head.'));
+ $state->discardLocalState();
+ return;
+ }
+
+ $dst_branch = head($update_branches);
+ if (!$this->isAncestorOf($dst_branch, $into_commit)) {
+ $log->writeStatus(
+ pht('CHECKOUT'),
+ pht(
+ 'Local branch "%s" has unpublished changes, checking it out '.
+ 'but leaving them in place.',
+ $dst_branch));
+ $do_reset = false;
+ } else {
+ $log->writeStatus(
+ pht('UPDATE'),
+ pht(
+ 'Switching to local branch "%s".',
+ $dst_branch));
+ $do_reset = true;
+ }
+
+ $api->execxLocal('checkout %s --', $dst_branch);
+
+ if ($do_reset) {
+ $api->execxLocal('reset --hard %s --', $into_commit);
+ }
+
+ $state->discardLocalState();
+ return;
+ }
+
+ $onto_refs = array_fuse($this->getOntoRefs());
+
+ $pull_branches = array();
+ foreach ($update_branches as $update_branch) {
+ $update_path = $api->getPathToUpstream($update_branch);
+
+ // Remove any branches which contain upstream cycles.
+ if ($update_path->getCycle()) {
+ $log->writeWarning(
+ pht('LOCAL CYCLE'),
+ pht(
+ 'Local branch "%s" tracks an upstream but following it leads to '.
+ 'a local cycle, ignoring branch.',
+ $update_branch));
+ continue;
+ }
+
+ // Remove any branches not connected to a remote.
+ if (!$update_path->isConnectedToRemote()) {
+ continue;
+ }
+
+ // Remove any branches connected to a remote other than the remote
+ // we actually pushed to.
+ $remote_name = $update_path->getRemoteRemoteName();
+ if ($remote_name !== $this->getOntoRemote()) {
+ continue;
+ }
+
+ // Remove any branches not connected to a branch we pushed to.
+ $remote_branch = $update_path->getRemoteBranchName();
+ if (!isset($onto_refs[$remote_branch])) {
+ continue;
+ }
+
+ // This is the most-desirable path between some local branch and
+ // an impacted upstream. Select it and continue.
+ $pull_branches = $update_path->getLocalBranches();
+ break;
+ }
+
+ // When we update these branches later, we want to start with the branch
+ // closest to the upstream and work our way down.
+ $pull_branches = array_reverse($pull_branches);
+ $pull_branches = array_fuse($pull_branches);
+
+ // If we started on a branch and it still exists but is not impacted
+ // by the changes we made to the remote (i.e., we aren't actually going
+ // to pull or update it if we continue), just switch back to it now. It's
+ // okay if this branch is completely unrelated to the changes we just
+ // landed.
+
+ if ($local_ref !== null) {
+ if (isset($update_branches[$local_ref])) {
+ if (!isset($pull_branches[$local_ref])) {
+
+ $log->writeStatus(
+ pht('RETURN'),
+ pht(
+ 'Returning to original branch "%s" in original state.',
+ $local_ref));
+
+ $state->restoreLocalState();
+ return;
+ }
+ }
+ }
+
+ // Otherwise, if we don't have any path from the upstream to any local
+ // branch, we don't want to switch to some unrelated branch which happens
+ // to have the same name as a branch we interacted with. Just stay where
+ // we ended up.
+
+ $dst_branch = null;
+ if ($pull_branches) {
+ $dst_branch = null;
+ foreach ($pull_branches as $pull_branch) {
+ if (!$this->isAncestorOf($pull_branch, $into_commit)) {
+
+ $log->writeStatus(
+ pht('LOCAL CHANGES'),
+ pht(
+ 'Local branch "%s" has unpublished changes, ending updates.',
+ $pull_branch));
+
+ break;
+ }
+
+ $log->writeStatus(
+ pht('UPDATE'),
+ pht(
+ 'Updating local branch "%s"...',
+ $pull_branch));
+
+ $api->execxLocal(
+ 'branch -f %s %s --',
+ $pull_branch,
+ $into_commit);
+
+ $dst_branch = $pull_branch;
+ }
+ }
+
+ if ($dst_branch) {
+ $log->writeStatus(
+ pht('CHECKOUT'),
+ pht(
+ 'Checking out "%s".',
+ $dst_branch));
+
+ $api->execxLocal('checkout %s --', $dst_branch);
+ } else {
+ $log->writeStatus(
+ pht('DETACHED HEAD'),
+ pht(
+ 'Unable to find any local branches to update, staying on '.
+ 'detached head.'));
+ }
+
+ $state->discardLocalState();
+ }
+
+ private function isAncestorOf($branch, $commit) {
+ $api = $this->getRepositoryAPI();
+
+ list($stdout) = $api->execxLocal(
+ 'merge-base %s %s',
+ $branch,
+ $commit);
+ $merge_base = trim($stdout);
+
+ list($stdout) = $api->execxLocal(
+ 'rev-parse --verify %s',
+ $branch);
+ $branch_hash = trim($stdout);
+
+ return ($merge_base === $branch_hash);
+ }
+
+ private function getAuthorAndDate($commit) {
+ $api = $this->getRepositoryAPI();
+
+ list($info) = $api->execxLocal(
+ 'log -n1 --format=%s %s --',
+ '%aD%n%an%n%ae',
+ $commit);
+
+ $info = trim($info);
+ list($date, $author, $email) = explode("\n", $info, 3);
+
+ return array(
+ "$author <{$email}>",
+ $date,
+ );
+ }
+
+ private function didHoldChanges() {
+ $log = $this->getLogEngine();
+
+ if ($this->getIsGitPerforce()) {
+ $this->writeInfo(
+ pht('HOLD'),
+ pht(
+ 'Holding change locally, it has not been submitted.'));
+
+ $push_command = csprintf(
+ '$ git p4 submit -M --commit %R --',
+ $this->mergedRef);
+ } else {
+ $log->writeStatus(
+ pht('HOLD'),
+ pht(
+ 'Holding change locally, it has not been pushed.'));
+
+ $push_command = csprintf(
+ '$ git push -- %R %R:%R',
+ $this->getTargetRemote(),
+ $this->mergedRef,
+ $this->getTargetOnto());
+ }
+
+ $restore_command = csprintf(
+ '$ git checkout %R --',
+ $this->localRef);
+
+ echo tsprintf(
+ "\n%s\n\n".
+ "%s\n\n".
+ " **%s**\n\n".
+ "%s\n\n".
+ " **%s**\n\n".
+ "%s\n",
+ pht(
+ 'This local working copy now contains the merged changes in a '.
+ 'detached state.'),
+ pht('You can push the changes manually with this command:'),
+ $push_command,
+ pht(
+ 'You can go back to how things were before you ran "arc land" with '.
+ 'this command:'),
+ $restore_command,
+ pht(
+ 'Local branches have not been changed, and are still in exactly the '.
+ 'same state as before.'));
+ }
+
+ protected function resolveSymbols(array $symbols) {
+ assert_instances_of($symbols, 'ArcanistLandSymbol');
+ $api = $this->getRepositoryAPI();
+
+ foreach ($symbols as $symbol) {
+ $raw_symbol = $symbol->getSymbol();
+
+ list($err, $stdout) = $api->execManualLocal(
+ 'rev-parse --verify %s',
+ $raw_symbol);
+
+ if ($err) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Branch "%s" does not exist in the local working copy.',
+ $raw_symbol));
+ }
+
+ $commit = trim($stdout);
+ $symbol->setCommit($commit);
+ }
+ }
+
+ 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 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();
+
+ $remote_onto = array();
+ foreach ($symbols as $symbol) {
+ $raw_symbol = $symbol->getSymbol();
+ $path = $api->getPathToUpstream($raw_symbol);
+
+ if (!$path->getLength()) {
+ continue;
+ }
+
+ $cycle = $path->getCycle();
+ if ($cycle) {
+ $log->writeWarning(
+ pht('LOCAL CYCLE'),
+ pht(
+ 'Local branch "%s" tracks an upstream, but following it leads '.
+ 'to a local cycle; ignoring branch upstream.',
+ $raw_symbol));
+
+ $log->writeWarning(
+ pht('LOCAL CYCLE'),
+ implode(' -> ', $cycle));
+
+ continue;
+ }
+
+ if (!$path->isConnectedToRemote()) {
+ $log->writeWarning(
+ pht('NO PATH TO REMOTE'),
+ pht(
+ 'Local branch "%s" tracks an upstream, but there is no path '.
+ 'to a remote; ignoring branch upstream.',
+ $raw_symbol));
+
+ continue;
+ }
+
+ $onto = $path->getRemoteBranchName();
+
+ $remote_onto[$onto] = $onto;
+ }
+
+ if (count($remote_onto) > 1) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'The branches you are landing are connected to multiple different '.
+ 'remote branches via Git branch upstreams. Use "--onto" to select '.
+ 'the refs you want to push to.'));
+ }
+
+ if ($remote_onto) {
+ $remote_onto = array_values($remote_onto);
+
+ $log->writeStatus(
+ pht('ONTO TARGET'),
+ pht(
+ 'Landing onto target "%s", selected by following tracking branches '.
+ 'upstream to the closest remote branch.',
+ head($remote_onto)));
+
+ return $remote_onto;
+ }
+
+ $default_onto = 'master';
+
+ $log->writeStatus(
+ pht('ONTO TARGET'),
+ pht(
+ 'Landing onto target "%s", the default target under Git.',
+ $default_onto));
+
+ return array($default_onto);
+ }
+
+ protected function selectOntoRemote(array $symbols) {
+ assert_instances_of($symbols, 'ArcanistLandSymbol');
+ $remote = $this->newOntoRemote($symbols);
+
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+ $is_pushable = $api->isPushableRemote($remote);
+ $is_perforce = $api->isPerforceRemote($remote);
+
+ if (!$is_pushable && !$is_perforce) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'No pushable remote "%s" exists. Use the "--onto-remote" flag to '.
+ 'choose a valid, pushable remote to land changes onto.',
+ $remote));
+ }
+
+ if ($is_perforce) {
+ $this->setIsGitPerforce(true);
+
+ $log->writeWarning(
+ pht('P4 MODE'),
+ pht(
+ 'Operating in Git/Perforce mode after selecting a Perforce '.
+ 'remote.'));
+
+ if (!$this->isSquashStrategy()) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Perforce mode does not support the "merge" land strategy. '.
+ 'Use the "squash" land strategy when landing to a Perforce '.
+ 'remote (you can use "--squash" to select this strategy).'));
+ }
+ }
+
+ return $remote;
+ }
+
+ private function newOntoRemote(array $onto_symbols) {
+ assert_instances_of($onto_symbols, 'ArcanistLandSymbol');
+ $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();
+
+ $upstream_remotes = array();
+ foreach ($onto_symbols as $onto_symbol) {
+ $path = $api->getPathToUpstream($onto_symbol->getSymbol());
+
+ $remote = $path->getRemoteRemoteName();
+ if ($remote !== null) {
+ $upstream_remotes[$remote][] = $onto_symbol;
+ }
+ }
+
+ if (count($upstream_remotes) > 1) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'The "onto" refs you have selected are connected to multiple '.
+ 'different remotes via Git branch upstreams. Use "--onto-remote" '.
+ 'to select a single remote.'));
+ }
+
+ if ($upstream_remotes) {
+ $upstream_remote = head_key($upstream_remotes);
+
+ $log->writeStatus(
+ pht('ONTO REMOTE'),
+ pht(
+ 'Remote "%s" was selected by following tracking branches '.
+ 'upstream to the closest remote.',
+ $remote));
+
+ return $upstream_remote;
+ }
+
+ $perforce_remote = 'p4';
+ if ($api->isPerforceRemote($remote)) {
+
+ $log->writeStatus(
+ pht('ONTO REMOTE'),
+ pht(
+ 'Peforce remote "%s" was selected because the existence of '.
+ 'this remote implies this working copy was synchronized '.
+ 'from a Perforce repository.',
+ $remote));
+
+ return $remote;
+ }
+
+ $default_remote = 'origin';
+
+ $log->writeStatus(
+ pht('ONTO REMOTE'),
+ pht(
+ 'Landing onto remote "%s", the default remote under Git.',
+ $default_remote));
+
+ return $default_remote;
+ }
+
+ 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) {
+
+ // TODO: We could allow users to pass a URI argument instead, but
+ // this also requires some updates to the fetch logic elsewhere.
+
+ if (!$api->isFetchableRemote($into)) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Remote "%s", specified with "--into", is not a valid fetchable '.
+ 'remote.',
+ $into));
+ }
+
+ $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();
+
+ list($err, $stdout) = $api->execManualLocal(
+ 'rev-parse --verify %s',
+ $local_ref);
+
+ if ($err) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Local ref "%s" does not exist.',
+ $local_ref));
+ }
+
+ $into_commit = trim($stdout);
+
+ $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 getLandTargetLocalCommit(ArcanistLandTarget $target) {
+ $commit = $this->resolveLandTargetLocalCommit($target);
+
+ if ($commit === null) {
+ throw new Exception(
+ pht(
+ 'No ref "%s" exists in remote "%s".',
+ $target->getRef(),
+ $target->getRemote()));
+ }
+
+ return $commit;
+ }
+
+ private function getLandTargetLocalExists(ArcanistLandTarget $target) {
+ $commit = $this->resolveLandTargetLocalCommit($target);
+ return ($commit !== null);
+ }
+
+ private function resolveLandTargetLocalCommit(ArcanistLandTarget $target) {
+ $target_key = $target->getLandTargetKey();
+
+ if (!array_key_exists($target_key, $this->landTargetCommitMap)) {
+ $full_ref = sprintf(
+ 'refs/remotes/%s/%s',
+ $target->getRemote(),
+ $target->getRef());
+
+ $api = $this->getRepositoryAPI();
+
+ list($err, $stdout) = $api->execManualLocal(
+ 'rev-parse --verify %s',
+ $full_ref);
+
+ if ($err) {
+ $result = null;
+ } else {
+ $result = trim($stdout);
+ }
+
+ $this->landTargetCommitMap[$target_key] = $result;
+ }
+
+ return $this->landTargetCommitMap[$target_key];
+ }
+
+ private function fetchLandTarget(
+ ArcanistLandTarget $target,
+ $ignore_failure = false) {
+ $api = $this->getRepositoryAPI();
+
+ // TODO: Format this fetch nicely as a workflow command.
+
+ $err = $api->execPassthru(
+ 'fetch --no-tags --quiet -- %s %s',
+ $target->getRemote(),
+ $target->getRef());
+ if ($err && !$ignore_failure) {
+ throw new ArcanistUsageException(
+ pht(
+ 'Fetch of "%s" from remote "%s" failed! Fix the error and '.
+ 'run "arc land" again.',
+ $target->getRef(),
+ $target->getRemote()));
+ }
+
+ // TODO: If the remote is a bare URI, we could read ".git/FETCH_HEAD"
+ // here and write the commit into the map. For now, settle for clearing
+ // the cache.
+
+ // We could also fetch into some named "refs/arc-land-temporary" named
+ // ref, then read that.
+
+ if (!$err) {
+ $target_key = $target->getLandTargetKey();
+ unset($this->landTargetCommitMap[$target_key]);
+ }
+ }
+
+ 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();
+ $format = '%H%x00%P%x00%s%x00';
+
+ if ($into_commit === null) {
+ list($commits) = $api->execxLocal(
+ 'log %s --format=%s',
+ $symbol_commit,
+ $format);
+ } else {
+ list($commits) = $api->execxLocal(
+ 'log %s --not %s --format=%s',
+ $symbol_commit,
+ $into_commit,
+ $format);
+ }
+
+ $commits = phutil_split_lines($commits, false);
+ foreach ($commits as $line) {
+ if (!strlen($line)) {
+ continue;
+ }
+
+ $parts = explode("\0", $line, 4);
+ if (count($parts) < 3) {
+ throw new Exception(
+ pht(
+ 'Unexpected output from "git 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];
+ $commit->addSymbol($symbol);
+ }
+ }
+
+ return $this->confirmCommits($into_commit, $symbols, $commit_map);
+ }
+
+ protected function getDefaultSymbols() {
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+
+ $branch = $api->getBranchName();
+ if ($branch === null) {
+ $log->writeStatus(
+ pht('SOURCE'),
+ pht(
+ 'Landing the current branch, "%s".',
+ $branch));
+
+ return array($branch);
+ }
+
+ $commit = $api->getCurrentCommitRef();
+
+ $log->writeStatus(
+ pht('SOURCE'),
+ pht(
+ 'Landing the current HEAD, "%s".',
+ $commit->getCommitHash()));
+
+ return array($branch);
+ }
+
+}
diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php
new file mode 100644
--- /dev/null
+++ b/src/land/engine/ArcanistLandEngine.php
@@ -0,0 +1,1430 @@
+<?php
+
+abstract class ArcanistLandEngine extends Phobject {
+
+ private $workflow;
+ private $viewer;
+ private $logEngine;
+ private $repositoryAPI;
+
+ private $sourceRefs;
+ private $shouldHold;
+ private $shouldKeep;
+ private $shouldPreview;
+ private $isIncremental;
+ private $ontoRemoteArgument;
+ private $ontoArguments;
+ private $intoEmptyArgument;
+ private $intoLocalArgument;
+ private $intoRemoteArgument;
+ private $intoArgument;
+ private $strategyArgument;
+ private $strategy;
+
+ private $revisionSymbol;
+ private $revisionSymbolRef;
+
+ private $ontoRemote;
+ private $ontoRefs;
+ private $intoRemote;
+ private $intoRef;
+ private $intoEmpty;
+ private $intoLocal;
+
+ final public function setViewer($viewer) {
+ $this->viewer = $viewer;
+ return $this;
+ }
+
+ final public function getViewer() {
+ return $this->viewer;
+ }
+
+ final public function setOntoRemote($onto_remote) {
+ $this->ontoRemote = $onto_remote;
+ return $this;
+ }
+
+ final public function getOntoRemote() {
+ return $this->ontoRemote;
+ }
+
+ final public function setOntoRefs($onto_refs) {
+ $this->ontoRefs = $onto_refs;
+ return $this;
+ }
+
+ final public function getOntoRefs() {
+ return $this->ontoRefs;
+ }
+
+ final public function setIntoRemote($into_remote) {
+ $this->intoRemote = $into_remote;
+ return $this;
+ }
+
+ final public function getIntoRemote() {
+ return $this->intoRemote;
+ }
+
+ final public function setIntoRef($into_ref) {
+ $this->intoRef = $into_ref;
+ return $this;
+ }
+
+ final public function getIntoRef() {
+ return $this->intoRef;
+ }
+
+ final public function setIntoEmpty($into_empty) {
+ $this->intoEmpty = $into_empty;
+ return $this;
+ }
+
+ final public function getIntoEmpty() {
+ return $this->intoEmpty;
+ }
+
+ final public function setIntoLocal($into_local) {
+ $this->intoLocal = $into_local;
+ return $this;
+ }
+
+ final public function getIntoLocal() {
+ return $this->intoLocal;
+ }
+
+ final public function setWorkflow($workflow) {
+ $this->workflow = $workflow;
+ return $this;
+ }
+
+ final public function getWorkflow() {
+ return $this->workflow;
+ }
+
+ final public function setRepositoryAPI(
+ ArcanistRepositoryAPI $repository_api) {
+ $this->repositoryAPI = $repository_api;
+ return $this;
+ }
+
+ final public function getRepositoryAPI() {
+ return $this->repositoryAPI;
+ }
+
+ final public function setLogEngine(ArcanistLogEngine $log_engine) {
+ $this->logEngine = $log_engine;
+ return $this;
+ }
+
+ final public function getLogEngine() {
+ return $this->logEngine;
+ }
+
+ final public function setShouldHold($should_hold) {
+ $this->shouldHold = $should_hold;
+ return $this;
+ }
+
+ final public function getShouldHold() {
+ return $this->shouldHold;
+ }
+
+ final public function setShouldKeep($should_keep) {
+ $this->shouldKeep = $should_keep;
+ return $this;
+ }
+
+ final public function getShouldKeep() {
+ return $this->shouldKeep;
+ }
+
+ final public function setStrategy($strategy) {
+ $this->strategy = $strategy;
+ return $this;
+ }
+
+ final public function getStrategy() {
+ return $this->strategy;
+ }
+
+ final public function setRevisionSymbol($revision_symbol) {
+ $this->revisionSymbol = $revision_symbol;
+ return $this;
+ }
+
+ final public function getRevisionSymbol() {
+ return $this->revisionSymbol;
+ }
+
+ final public function setRevisionSymbolRef(
+ ArcanistRevisionSymbolRef $revision_ref) {
+ $this->revisionSymbolRef = $revision_ref;
+ return $this;
+ }
+
+ final public function getRevisionSymbolRef() {
+ return $this->revisionSymbolRef;
+ }
+
+ final public function setShouldPreview($should_preview) {
+ $this->shouldPreview = $should_preview;
+ return $this;
+ }
+
+ final public function getShouldPreview() {
+ return $this->shouldPreview;
+ }
+
+ final public function setSourceRefs(array $source_refs) {
+ $this->sourceRefs = $source_refs;
+ return $this;
+ }
+
+ final public function getSourceRefs() {
+ return $this->sourceRefs;
+ }
+
+ final public function setOntoRemoteArgument($remote_argument) {
+ $this->ontoRemoteArgument = $remote_argument;
+ return $this;
+ }
+
+ final public function getOntoRemoteArgument() {
+ return $this->ontoRemoteArgument;
+ }
+
+ final public function setOntoArguments(array $onto_arguments) {
+ $this->ontoArguments = $onto_arguments;
+ return $this;
+ }
+
+ final public function getOntoArguments() {
+ return $this->ontoArguments;
+ }
+
+ final public function setIsIncremental($is_incremental) {
+ $this->isIncremental = $is_incremental;
+ return $this;
+ }
+
+ final public function getIsIncremental() {
+ return $this->isIncremental;
+ }
+
+ final public function setIntoEmptyArgument($into_empty_argument) {
+ $this->intoEmptyArgument = $into_empty_argument;
+ return $this;
+ }
+
+ final public function getIntoEmptyArgument() {
+ return $this->intoEmptyArgument;
+ }
+
+ final public function setIntoLocalArgument($into_local_argument) {
+ $this->intoLocalArgument = $into_local_argument;
+ return $this;
+ }
+
+ final public function getIntoLocalArgument() {
+ return $this->intoLocalArgument;
+ }
+
+ final public function setIntoRemoteArgument($into_remote_argument) {
+ $this->intoRemoteArgument = $into_remote_argument;
+ return $this;
+ }
+
+ final public function getIntoRemoteArgument() {
+ return $this->intoRemoteArgument;
+ }
+
+ final public function setIntoArgument($into_argument) {
+ $this->intoArgument = $into_argument;
+ return $this;
+ }
+
+ final public function getIntoArgument() {
+ return $this->intoArgument;
+ }
+
+ final protected function getOntoFromConfiguration() {
+ $config_key = $this->getOntoConfigurationKey();
+ return $this->getWorkflow()->getConfig($config_key);
+ }
+
+ final protected function getOntoConfigurationKey() {
+ return 'arc.land.onto';
+ }
+
+ final protected function getOntoRemoteFromConfiguration() {
+ $config_key = $this->getOntoRemoteConfigurationKey();
+ return $this->getWorkflow()->getConfig($config_key);
+ }
+
+ final protected function getOntoRemoteConfigurationKey() {
+ return 'arc.land.onto-remote';
+ }
+
+ final protected function confirmRevisions(array $sets) {
+ assert_instances_of($sets, 'ArcanistLandCommitSet');
+
+ $revision_refs = mpull($sets, 'getRevisionRef');
+ $viewer = $this->getViewer();
+ $viewer_phid = $viewer->getPHID();
+
+ $unauthored = array();
+ foreach ($revision_refs as $revision_ref) {
+ $author_phid = $revision_ref->getAuthorPHID();
+ if ($author_phid !== $viewer_phid) {
+ $unauthored[] = $revision_ref;
+ }
+ }
+
+ if ($unauthored) {
+ $this->getWorkflow()->loadHardpoints(
+ $unauthored,
+ array(
+ ArcanistRevisionRef::HARDPOINT_AUTHORREF,
+ ));
+
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('NOT REVISION AUTHOR'),
+ pht(
+ 'You are landing revisions which you ("%s") are not the author of:',
+ $viewer->getMonogram()));
+
+ foreach ($unauthored as $revision_ref) {
+ $display_ref = $revision_ref->newDisplayRef();
+
+ $author_ref = $revision_ref->getAuthorRef();
+ if ($author_ref) {
+ $display_ref->appendLine(
+ pht(
+ 'Author: %s',
+ $author_ref->getMonogram()));
+ }
+
+ echo tsprintf('%s', $display_ref);
+ }
+
+ echo tsprintf(
+ "\n%?\n",
+ pht(
+ 'Use "Commandeer" in the web interface to become the author of '.
+ 'a revision.'));
+
+ $query = pht('Land revisions you are not the author of?');
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.unauthored')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ $planned = array();
+ $closed = array();
+ $not_accepted = array();
+ foreach ($revision_refs as $revision_ref) {
+ if ($revision_ref->isStatusChangesPlanned()) {
+ $planned[] = $revision_ref;
+ } else if ($revision_ref->isStatusClosed()) {
+ $closed[] = $revision_ref;
+ } else if (!$revision_ref->isStatusAccepted()) {
+ $not_accepted[] = $revision_ref;
+ }
+ }
+
+ // See T10233. Previously, this prompt was bundled with the generic "not
+ // accepted" prompt, but users found it confusing and interpreted the
+ // prompt as a bug.
+
+ if ($planned) {
+ $example_ref = head($planned);
+
+ echo tsprintf(
+ "\n%!\n%W\n\n%W\n\n%W\n\n",
+ pht('%s REVISION(S) HAVE CHANGES PLANNED', phutil_count($planned)),
+ pht(
+ 'You are landing %s revision(s) which are currently in the state '.
+ '"%s", indicating that you expect to revise them before moving '.
+ 'forward.',
+ phutil_count($planned),
+ $example_ref->getStatusDisplayName()),
+ pht(
+ 'Normally, you should update these %s revision(s), submit them '.
+ 'for review, and wait for reviewers to accept them before '.
+ 'you continue. To resubmit a revision for review, either: '.
+ 'update the revision with revised changes; or use '.
+ '"Request Review" from the web interface.',
+ phutil_count($planned)),
+ pht(
+ 'These %s revision(s) have changes planned:',
+ phutil_count($planned)));
+
+ foreach ($planned as $revision_ref) {
+ echo tsprintf('%s', $revision_ref->newDisplayRef());
+ }
+
+ $query = pht(
+ 'Land %s revision(s) with changes planned?',
+ phutil_count($planned));
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.changes-planned')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ // See PHI1727. Previously, this prompt was bundled with the generic
+ // "not accepted" prompt, but at least one user found it confusing.
+
+ if ($closed) {
+ $example_ref = head($closed);
+
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('%s REVISION(S) ARE ALREADY CLOSED', phutil_count($closed)),
+ pht(
+ 'You are landing %s revision(s) which are already in the state '.
+ '"%s", indicating that they have previously landed:',
+ phutil_count($closed),
+ $example_ref->getStatusDisplayName()));
+
+ foreach ($closed as $revision_ref) {
+ echo tsprintf('%s', $revision_ref->newDisplayRef());
+ }
+
+ $query = pht(
+ 'Land %s revision(s) that are already closed?',
+ phutil_count($closed));
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.closed')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ if ($not_accepted) {
+ $example_ref = head($not_accepted);
+
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('%s REVISION(S) ARE NOT ACCEPTED', phutil_count($not_accepted)),
+ pht(
+ 'You are landing %s revision(s) which are not in state "Accepted", '.
+ 'indicating that they have not been accepted by reviewers. '.
+ 'Normally, you should land changes only once they have been '.
+ 'accepted. These revisions are in the wrong state:',
+ phutil_count($not_accepted)));
+
+ foreach ($not_accepted as $revision_ref) {
+ $display_ref = $revision_ref->newDisplayRef();
+ $display_ref->appendLine(
+ pht(
+ 'Status: %s',
+ $revision_ref->getStatusDisplayName()));
+ echo tsprintf('%s', $display_ref);
+ }
+
+ $query = pht(
+ 'Land %s revision(s) in the wrong state?',
+ phutil_count($not_accepted));
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.not-accepted')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ $this->getWorkflow()->loadHardpoints(
+ $revision_refs,
+ array(
+ ArcanistRevisionRef::HARDPOINT_PARENTREVISIONREFS,
+ ));
+
+ $open_parents = array();
+ foreach ($revision_refs as $revision_phid => $revision_ref) {
+ $parent_refs = $revision_ref->getParentRevisionRefs();
+ foreach ($parent_refs as $parent_ref) {
+ $parent_phid = $parent_ref->getPHID();
+
+ // If we're landing a parent revision in this operation, we don't need
+ // to complain that it hasn't been closed yet.
+ if (isset($revision_refs[$parent_phid])) {
+ continue;
+ }
+
+ if ($parent_ref->isClosed()) {
+ continue;
+ }
+
+ if (!isset($open_parents[$parent_phid])) {
+ $open_parents[$parent_phid] = array(
+ 'ref' => $parent_ref,
+ 'children' => array(),
+ );
+ }
+
+ $open_parents[$parent_phid]['children'][] = $revision_ref;
+ }
+ }
+
+ if ($open_parents) {
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('%s OPEN PARENT REVISION(S) ', phutil_count($open_parents)),
+ pht(
+ 'The changes you are landing depend on %s open parent revision(s). '.
+ 'Usually, you should land parent revisions before landing the '.
+ 'changes which depend on them. These parent revisions are open:',
+ phutil_count($open_parents)));
+
+ foreach ($open_parents as $parent_phid => $spec) {
+ $parent_ref = $spec['ref'];
+
+ $display_ref = $parent_ref->newDisplayRef();
+
+ $display_ref->appendLine(
+ pht(
+ 'Status: %s',
+ $parent_ref->getStatusDisplayName()));
+
+ foreach ($spec['children'] as $child_ref) {
+ $display_ref->appendLine(
+ pht(
+ 'Parent of: %s %s',
+ $child_ref->getMonogram(),
+ $child_ref->getName()));
+ }
+
+ echo tsprintf('%s', $display_ref);
+ }
+
+ $query = pht(
+ 'Land changes that depend on %s open revision(s)?',
+ phutil_count($open_parents));
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.open-parents')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ $this->confirmBuilds($revision_refs);
+
+ // This is a reasonable place to bulk-load the commit messages, which
+ // we'll need soon.
+
+ $this->getWorkflow()->loadHardpoints(
+ $revision_refs,
+ array(
+ ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE,
+ ));
+ }
+
+ private function confirmBuilds(array $revision_refs) {
+ assert_instances_of($revision_refs, 'ArcanistRevisionRef');
+
+ $this->getWorkflow()->loadHardpoints(
+ $revision_refs,
+ array(
+ ArcanistRevisionRef::HARDPOINT_BUILDABLEREF,
+ ));
+
+ $buildable_refs = array();
+ foreach ($revision_refs as $revision_ref) {
+ $ref = $revision_ref->getBuildableRef();
+ if ($ref) {
+ $buildable_refs[] = $ref;
+ }
+ }
+
+ $this->getWorkflow()->loadHardpoints(
+ $buildable_refs,
+ array(
+ ArcanistBuildableRef::HARDPOINT_BUILDREFS,
+ ));
+
+ $build_refs = array();
+ foreach ($buildable_refs as $buildable_ref) {
+ foreach ($buildable_ref->getBuildRefs() as $build_ref) {
+ $build_refs[] = $build_ref;
+ }
+ }
+
+ $this->getWorkflow()->loadHardpoints(
+ $build_refs,
+ array(
+ ArcanistBuildRef::HARDPOINT_BUILDPLANREF,
+ ));
+
+ $problem_builds = array();
+ $has_failures = false;
+ $has_ongoing = false;
+
+ $build_refs = msortv($build_refs, 'getStatusSortVector');
+ foreach ($build_refs as $build_ref) {
+ $plan_ref = $build_ref->getBuildPlanRef();
+ if (!$plan_ref) {
+ continue;
+ }
+
+ $plan_behavior = $plan_ref->getBehavior('arc-land', 'always');
+ $if_building = ($plan_behavior == 'building');
+ $if_complete = ($plan_behavior == 'complete');
+ $if_never = ($plan_behavior == 'never');
+
+ // If the build plan "Never" warns when landing, skip it.
+ if ($if_never) {
+ continue;
+ }
+
+ // If the build plan warns when landing "If Complete" but the build is
+ // not complete, skip it.
+ if ($if_complete && !$build_ref->isComplete()) {
+ continue;
+ }
+
+ // If the build plan warns when landing "If Building" but the build is
+ // complete, skip it.
+ if ($if_building && $build_ref->isComplete()) {
+ continue;
+ }
+
+ // Ignore passing builds.
+ if ($build_ref->isPassed()) {
+ continue;
+ }
+
+ if ($build_ref->isComplete()) {
+ $has_failures = true;
+ } else {
+ $has_ongoing = true;
+ }
+
+ $problem_builds[] = $build_ref;
+ }
+
+ if (!$problem_builds) {
+ return;
+ }
+
+ $build_map = array();
+ $failure_map = array();
+ $buildable_map = mpull($buildable_refs, null, 'getPHID');
+ $revision_map = mpull($revision_refs, null, 'getDiffPHID');
+ foreach ($problem_builds as $build_ref) {
+ $buildable_phid = $build_ref->getBuildablePHID();
+ $buildable_ref = $buildable_map[$buildable_phid];
+
+ $object_phid = $buildable_ref->getObjectPHID();
+ $revision_ref = $revision_map[$object_phid];
+
+ $revision_phid = $revision_ref->getPHID();
+
+ if (!isset($build_map[$revision_phid])) {
+ $build_map[$revision_phid] = array(
+ 'revisionRef' => $revision_phid,
+ 'buildRefs' => array(),
+ );
+ }
+
+ $build_map[$revision_phid]['buildRefs'][] = $build_ref;
+ }
+
+ $log = $this->getLogEngine();
+
+ if ($has_failures) {
+ if ($has_ongoing) {
+ $message = pht(
+ '%s revision(s) have build failures or ongoing builds:',
+ phutil_count($build_map));
+
+ $query = pht(
+ 'Land %s revision(s) anyway, despite ongoing and failed builds?',
+ phutil_count($build_map));
+
+ } else {
+ $message = pht(
+ '%s revision(s) have build failures:',
+ phutil_count($build_map));
+
+ $query = pht(
+ 'Land %s revision(s) anyway, despite failed builds?',
+ phutil_count($build_map));
+ }
+
+ echo tsprintf(
+ "%!\n%s\n\n",
+ pht('BUILD FAILURES'),
+ $message);
+
+ $prompt_key = 'arc.land.failed-builds';
+ } else if ($has_ongoing) {
+ echo tsprintf(
+ "%!\n%s\n\n",
+ pht('ONGOING BUILDS'),
+ pht(
+ '%s revision(s) have ongoing builds:',
+ phutil_count($build_map)));
+
+ $query = pht(
+ 'Land %s revision(s) anyway, despite ongoing builds?',
+ phutil_count($build_map));
+
+ $prompt_key = 'arc.land.ongoing-builds';
+ }
+
+ echo tsprintf("\n");
+ foreach ($build_map as $build_item) {
+ $revision_ref = $build_item['revisionRef'];
+
+ echo tsprintf('%s', $revision_ref->newDisplayRef());
+
+ foreach ($build_item['buildRefs'] as $build_ref) {
+ echo tsprintf('%s', $build_ref->newDisplayRef());
+ }
+
+ echo tsprintf("\n");
+ }
+
+ echo tsprintf(
+ "\n%s\n\n",
+ pht('You can review build details here:'));
+
+ // TODO: Only show buildables with problem builds.
+
+ foreach ($buildable_refs as $buildable) {
+ $display_ref = $buildable->newDisplayRef();
+
+ // TODO: Include URI here.
+
+ echo tsprintf('%s', $display_ref);
+ }
+
+ $this->getWorkflow()
+ ->getPrompt($prompt_key)
+ ->setQuery($query)
+ ->execute();
+ }
+
+ final protected function confirmImplicitCommits(array $sets, array $symbols) {
+ assert_instances_of($sets, 'ArcanistLandCommitSet');
+ assert_instances_of($symbols, 'ArcanistLandSymbol');
+
+ $implicit = array();
+ foreach ($sets as $set) {
+ if ($set->hasImplicitCommits()) {
+ $implicit[] = $set;
+ }
+ }
+
+ if (!$implicit) {
+ return;
+ }
+
+ echo tsprintf(
+ "\n%!\n%W\n",
+ pht('IMPLICIT COMMITS'),
+ pht(
+ 'Some commits reachable from the specified sources (%s) are not '.
+ 'associated with revisions, and may not have been reviewed. These '.
+ 'commits will be landed as though they belong to the nearest '.
+ 'ancestor revision:',
+ $this->getDisplaySymbols($symbols)));
+
+ foreach ($implicit as $set) {
+ $this->printCommitSet($set);
+ }
+
+ $query = pht(
+ 'Continue with this mapping between commits and revisions?');
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.implicit')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ final protected function getDisplaySymbols(array $symbols) {
+ $display = array();
+
+ foreach ($symbols as $symbol) {
+ $display[] = sprintf('"%s"', addcslashes($symbol->getSymbol(), '\\"'));
+ }
+
+ return implode(', ', $display);
+ }
+
+ final protected function printCommitSet(ArcanistLandCommitSet $set) {
+ $revision_ref = $set->getRevisionRef();
+
+ echo tsprintf(
+ "\n%s",
+ $revision_ref->newDisplayRef());
+
+ foreach ($set->getCommits() as $commit) {
+ $is_implicit = $commit->getIsImplicitCommit();
+
+ $display_hash = $this->getDisplayHash($commit->getHash());
+ $display_summary = $commit->getDisplaySummary();
+
+ if ($is_implicit) {
+ echo tsprintf(
+ " <bg:yellow> %s </bg> %s\n",
+ $display_hash,
+ $display_summary);
+ } else {
+ echo tsprintf(
+ " %s %s\n",
+ $display_hash,
+ $display_summary);
+ }
+ }
+ }
+
+ final protected function loadRevisionRefs(array $commit_map) {
+ assert_instances_of($commit_map, 'ArcanistLandCommit');
+ $workflow = $this->getWorkflow();
+
+ $state_refs = array();
+ foreach ($commit_map as $commit) {
+ $hash = $commit->getHash();
+
+ $commit_ref = id(new ArcanistCommitRef())
+ ->setCommitHash($hash);
+
+ $state_ref = id(new ArcanistWorkingCopyStateRef())
+ ->setCommitRef($commit_ref);
+
+ $state_refs[$hash] = $state_ref;
+ }
+
+ $force_symbol_ref = $this->getRevisionSymbolRef();
+ $force_ref = null;
+ if ($force_symbol_ref) {
+ $workflow->loadHardpoints(
+ $force_symbol_ref,
+ ArcanistSymbolRef::HARDPOINT_OBJECT);
+
+ $force_ref = $force_symbol_ref->getObject();
+ if (!$force_ref) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Symbol "%s" does not identify a valid revision.',
+ $force_symbol_ref->getSymbol()));
+ }
+ }
+
+ $workflow->loadHardpoints(
+ $state_refs,
+ ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS);
+
+ foreach ($commit_map as $commit) {
+ $hash = $commit->getHash();
+ $state_ref = $state_refs[$hash];
+
+ $revision_refs = $state_ref->getRevisionRefs();
+
+ // If we have several possible revisions but one of them matches the
+ // "--revision" argument, just select it. This is relatively safe and
+ // reasonable and doesn't need a warning.
+
+ if ($force_ref) {
+ if (count($revision_refs) > 1) {
+ foreach ($revision_refs as $revision_ref) {
+ if ($revision_ref->getPHID() === $force_ref->getPHID()) {
+ $revision_refs = array($revision_ref);
+ break;
+ }
+ }
+ }
+ }
+
+ if (count($revision_refs) === 1) {
+ $revision_ref = head($revision_refs);
+ $commit->setExplicitRevisionRef($revision_ref);
+ continue;
+ }
+
+ if (!$revision_refs) {
+ continue;
+ }
+
+ // TODO: If we have several refs but all but one are abandoned or closed
+ // or authored by someone else, we could guess what you mean.
+
+ $symbols = $commit->getSymbols();
+ $raw_symbols = mpull($symbols, 'getSymbol');
+ $symbol_list = implode(', ', $raw_symbols);
+ $display_hash = $this->getDisplayHash($hash);
+
+ // TODO: Include "use 'arc look --type commit abc' to figure out why"
+ // once that works?
+
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('AMBIGUOUS REVISION'),
+ pht(
+ 'The revision associated with commit "%s" (an ancestor of: %s) '.
+ 'is ambiguous. These %s revision(s) are associated with the commit:',
+ $display_hash,
+ implode(', ', $raw_symbols),
+ phutil_count($revision_refs)));
+
+ foreach ($revision_refs as $revision_ref) {
+ echo tsprintf(
+ '%s',
+ $revision_ref->newDisplayRef());
+ }
+
+ echo tsprintf("\n");
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Revision for commit "%s" is ambiguous. Use "--revision" to force '.
+ 'selection of a particular revision.',
+ $display_hash));
+ }
+
+ // TODO: Some of the revisions we've identified may be mapped to an
+ // outdated set of commits. We should look in local branches for a better
+ // set of commits, and try to confirm that the state we're about to land
+ // is the current state in Differential.
+
+ if ($force_ref) {
+ $phid_map = array();
+ foreach ($commit_map as $commit) {
+ $explicit_ref = $commit->getExplicitRevisionRef();
+ if ($explicit_ref) {
+ $revision_phid = $explicit_ref->getPHID();
+ $phid_map[$revision_phid] = $revision_phid;
+ }
+ }
+
+ $force_phid = $force_ref->getPHID();
+
+ // If none of the commits have a revision, forcing the revision is
+ // reasonable and we don't need to confirm it.
+
+ // If some of the commits have a revision, but it's the same as the
+ // revision we're forcing, forcing the revision is also reasonable.
+
+ // Discard the revision we're trying to force, then check if there's
+ // anything left. If some of the commits have a different revision,
+ // make sure the user is really doing what they expect.
+
+ unset($phid_map[$force_phid]);
+
+ if ($phid_map) {
+ // TODO: Make this more clear.
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'TODO: You are forcing a revision, but commits are associated '.
+ 'with some other revision. Are you REALLY sure you want to land '.
+ 'ALL these commits wiht a different unrelated revision???'));
+ }
+
+ foreach ($commit_map as $commit) {
+ $commit->setExplicitRevisionRef($force_ref);
+ }
+ }
+ }
+
+ final protected function getDisplayHash($hash) {
+ // TODO: This should be on the API object.
+ return substr($hash, 0, 12);
+ }
+
+ final protected function confirmCommits(
+ $into_commit,
+ array $symbols,
+ array $commit_map) {
+
+ $commit_count = count($commit_map);
+
+ if (!$commit_count) {
+ $message = pht(
+ 'There are no commits reachable from the specified sources (%s) '.
+ 'which are not already present in the state you are merging '.
+ 'into ("%s"), so nothing can land.',
+ $this->getDisplaySymbols($symbols),
+ $this->getDisplayHash($into_commit));
+
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('NOTHING TO LAND'),
+ $message);
+
+ throw new PhutilArgumentUsageException(
+ pht('There are no commits to land.'));
+ }
+
+ // Reverse the commit list so that it's oldest-first, since this is the
+ // order we'll use to show revisions.
+ $commit_map = array_reverse($commit_map, true);
+
+ $warn_limit = $this->getWorkflow()->getLargeWorkingSetLimit();
+ $show_limit = 5;
+ if ($commit_count > $warn_limit) {
+ if ($into_commit === null) {
+ $message = pht(
+ 'There are %s commit(s) reachable from the specified sources (%s). '.
+ 'You are landing into the empty state, so all of these commits '.
+ 'will land:',
+ new PhutilNumber($commit_count),
+ $this->getDisplaySymbols($symbols));
+ } else {
+ $message = pht(
+ 'There are %s commit(s) reachable from the specified sources (%s) '.
+ 'that are not present in the repository state you are merging '.
+ 'into ("%s"). All of these commits will land:',
+ new PhutilNumber($commit_count),
+ $this->getDisplaySymbols($symbols),
+ $this->getDisplayHash($into_commit));
+ }
+
+ echo tsprintf(
+ "\n%!\n%W\n",
+ pht('LARGE WORKING SET'),
+ $message);
+
+ $display_commits = array_merge(
+ array_slice($commit_map, 0, $show_limit),
+ array(null),
+ array_slice($commit_map, -$show_limit));
+
+ echo tsprintf("\n");
+
+ foreach ($display_commits as $commit) {
+ if ($commit === null) {
+ echo tsprintf(
+ " %s\n",
+ pht(
+ '< ... %s more commits ... >',
+ new PhutilNumber($commit_count - ($show_limit * 2))));
+ } else {
+ echo tsprintf(
+ " %s %s\n",
+ $this->getDisplayHash($commit->getHash()),
+ $commit->getDisplaySummary());
+ }
+ }
+
+ $query = pht(
+ 'Land %s commit(s)?',
+ new PhutilNumber($commit_count));
+
+ $this->getWorkflow()
+ ->getPrompt('arc.land.large-working-set')
+ ->setQuery($query)
+ ->execute();
+ }
+
+ // Build the commit objects into a tree.
+ foreach ($commit_map as $commit_hash => $commit) {
+ $parent_map = array();
+ foreach ($commit->getParents() as $parent) {
+ if (isset($commit_map[$parent])) {
+ $parent_map[$parent] = $commit_map[$parent];
+ }
+ }
+ $commit->setParentCommits($parent_map);
+ }
+
+ // Identify the commits which are heads (have no children).
+ $child_map = array();
+ foreach ($commit_map as $commit_hash => $commit) {
+ foreach ($commit->getParents() as $parent) {
+ $child_map[$parent][$commit_hash] = $commit;
+ }
+ }
+
+ foreach ($commit_map as $commit_hash => $commit) {
+ if (isset($child_map[$commit_hash])) {
+ continue;
+ }
+ $commit->setIsHeadCommit(true);
+ }
+
+ return $commit_map;
+ }
+
+ public function execute() {
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+
+ $this->validateArguments();
+
+ $raw_symbols = $this->getSourceRefs();
+ if (!$raw_symbols) {
+ $raw_symbols = $this->getDefaultSymbols();
+ }
+
+ $symbols = array();
+ foreach ($raw_symbols as $raw_symbol) {
+ $symbols[] = id(new ArcanistLandSymbol())
+ ->setSymbol($raw_symbol);
+ }
+
+ $this->resolveSymbols($symbols);
+
+ $onto_remote = $this->selectOntoRemote($symbols);
+ $this->setOntoRemote($onto_remote);
+
+ $onto_refs = $this->selectOntoRefs($symbols);
+ $this->confirmOntoRefs($onto_refs);
+ $this->setOntoRefs($onto_refs);
+
+ $this->selectIntoRemote();
+ $this->selectIntoRef();
+
+ $into_commit = $this->selectIntoCommit();
+ $commit_map = $this->selectCommits($into_commit, $symbols);
+
+ $this->loadRevisionRefs($commit_map);
+
+ // TODO: It's possible we have a list of commits which includes disjoint
+ // groups of commits associated with the same revision, or groups of
+ // commits which do not form a range. We should test that here, since we
+ // can't land commit groups which are not a single contiguous range.
+
+ $revision_groups = array();
+ foreach ($commit_map as $commit_hash => $commit) {
+ $revision_ref = $commit->getRevisionRef();
+
+ if (!$revision_ref) {
+ echo tsprintf(
+ "\n%!\n%W\n\n",
+ pht('UNKNOWN REVISION'),
+ pht(
+ 'Unable to determine which revision is associated with commit '.
+ '"%s". Use "arc diff" to create or update a revision with this '.
+ 'commit, or "--revision" to force selection of a particular '.
+ 'revision.',
+ $this->getDisplayHash($commit_hash)));
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Unable to determine revision for commit "%s".',
+ $this->getDisplayHash($commit_hash)));
+ }
+
+ $revision_groups[$revision_ref->getPHID()][] = $commit;
+ }
+
+ $commit_heads = array();
+ foreach ($commit_map as $commit) {
+ if ($commit->getIsHeadCommit()) {
+ $commit_heads[] = $commit;
+ }
+ }
+
+ $revision_order = array();
+ foreach ($commit_heads as $head) {
+ foreach ($head->getAncestorRevisionPHIDs() as $phid) {
+ $revision_order[$phid] = true;
+ }
+ }
+
+ $revision_groups = array_select_keys(
+ $revision_groups,
+ array_keys($revision_order));
+
+ $sets = array();
+ foreach ($revision_groups as $revision_phid => $group) {
+ $revision_ref = head($group)->getRevisionRef();
+
+ $set = id(new ArcanistLandCommitSet())
+ ->setRevisionRef($revision_ref)
+ ->setCommits($group);
+
+ $sets[$revision_phid] = $set;
+ }
+
+ if (!$this->getShouldPreview()) {
+ $this->confirmImplicitCommits($sets, $symbols);
+ }
+
+ $log->writeStatus(
+ pht('LANDING'),
+ pht('These changes will land:'));
+
+ foreach ($sets as $set) {
+ $this->printCommitSet($set);
+ }
+
+ if ($this->getShouldPreview()) {
+ $log->writeStatus(
+ pht('PREVIEW'),
+ pht('Completed preview of land operation.'));
+ return;
+ }
+
+ $query = pht('Land these changes?');
+ $this->getWorkflow()
+ ->getPrompt('arc.land.confirm')
+ ->setQuery($query)
+ ->execute();
+
+ $this->confirmRevisions($sets);
+
+ $workflow = $this->getWorkflow();
+
+ $is_incremental = $this->getIsIncremental();
+ $is_hold = $this->getShouldHold();
+ $is_keep = $this->getShouldKeep();
+
+ $local_state = $api->newLocalState()
+ ->setWorkflow($workflow)
+ ->saveLocalState();
+
+ $seen_into = array();
+ try {
+ $last_key = last_key($sets);
+
+ $need_cascade = array();
+ $need_prune = array();
+
+ foreach ($sets as $set_key => $set) {
+ // Add these first, so we don't add them multiple times if we need
+ // to retry a push.
+ $need_prune[] = $set;
+ $need_cascade[] = $set;
+
+ while (true) {
+ $into_commit = $this->executeMerge($set, $into_commit);
+
+ if ($is_hold) {
+ $should_push = false;
+ } else if ($is_incremental) {
+ $should_push = true;
+ } else {
+ $is_last = ($set_key === $last_key);
+ $should_push = $is_last;
+ }
+
+ if ($should_push) {
+ try {
+ $this->pushChange($into_commit);
+ } catch (Exception $ex) {
+
+ // TODO: If the push fails, fetch and retry if the remote ref
+ // has moved ahead of us.
+
+ if ($this->getIntoLocal()) {
+ $can_retry = false;
+ } else if ($this->getIntoEmpty()) {
+ $can_retry = false;
+ } else if ($this->getIntoRemote() !== $this->getOntoRemote()) {
+ $can_retry = false;
+ } else {
+ $can_retry = false;
+ }
+
+ if ($can_retry) {
+ // New commit state here
+ $into_commit = '..';
+ continue;
+ }
+
+ throw $ex;
+ }
+
+ if ($need_cascade) {
+
+ // NOTE: We cascade each set we've pushed, but we're going to
+ // cascade them from most recent to least recent. This way,
+ // branches which descend from more recent changes only cascade
+ // once, directly in to the correct state.
+
+ $need_cascade = array_reverse($need_cascade);
+ foreach ($need_cascade as $cascade_set) {
+ $this->cascadeState($set, $into_commit);
+ }
+ $need_cascade = array();
+ }
+
+ if (!$is_keep) {
+ $this->pruneBranches($need_prune);
+ $need_prune = array();
+ }
+ }
+
+ break;
+ }
+ }
+
+ if ($is_hold) {
+ $this->didHoldChanges();
+ $this->discardLocalState();
+ } else {
+ $this->reconcileLocalState($into_commit, $local_state);
+ }
+
+ // TODO: Restore this.
+ // $this->getWorkflow()->askForRepositoryUpdate();
+
+ $log->writeSuccess(
+ pht('DONE'),
+ pht('Landed changes.'));
+ } catch (Exception $ex) {
+ $local_state->restoreLocalState();
+ throw $ex;
+ } catch (Throwable $ex) {
+ $local_state->restoreLocalState();
+ throw $ex;
+ }
+ }
+
+
+ protected function validateArguments() {
+ $log = $this->getLogEngine();
+
+ $into_local = $this->getIntoLocalArgument();
+ $into_empty = $this->getIntoEmptyArgument();
+ $into_remote = $this->getIntoRemoteArgument();
+
+ $into_count = 0;
+ if ($into_remote !== null) {
+ $into_count++;
+ }
+
+ if ($into_local) {
+ $into_count++;
+ }
+
+ if ($into_empty) {
+ $into_count++;
+ }
+
+ if ($into_count > 1) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Arguments "--into-local", "--into-remote", and "--into-empty" '.
+ 'are mutually exclusive.'));
+ }
+
+ $into = $this->getIntoArgument();
+ if ($into && ($into_empty !== null)) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Arguments "--into" and "--into-empty" are mutually exclusive.'));
+ }
+
+ $strategy = $this->selectMergeStrategy();
+ $this->setStrategy($strategy);
+
+ // Build the symbol ref here (which validates the format of the symbol),
+ // but don't load the object until later on when we're sure we actually
+ // need it, since loading it requires a relatively expensive Conduit call.
+ $revision_symbol = $this->getRevisionSymbol();
+ if ($revision_symbol) {
+ $symbol_ref = id(new ArcanistRevisionSymbolRef())
+ ->setSymbol($revision_symbol);
+ $this->setRevisionSymbolRef($symbol_ref);
+ }
+
+ // NOTE: When a user provides: "--hold" or "--preview"; and "--incremental"
+ // or various combinations of remote flags, the flags affecting push/remote
+ // behavior have no effect.
+
+ // These combinations are allowed to support adding "--preview" or "--hold"
+ // to any command to run the same command with fewer side effects.
+ }
+
+ abstract protected function getDefaultSymbols();
+ abstract protected function resolveSymbols(array $symbols);
+ abstract protected function selectOntoRemote(array $symbols);
+ abstract protected function selectOntoRefs(array $symbols);
+ abstract protected function confirmOntoRefs(array $onto_refs);
+ abstract protected function selectIntoRemote();
+ abstract protected function selectIntoRef();
+ abstract protected function selectIntoCommit();
+ abstract protected function selectCommits($into_commit, array $symbols);
+ abstract protected function executeMerge(
+ ArcanistLandCommitSet $set,
+ $into_commit);
+ abstract protected function pushChange($into_commit);
+ abstract protected function cascadeState(
+ ArcanistLandCommitSet $set,
+ $into_commit);
+
+ protected function isSquashStrategy() {
+ return ($this->getStrategy() === 'squash');
+ }
+
+ abstract protected function pruneBranches(array $sets);
+
+ abstract protected function reconcileLocalState(
+ $into_commit,
+ ArcanistRepositoryLocalState $state);
+
+ private function selectMergeStrategy() {
+ $log = $this->getLogEngine();
+
+ $supported_strategies = array(
+ 'merge',
+ 'squash',
+ );
+ $supported_strategies = array_fuse($supported_strategies);
+ $strategy_list = implode(', ', $supported_strategies);
+
+ $strategy = $this->getStrategyArgument();
+ if ($strategy !== null) {
+ if (!isset($supported_strategies[$strategy])) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Merge strategy "%s" specified with "--strategy" is unknown. '.
+ 'Supported merge strategies are: %s.',
+ $strategy,
+ $strategy_list));
+ }
+
+ $log->writeStatus(
+ pht('STRATEGY'),
+ pht(
+ 'Merging with "%s" strategy, selected with "--strategy".',
+ $strategy));
+
+ return $strategy;
+ }
+
+ $strategy_key = 'arc.land.strategy';
+ $strategy = $this->getWorkflow()->getConfig($strategy_key);
+ if ($strategy !== null) {
+ if (!isset($supported_strategies[$strategy])) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Merge strategy "%s" specified in "%s" configuration is '.
+ 'unknown. Supported merge strategies are: %s.',
+ $strategy,
+ $strategy_list));
+ }
+
+ $log->writeStatus(
+ pht('STRATEGY'),
+ pht(
+ 'Merging with "%s" strategy, configured with "%s".',
+ $strategy,
+ $strategy_key));
+
+ return $strategy;
+ }
+
+ $strategy = 'squash';
+
+ $log->writeStatus(
+ pht('STRATEGY'),
+ pht(
+ 'Merging with "%s" strategy, the default strategy.',
+ $strategy));
+
+ return $strategy;
+ }
+
+}
diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php
new file mode 100644
--- /dev/null
+++ b/src/land/engine/ArcanistMercurialLandEngine.php
@@ -0,0 +1,603 @@
+<?php
+
+final class ArcanistMercurialLandEngine
+ extends ArcanistLandEngine {
+
+ protected function getDefaultSymbols() {
+ $api = $this->getRepositoryAPI();
+ $log = $this->getLogEngine();
+
+ $bookmark = $api->getActiveBookmark();
+ if ($bookmark !== null) {
+
+ $log->writeStatus(
+ pht('SOURCE'),
+ pht(
+ 'Landing the active bookmark, "%s".',
+ $bookmark));
+
+ return array($bookmark);
+ }
+
+ $branch = $api->getBranchName();
+ if ($branch !== null) {
+
+ $log->writeStatus(
+ pht('SOURCE'),
+ pht(
+ 'Landing the current branch, "%s".',
+ $branch));
+
+ return array($branch);
+ }
+
+ throw new Exception(pht('TODO: Operate on raw revision.'));
+ }
+
+ protected function resolveSymbols(array $symbols) {
+ assert_instances_of($symbols, 'ArcanistLandSymbol');
+ $api = $this->getRepositoryAPI();
+
+ foreach ($symbols as $symbol) {
+ $raw_symbol = $symbol->getSymbol();
+
+ if ($api->isBookmark($raw_symbol)) {
+ $hash = $api->getBookmarkCommitHash($raw_symbol);
+ $symbol->setCommit($hash);
+
+ // TODO: Set that this is a bookmark?
+
+ continue;
+ }
+
+ if ($api->isBranch($raw_symbol)) {
+ $hash = $api->getBranchCommitHash($raw_symbol);
+ $symbol->setCommit($hash);
+
+ // TODO: Set that this is a branch?
+
+ continue;
+ }
+
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Symbol "%s" is not a bookmark or branch name.',
+ $raw_symbol));
+ }
+ }
+
+ protected function selectOntoRemote(array $symbols) {
+ assert_instances_of($symbols, 'ArcanistLandSymbol');
+ $remote = $this->newOntoRemote($symbols);
+
+ // TODO: Verify this remote actually exists.
+
+ 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) {
+
+ // TODO: Verify that this is a valid path.
+ // 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: Format this nicely with passthru.
+ // TODO: Raise a good error message when the ref does not exist.
+
+ $api->execPassthru(
+ 'pull -b %s -- %s',
+ $target->getRef(),
+ $target->getRemote());
+
+ // TODO: Deal with multiple branch heads.
+
+ list($stdout) = $api->execxLocal(
+ 'log --rev %s --template %s --',
+ hgsprintf(
+ 'last(ancestors(%s) and !outgoing(%s))',
+ $target->getRef(),
+ $target->getRemote()),
+ '{node}');
+
+ return trim($stdout);
+ }
+
+ 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);
+ 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];
+ $commit->addSymbol($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.
+
+ $api->execxLocal(
+ 'push --rev %s -- %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();
+ }
+
+}
diff --git a/src/query/ArcanistMercurialCommitMessageHardpointQuery.php b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php
new file mode 100644
--- /dev/null
+++ b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php
@@ -0,0 +1,36 @@
+<?php
+
+final class ArcanistMercurialCommitMessageHardpointQuery
+ extends ArcanistWorkflowMercurialHardpointQuery {
+
+ public function getHardpoints() {
+ return array(
+ ArcanistCommitRef::HARDPOINT_MESSAGE,
+ );
+ }
+
+ protected function canLoadRef(ArcanistRef $ref) {
+ return ($ref instanceof ArcanistCommitRef);
+ }
+
+ public function loadHardpoint(array $refs, $hardpoint) {
+ $api = $this->getRepositoryAPI();
+
+ $hashes = mpull($refs, 'getCommitHash');
+ $unique_hashes = array_fuse($hashes);
+
+ // TODO: Batch this properly and make it future oriented.
+
+ $messages = array();
+ foreach ($unique_hashes as $unique_hash) {
+ $messages[$unique_hash] = $api->getCommitMessage($unique_hash);
+ }
+
+ foreach ($hashes as $ref_key => $hash) {
+ $hashes[$ref_key] = $messages[$hash];
+ }
+
+ yield $this->yieldMap($hashes);
+ }
+
+}
diff --git a/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php
new file mode 100644
--- /dev/null
+++ b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php
@@ -0,0 +1,76 @@
+<?php
+
+final class ArcanistMercurialWorkingCopyRevisionHardpointQuery
+ extends ArcanistWorkflowMercurialHardpointQuery {
+
+ public function getHardpoints() {
+ return array(
+ ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS,
+ );
+ }
+
+ protected function canLoadRef(ArcanistRef $ref) {
+ return ($ref instanceof ArcanistWorkingCopyStateRef);
+ }
+
+ public function loadHardpoint(array $refs, $hardpoint) {
+ yield $this->yieldRequests(
+ $refs,
+ array(
+ ArcanistWorkingCopyStateRef::HARDPOINT_COMMITREF,
+ ));
+
+ // TODO: This has a lot in common with the Git query in the same role.
+
+ $hashes = array();
+ $map = array();
+ foreach ($refs as $ref_key => $ref) {
+ $commit = $ref->getCommitRef();
+
+ $commit_hashes = array();
+
+ $commit_hashes[] = array(
+ 'hgcm',
+ $commit->getCommitHash(),
+ );
+
+ foreach ($commit_hashes as $hash) {
+ $hashes[] = $hash;
+ $hash_key = $this->getHashKey($hash);
+ $map[$hash_key][$ref_key] = $ref;
+ }
+ }
+
+ $results = array_fill_keys(array_keys($refs), array());
+ if ($hashes) {
+ $revisions = (yield $this->yieldConduit(
+ 'differential.query',
+ array(
+ 'commitHashes' => $hashes,
+ )));
+
+ foreach ($revisions as $dict) {
+ $revision_hashes = idx($dict, 'hashes');
+ if (!$revision_hashes) {
+ continue;
+ }
+
+ $revision_ref = ArcanistRevisionRef::newFromConduitQuery($dict);
+ foreach ($revision_hashes as $revision_hash) {
+ $hash_key = $this->getHashKey($revision_hash);
+ $state_refs = idx($map, $hash_key, array());
+ foreach ($state_refs as $ref_key => $state_ref) {
+ $results[$ref_key][] = $revision_ref;
+ }
+ }
+ }
+ }
+
+ yield $this->yieldMap($results);
+ }
+
+ private function getHashKey(array $hash) {
+ return $hash[0].':'.$hash[1];
+ }
+
+}
diff --git a/src/query/ArcanistWorkflowMercurialHardpointQuery.php b/src/query/ArcanistWorkflowMercurialHardpointQuery.php
new file mode 100644
--- /dev/null
+++ b/src/query/ArcanistWorkflowMercurialHardpointQuery.php
@@ -0,0 +1,11 @@
+<?php
+
+abstract class ArcanistWorkflowMercurialHardpointQuery
+ extends ArcanistRuntimeHardpointQuery {
+
+ final protected function canLoadHardpoint() {
+ $api = $this->getRepositoryAPI();
+ return ($api instanceof ArcanistMercurialAPI);
+ }
+
+}
diff --git a/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php
--- a/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php
+++ b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php
@@ -29,7 +29,7 @@
$buildables = (yield $this->yieldConduitSearch(
'harbormaster.buildable.search',
array(
- 'objectPHIDs' => $diff_map,
+ 'objectPHIDs' => array_values($diff_map),
'manual' => false,
)));
diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1564,6 +1564,11 @@
return ($uri !== null);
}
+ public function isFetchableRemote($remote_name) {
+ $uri = $this->getGitRemoteFetchURI($remote_name);
+ return ($uri !== null);
+ }
+
private function getGitRemoteFetchURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = false);
}
@@ -1739,4 +1744,13 @@
return false;
}
+ protected function newLandEngine() {
+ return new ArcanistGitLandEngine();
+ }
+
+ public function newLocalState() {
+ return id(new ArcanistGitLocalState())
+ ->setRepositoryAPI($this);
+ }
+
}
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -534,6 +534,8 @@
}
public function getAllBranches() {
+ // TODO: This is wrong, and returns bookmarks.
+
list($branch_info) = $this->execxLocal('bookmarks');
if (trim($branch_info) == 'no bookmarks set') {
return array();
@@ -548,10 +550,14 @@
$return = array();
foreach ($matches as $match) {
- list(, $current, $name) = $match;
+ list(, $current, $name, $hash) = $match;
+
+ list($id, $hash) = explode(':', $hash);
+
$return[] = array(
'current' => (bool)$current,
'name' => rtrim($name),
+ 'hash' => $hash,
);
}
return $return;
@@ -562,9 +568,13 @@
$refs = array();
foreach ($branches as $branch) {
+ $commit_ref = $this->newCommitRef()
+ ->setCommitHash($branch['hash']);
+
$refs[] = $this->newBranchRef()
->setBranchName($branch['name'])
- ->setIsCurrentBranch($branch['current']);
+ ->setIsCurrentBranch($branch['current'])
+ ->attachCommitRef($commit_ref);
}
return $refs;
@@ -1064,6 +1074,46 @@
return $bookmarks;
}
+ public function getBookmarkCommitHash($name) {
+ // TODO: Cache this.
+
+ $bookmarks = $this->getBookmarks($name);
+ $bookmarks = ipull($bookmarks, null, 'name');
+
+ foreach ($bookmarks as $bookmark) {
+ if ($bookmark['name'] === $name) {
+ return $bookmark['revision'];
+ }
+ }
+
+ throw new Exception(pht('No bookmark "%s".', $name));
+ }
+
+ public function getBranchCommitHash($name) {
+ // TODO: Cache this.
+ // TODO: This won't work when there are multiple branch heads with the
+ // same name.
+
+ $branches = $this->getBranches($name);
+
+ $heads = array();
+ foreach ($branches as $branch) {
+ if ($branch['name'] === $name) {
+ $heads[] = $branch;
+ }
+ }
+
+ if (count($heads) === 1) {
+ return idx(head($heads), 'revision');
+ }
+
+ if (!$heads) {
+ throw new Exception(pht('No branch "%s".', $name));
+ }
+
+ throw new Exception(pht('Too many branch heads for "%s".', $name));
+ }
+
private function splitBranchOrBookmarkLine($line) {
// branches and bookmarks are printed in the format:
// default 0:a5ead76cdf85 (inactive)
@@ -1108,4 +1158,14 @@
return $env;
}
+ protected function newLandEngine() {
+ return new ArcanistMercurialLandEngine();
+ }
+
+ public function newLocalState() {
+ return id(new ArcanistMercurialLocalState())
+ ->setRepositoryAPI($this);
+ }
+
+
}
diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php
--- a/src/repository/api/ArcanistRepositoryAPI.php
+++ b/src/repository/api/ArcanistRepositoryAPI.php
@@ -734,4 +734,19 @@
final public function newBranchRef() {
return new ArcanistBranchRef();
}
+
+ final public function getLandEngine() {
+ $engine = $this->newLandEngine();
+
+ if ($engine) {
+ $engine->setRepositoryAPI($this);
+ }
+
+ return $engine;
+ }
+
+ protected function newLandEngine() {
+ return null;
+ }
+
}
diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php
new file mode 100644
--- /dev/null
+++ b/src/repository/state/ArcanistMercurialLocalState.php
@@ -0,0 +1,60 @@
+<?php
+
+final class ArcanistMercurialLocalState
+ extends ArcanistRepositoryLocalState {
+
+ private $localCommit;
+ private $localRef;
+ private $localPath;
+
+ public function getLocalRef() {
+ return $this->localRef;
+ }
+
+ public function getLocalPath() {
+ return $this->localPath;
+ }
+
+ protected function executeSaveLocalState() {
+ $api = $this->getRepositoryAPI();
+ // TODO: Fix this.
+ }
+
+ 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() {
+ // Depends on stash extension.
+ return false;
+ }
+
+ protected function getIgnoreHints() {
+ // TODO: Provide this.
+ return array();
+ }
+
+ protected function saveStash() {
+ return null;
+ }
+
+ protected function restoreStash($stash_ref) {
+ return null;
+ }
+
+ protected function discardStash($stash_ref) {
+ return null;
+ }
+
+}
diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php
--- a/src/repository/state/ArcanistRepositoryLocalState.php
+++ b/src/repository/state/ArcanistRepositoryLocalState.php
@@ -151,6 +151,9 @@
$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;
}
diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php
--- a/src/workflow/ArcanistLandWorkflow.php
+++ b/src/workflow/ArcanistLandWorkflow.php
@@ -3,1608 +3,281 @@
/**
* Lands a branch by rebasing, merging and amending it.
*/
-final class ArcanistLandWorkflow extends ArcanistWorkflow {
-
- private $isGit;
- private $isGitSvn;
- private $isHg;
- private $isHgSvn;
-
- private $oldBranch;
- private $branch;
- private $onto;
- private $ontoRemoteBranch;
- private $remote;
- private $useSquash;
- private $keepBranch;
- private $branchType;
- private $ontoType;
- private $preview;
-
- private $revision;
- private $messageFile;
-
- const REFTYPE_BRANCH = 'branch';
- const REFTYPE_BOOKMARK = 'bookmark';
-
- public function getRevisionDict() {
- return $this->revision;
- }
+final class ArcanistLandWorkflow
+ extends ArcanistArcWorkflow {
public function getWorkflowName() {
return 'land';
}
- public function getCommandSynopses() {
- return phutil_console_format(<<<EOTEXT
- **land** [__options__] [__ref__]
-EOTEXT
- );
- }
-
- public function getCommandHelp() {
- return phutil_console_format(<<<EOTEXT
- Supports: git, git/p4, hg
+ public function getWorkflowInformation() {
+ $help = pht(<<<EOTEXT
+Supports: git, git/p4, hg
- Publish an accepted revision after review. This command is the last
- step in the standard Differential code review workflow.
+Publish an accepted revision after review. This command is the last
+step in the standard Differential code review workflow.
- This command merges and pushes changes associated with an accepted
- revision that are currently sitting in __ref__, which is usually the
- name of a local branch. Without __ref__, the current working copy
- state will be used.
+This command merges and pushes changes associated with an accepted
+revision that are currently sitting in __ref__, which is usually the
+name of a local branch. Without __ref__, the current working copy
+state will be used.
- Under Git: branches, tags, and arbitrary commits (detached HEADs)
- may be landed.
+Under Git: branches, tags, and arbitrary commits (detached HEADs)
+may be landed.
- Under Git/Perforce: branches, tags, and arbitrary commits may
- be submitted.
+Under Git/Perforce: branches, tags, and arbitrary commits may
+be submitted.
- Under Mercurial: branches and bookmarks may be landed, but only
- onto a target of the same type. See T3855.
+Under Mercurial: branches and bookmarks may be landed, but only
+onto a target of the same type. See T3855.
- The workflow selects a target branch to land onto and a remote where
- the change will be pushed to.
+The workflow selects a target branch to land onto and a remote where
+the change will be pushed to.
- A target branch is selected by examining these sources in order:
+A target branch is selected by examining these sources in order:
- - the **--onto** flag;
- - the upstream of the branch targeted by the land operation,
- recursively (Git only);
- - the __arc.land.onto.default__ configuration setting;
- - or by falling back to a standard default:
- - "master" in Git;
- - "default" in Mercurial.
+ - the **--onto** flag;
+ - the upstream of the branch targeted by the land operation,
+ recursively (Git only);
+ - the __arc.land.onto.default__ configuration setting;
+ - or by falling back to a standard default:
+ - "master" in Git;
+ - "default" in Mercurial.
- A remote is selected by examining these sources in order:
+A remote is selected by examining these sources in order:
- - the **--remote** flag;
- - the upstream of the current branch, recursively (Git only);
- - the special "p4" remote which indicates a repository has
- been synchronized with Perforce (Git only);
- - or by falling back to a standard default:
- - "origin" in Git;
- - the default remote in Mercurial.
+ - the **--remote** flag;
+ - the upstream of the current branch, recursively (Git only);
+ - the special "p4" remote which indicates a repository has
+ been synchronized with Perforce (Git only);
+ - or by falling back to a standard default:
+ - "origin" in Git;
+ - the default remote in Mercurial.
- After selecting a target branch and a remote, the commits which will
- be landed are printed.
+After selecting a target branch and a remote, the commits which will
+be landed are printed.
- With **--preview**, execution stops here, before the change is
- merged.
+With **--preview**, execution stops here, before the change is
+merged.
- The change is merged with the changes in the target branch,
- following these rules:
+The change is merged with the changes in the target branch,
+following these rules:
- In repositories with mutable history or with **--squash**, this will
- perform a squash merge (the entire branch will be represented as one
- commit after the merge).
+In repositories with mutable history or with **--squash**, this will
+perform a squash merge (the entire branch will be represented as one
+commit after the merge).
- In repositories with immutable history or with **--merge**, this will
- perform a strict merge (a merge commit will always be created, and
- local commits will be preserved).
+In repositories with immutable history or with **--merge**, this will
+perform a strict merge (a merge commit will always be created, and
+local commits will be preserved).
- The resulting commit will be given an up-to-date commit message
- describing the final state of the revision in Differential.
+The resulting commit will be given an up-to-date commit message
+describing the final state of the revision in Differential.
- In Git, the merge occurs in a detached HEAD. The local branch
- reference (if one exists) is not updated yet.
+In Git, the merge occurs in a detached HEAD. The local branch
+reference (if one exists) is not updated yet.
- With **--hold**, execution stops here, before the change is pushed.
+With **--hold**, execution stops here, before the change is pushed.
- The change is pushed into the remote.
+The change is pushed into the remote.
- Consulting mystical sources of power, the workflow makes a guess
- about what state you wanted to end up in after the process finishes
- and the working copy is put into that state.
-
- The branch which was landed is deleted, unless the **--keep-branch**
- flag was passed or the landing branch is the same as the target
- branch.
+Consulting mystical sources of power, the workflow makes a guess
+about what state you wanted to end up in after the process finishes
+and the working copy is put into that state.
+The branch which was landed is deleted, unless the **--keep-branch**
+flag was passed or the landing branch is the same as the target
+branch.
EOTEXT
);
- }
-
- public function requiresWorkingCopy() {
- return true;
- }
-
- public function requiresConduit() {
- return true;
- }
- public function requiresAuthentication() {
- return true;
- }
+ // TODO: Add command synopses.
- public function requiresRepositoryAPI() {
- return true;
+ return $this->newWorkflowInformation()
+ ->setHelp($help);
}
- public function getArguments() {
+ public function getWorkflowArguments() {
return array(
- 'onto' => array(
- 'param' => 'master',
- 'help' => pht(
- "Land feature branch onto a branch other than the default ".
- "('master' in git, 'default' in hg). You can change the default ".
- "by setting '%s' with `%s` or for the entire project in %s.",
- 'arc.land.onto.default',
- 'arc set-config',
- '.arcconfig'),
- ),
- 'hold' => array(
- 'help' => pht(
- 'Prepare the change to be pushed, but do not actually push it.'),
- ),
- 'keep-branch' => array(
- 'help' => pht(
- 'Keep the feature branch after pushing changes to the '.
- 'remote (by default, it is deleted).'),
- ),
- 'remote' => array(
- 'param' => 'origin',
- 'help' => pht(
- 'Push to a remote other than the default.'),
- ),
- 'merge' => array(
- 'help' => pht(
- 'Perform a %s merge, not a %s merge. If the project '.
- 'is marked as having an immutable history, this is the default '.
- 'behavior.',
- '--no-ff',
- '--squash'),
- 'supports' => array(
- 'git',
- ),
- 'nosupport' => array(
- 'hg' => pht(
- 'Use the %s strategy when landing in mercurial.',
- '--squash'),
- ),
- ),
- 'squash' => array(
- 'help' => pht(
- 'Perform a %s merge, not a %s merge. If the project is '.
- 'marked as having a mutable history, this is the default behavior.',
- '--squash',
- '--no-ff'),
- 'conflicts' => array(
- 'merge' => pht(
- '%s and %s are conflicting merge strategies.',
- '--merge',
- '--squash'),
- ),
- ),
- 'delete-remote' => array(
- 'help' => pht(
- 'Delete the feature branch in the remote after landing it.'),
- 'conflicts' => array(
- 'keep-branch' => true,
- ),
- 'supports' => array(
- 'hg',
- ),
- ),
- 'revision' => array(
- 'param' => 'id',
- 'help' => pht(
- 'Use the message from a specific revision, rather than '.
- 'inferring the revision based on branch content.'),
- ),
- 'preview' => array(
- 'help' => pht(
- 'Prints the commits that would be landed. Does not '.
- 'actually modify or land the commits.'),
- ),
- '*' => 'branch',
+ $this->newWorkflowArgument('hold')
+ ->setHelp(
+ pht(
+ 'Prepare the change to be pushed, but do not actually push it.')),
+ $this->newWorkflowArgument('keep-branches')
+ ->setHelp(
+ pht(
+ 'Keep local branches around after changes are pushed. By '.
+ 'default, local branches are deleted after they land.')),
+ $this->newWorkflowArgument('onto-remote')
+ ->setParameter('remote-name')
+ ->setHelp(pht('Push to a remote other than the default.')),
+
+ // TODO: Formally allow flags to be bound to related configuration
+ // for documentation, e.g. "setRelatedConfiguration('arc.land.onto')".
+
+ $this->newWorkflowArgument('onto')
+ ->setParameter('branch-name')
+ ->setRepeatable(true)
+ ->setHelp(
+ pht(
+ 'After merging, push changes onto a specified branch. '.
+ 'Specifying this flag multiple times will push multiple '.
+ 'branches.')),
+ $this->newWorkflowArgument('strategy')
+ ->setParameter('strategy-name')
+ ->setHelp(
+ pht(
+ // TODO: Improve this.
+ 'Merge using a particular strategy.')),
+ $this->newWorkflowArgument('revision')
+ ->setParameter('revision-identifier')
+ ->setHelp(
+ pht(
+ 'Land a specific revision, rather than determining the revisions '.
+ 'from the commits that are landing.')),
+ $this->newWorkflowArgument('preview')
+ ->setHelp(
+ pht(
+ 'Shows the changes that will land. Does not modify the working '.
+ 'copy or the remote.')),
+ $this->newWorkflowArgument('into')
+ ->setParameter('commit-ref')
+ ->setHelp(
+ pht(
+ 'Specifies 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(
+ pht(
+ 'When landing multiple revisions at once, push and rebase '.
+ 'after each operation instead of waiting until all merges '.
+ 'are completed. This is slower than the default behavior and '.
+ 'not atomic, but may make it easier to resolve conflicts and '.
+ 'land complicated changes by letting you make progress one '.
+ 'step at a time.')),
+ $this->newWorkflowArgument('ref')
+ ->setWildcard(true),
);
}
- public function run() {
- $this->readArguments();
-
- $engine = null;
- if ($this->isGit && !$this->isGitSvn) {
- $engine = new ArcanistGitLandEngine();
- }
-
- if ($engine) {
- $should_hold = $this->getArgument('hold');
- $remote_arg = $this->getArgument('remote');
- $onto_arg = $this->getArgument('onto');
-
- $engine
- ->setWorkflow($this)
- ->setRepositoryAPI($this->getRepositoryAPI())
- ->setSourceRef($this->branch)
- ->setShouldHold($should_hold)
- ->setShouldKeep($this->keepBranch)
- ->setShouldSquash($this->useSquash)
- ->setShouldPreview($this->preview)
- ->setRemoteArgument($remote_arg)
- ->setOntoArgument($onto_arg)
- ->setBuildMessageCallback(array($this, 'buildEngineMessage'));
-
- // The goal here is to raise errors with flags early (which is cheap),
- // before we test if the working copy is clean (which can be slow). This
- // could probably be structured more cleanly.
-
- $engine->parseArguments();
-
- // This must be configured or we fail later inside "buildEngineMessage()".
- // This is less than ideal.
- $this->ontoRemoteBranch = sprintf(
- '%s/%s',
- $engine->getTargetRemote(),
- $engine->getTargetOnto());
-
- $this->requireCleanWorkingCopy();
- $engine->execute();
-
- if (!$should_hold && !$this->preview) {
- $this->didPush();
- }
-
- return 0;
- }
-
- $this->validate();
-
- try {
- $this->pullFromRemote();
- } catch (Exception $ex) {
- $this->restoreBranch();
- throw $ex;
- }
-
- $this->printPendingCommits();
- if ($this->preview) {
- $this->restoreBranch();
- return 0;
- }
-
- $this->checkoutBranch();
- $this->findRevision();
-
- if ($this->useSquash) {
- $this->rebase();
- $this->squash();
- } else {
- $this->merge();
- }
-
- $this->push();
-
- if (!$this->keepBranch) {
- $this->cleanupBranch();
- }
-
- if ($this->oldBranch != $this->onto) {
- // If we were on some branch A and the user ran "arc land B",
- // switch back to A.
- if ($this->keepBranch || $this->oldBranch != $this->branch) {
- $this->restoreBranch();
- }
- }
-
- echo pht('Done.'), "\n";
-
- return 0;
- }
-
- private function getUpstreamMatching($branch, $pattern) {
- if ($this->isGit) {
- $repository_api = $this->getRepositoryAPI();
- list($err, $fullname) = $repository_api->execManualLocal(
- 'rev-parse --symbolic-full-name %s@{upstream}',
- $branch);
- if (!$err) {
- $matches = null;
- if (preg_match($pattern, $fullname, $matches)) {
- return last($matches);
- }
- }
- }
- return null;
- }
-
- private function getGitSvnTrunk() {
- if (!$this->isGitSvn) {
- return null;
- }
-
- // See T13293, this depends on the options passed when cloning.
- // On any error we return `trunk`, which was the previous default.
-
- $repository_api = $this->getRepositoryAPI();
- list($err, $refspec) = $repository_api->execManualLocal(
- 'config svn-remote.svn.fetch');
-
- if ($err) {
- return 'trunk';
- }
-
- $refspec = rtrim(substr($refspec, strrpos($refspec, ':') + 1));
-
- $prefix = 'refs/remotes/';
- if (substr($refspec, 0, strlen($prefix)) !== $prefix) {
- return 'trunk';
- }
-
- $refspec = substr($refspec, strlen($prefix));
- return $refspec;
- }
-
- private function readArguments() {
- $repository_api = $this->getRepositoryAPI();
- $this->isGit = $repository_api instanceof ArcanistGitAPI;
- $this->isHg = $repository_api instanceof ArcanistMercurialAPI;
-
- if ($this->isGit) {
- $repository = $this->loadProjectRepository();
- $this->isGitSvn = (idx($repository, 'vcs') == 'svn');
- }
-
- if ($this->isHg) {
- $this->isHgSvn = $repository_api->isHgSubversionRepo();
- }
-
- $branch = $this->getArgument('branch');
- if (empty($branch)) {
- $branch = $this->getBranchOrBookmark();
- if ($branch !== null) {
- $this->branchType = $this->getBranchType($branch);
-
- // TODO: This message is misleading when landing a detached head or
- // a tag in Git.
-
- echo pht("Landing current %s '%s'.", $this->branchType, $branch), "\n";
- $branch = array($branch);
- }
- }
-
- if (count($branch) !== 1) {
- throw new ArcanistUsageException(
- pht('Specify exactly one branch or bookmark to land changes from.'));
- }
- $this->branch = head($branch);
- $this->keepBranch = $this->getArgument('keep-branch');
-
- $this->preview = $this->getArgument('preview');
-
- if (!$this->branchType) {
- $this->branchType = $this->getBranchType($this->branch);
- }
-
- $onto_default = $this->isGit ? 'master' : 'default';
- $onto_default = nonempty(
- $this->getConfigFromAnySource('arc.land.onto.default'),
- $onto_default);
- $onto_default = coalesce(
- $this->getUpstreamMatching($this->branch, '/^refs\/heads\/(.+)$/'),
- $onto_default);
- $this->onto = $this->getArgument('onto', $onto_default);
- $this->ontoType = $this->getBranchType($this->onto);
-
- $remote_default = $this->isGit ? 'origin' : '';
- $remote_default = coalesce(
- $this->getUpstreamMatching($this->onto, '/^refs\/remotes\/(.+?)\//'),
- $remote_default);
- $this->remote = $this->getArgument('remote', $remote_default);
-
- if ($this->getArgument('merge')) {
- $this->useSquash = false;
- } else if ($this->getArgument('squash')) {
- $this->useSquash = true;
- } else {
- $this->useSquash = !$this->isHistoryImmutable();
- }
-
- $this->ontoRemoteBranch = $this->onto;
- if ($this->isGitSvn) {
- $this->ontoRemoteBranch = $this->getGitSvnTrunk();
- } else if ($this->isGit) {
- $this->ontoRemoteBranch = $this->remote.'/'.$this->onto;
- }
-
- $this->oldBranch = $this->getBranchOrBookmark();
- }
-
- private function validate() {
- $repository_api = $this->getRepositoryAPI();
-
- if ($this->onto == $this->branch) {
- $message = pht(
- "You can not land a %s onto itself -- you are trying ".
- "to land '%s' onto '%s'. For more information on how to push ".
- "changes, see 'Pushing and Closing Revisions' in 'Arcanist User ".
- "Guide: arc diff' in the documentation.",
- $this->branchType,
- $this->branch,
- $this->onto);
- if (!$this->isHistoryImmutable()) {
- $message .= ' '.pht("You may be able to '%s' instead.", 'arc amend');
- }
- throw new ArcanistUsageException($message);
- }
-
- if ($this->isHg) {
- if ($this->useSquash) {
- if (!$repository_api->supportsRebase()) {
- throw new ArcanistUsageException(
- pht(
- 'You must enable the rebase extension to use the %s strategy.',
- '--squash'));
- }
- }
-
- if ($this->branchType != $this->ontoType) {
- throw new ArcanistUsageException(pht(
- 'Source %s is a %s but destination %s is a %s. When landing a '.
- '%s, the destination must also be a %s. Use %s to specify a %s, '.
- 'or set %s in %s.',
- $this->branch,
- $this->branchType,
- $this->onto,
- $this->ontoType,
- $this->branchType,
- $this->branchType,
- '--onto',
- $this->branchType,
- 'arc.land.onto.default',
- '.arcconfig'));
- }
- }
-
- if ($this->isGit) {
- list($err) = $repository_api->execManualLocal(
- 'rev-parse --verify %s',
- $this->branch);
-
- if ($err) {
- throw new ArcanistUsageException(
- pht("Branch '%s' does not exist.", $this->branch));
- }
- }
-
- $this->requireCleanWorkingCopy();
- }
-
- private function checkoutBranch() {
- $repository_api = $this->getRepositoryAPI();
- if ($this->getBranchOrBookmark() != $this->branch) {
- $repository_api->execxLocal('checkout %s', $this->branch);
- }
-
- switch ($this->branchType) {
- case self::REFTYPE_BOOKMARK:
- $message = pht(
- 'Switched to bookmark **%s**. Identifying and merging...',
- $this->branch);
- break;
- case self::REFTYPE_BRANCH:
- default:
- $message = pht(
- 'Switched to branch **%s**. Identifying and merging...',
- $this->branch);
- break;
- }
-
- echo phutil_console_format($message."\n");
- }
-
- private function printPendingCommits() {
- $repository_api = $this->getRepositoryAPI();
-
- if ($repository_api instanceof ArcanistGitAPI) {
- list($out) = $repository_api->execxLocal(
- 'log --oneline %s %s --',
- $this->branch,
- '^'.$this->onto);
- } else if ($repository_api instanceof ArcanistMercurialAPI) {
- $common_ancestor = $repository_api->getCanonicalRevisionName(
- hgsprintf('ancestor(%s,%s)',
- $this->onto,
- $this->branch));
-
- $branch_range = hgsprintf(
- 'reverse((%s::%s) - %s)',
- $common_ancestor,
- $this->branch,
- $common_ancestor);
-
- list($out) = $repository_api->execxLocal(
- 'log -r %s --template %s',
- $branch_range,
- '{node|short} {desc|firstline}\n');
- }
-
- if (!trim($out)) {
- $this->restoreBranch();
- throw new ArcanistUsageException(
- pht('No commits to land from %s.', $this->branch));
- }
-
- echo pht("The following commit(s) will be landed:\n\n%s", $out), "\n";
- }
-
- private function findRevision() {
- $repository_api = $this->getRepositoryAPI();
-
- $this->parseBaseCommitArgument(array($this->ontoRemoteBranch));
-
- $revision_id = $this->getArgument('revision');
- if ($revision_id) {
- $revision_id = $this->normalizeRevisionID($revision_id);
- $revisions = $this->getConduit()->callMethodSynchronous(
- 'differential.query',
- array(
- 'ids' => array($revision_id),
- ));
- if (!$revisions) {
- throw new ArcanistUsageException(pht(
- "No such revision '%s'!",
- "D{$revision_id}"));
- }
- } else {
- $revisions = $repository_api->loadWorkingCopyDifferentialRevisions(
- $this->getConduit(),
- array());
- }
-
- if (!count($revisions)) {
- throw new ArcanistUsageException(pht(
- "arc can not identify which revision exists on %s '%s'. Update the ".
- "revision with recent changes to synchronize the %s name and hashes, ".
- "or use '%s' to amend the commit message at HEAD, or use ".
- "'%s' to select a revision explicitly.",
- $this->branchType,
- $this->branch,
- $this->branchType,
- 'arc amend',
- '--revision <id>'));
- } else if (count($revisions) > 1) {
- switch ($this->branchType) {
- case self::REFTYPE_BOOKMARK:
- $message = pht(
- "There are multiple revisions on feature bookmark '%s' which are ".
- "not present on '%s':\n\n".
- "%s\n".
- 'Separate these revisions onto different bookmarks, or use '.
- '--revision <id> to use the commit message from <id> '.
- 'and land them all.',
- $this->branch,
- $this->onto,
- $this->renderRevisionList($revisions));
- break;
- case self::REFTYPE_BRANCH:
- default:
- $message = pht(
- "There are multiple revisions on feature branch '%s' which are ".
- "not present on '%s':\n\n".
- "%s\n".
- 'Separate these revisions onto different branches, or use '.
- '--revision <id> to use the commit message from <id> '.
- 'and land them all.',
- $this->branch,
- $this->onto,
- $this->renderRevisionList($revisions));
- break;
- }
-
- throw new ArcanistUsageException($message);
- }
-
- $this->revision = head($revisions);
-
- $rev_status = $this->revision['status'];
- $rev_id = $this->revision['id'];
- $rev_title = $this->revision['title'];
- $rev_auxiliary = idx($this->revision, 'auxiliary', array());
-
- $full_name = pht('D%d: %s', $rev_id, $rev_title);
-
- if ($this->revision['authorPHID'] != $this->getUserPHID()) {
- $other_author = $this->getConduit()->callMethodSynchronous(
- 'user.query',
- array(
- 'phids' => array($this->revision['authorPHID']),
- ));
- $other_author = ipull($other_author, 'userName', 'phid');
- $other_author = $other_author[$this->revision['authorPHID']];
- $ok = phutil_console_confirm(pht(
- "This %s has revision '%s' but you are not the author. Land this ".
- "revision by %s?",
- $this->branchType,
- $full_name,
- $other_author));
- if (!$ok) {
- throw new ArcanistUserAbortException();
- }
- }
-
- $state_warning = null;
- $state_header = null;
- if ($rev_status == ArcanistDifferentialRevisionStatus::CHANGES_PLANNED) {
- $state_header = pht('REVISION HAS CHANGES PLANNED');
- $state_warning = pht(
- 'The revision you are landing ("%s") is currently in the "%s" state, '.
- 'indicating that you expect to revise it before moving forward.'.
- "\n\n".
- 'Normally, you should resubmit it for review and wait until it is '.
- '"%s" by reviewers before you continue.'.
- "\n\n".
- 'To resubmit the revision for review, either: update the revision '.
- 'with revised changes; or use "Request Review" from the web interface.',
- $full_name,
- pht('Changes Planned'),
- pht('Accepted'));
- } else if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) {
- $state_header = pht('REVISION HAS NOT BEEN ACCEPTED');
- $state_warning = pht(
- 'The revision you are landing ("%s") has not been "%s" by reviewers.',
- $full_name,
- pht('Accepted'));
- }
-
- if ($state_warning !== null) {
- $prompt = pht('Land revision in the wrong state?');
-
- id(new PhutilConsoleBlock())
- ->addParagraph(tsprintf('<bg:yellow>** %s **</bg>', $state_header))
- ->addParagraph(tsprintf('%B', $state_warning))
- ->draw();
-
- $ok = phutil_console_confirm($prompt);
- if (!$ok) {
- throw new ArcanistUserAbortException();
- }
- }
-
- if ($rev_auxiliary) {
- $phids = idx($rev_auxiliary, 'phabricator:depends-on', array());
- if ($phids) {
- $dep_on_revs = $this->getConduit()->callMethodSynchronous(
- 'differential.query',
- array(
- 'phids' => $phids,
- 'status' => 'status-open',
- ));
-
- $open_dep_revs = array();
- foreach ($dep_on_revs as $dep_on_rev) {
- $dep_on_rev_id = $dep_on_rev['id'];
- $dep_on_rev_title = $dep_on_rev['title'];
- $dep_on_rev_status = $dep_on_rev['status'];
- $open_dep_revs[$dep_on_rev_id] = $dep_on_rev_title;
- }
-
- if (!empty($open_dep_revs)) {
- $open_revs = array();
- foreach ($open_dep_revs as $id => $title) {
- $open_revs[] = ' - D'.$id.': '.$title;
- }
- $open_revs = implode("\n", $open_revs);
-
- echo pht(
- "Revision '%s' depends on open revisions:\n\n%s",
- "D{$rev_id}: {$rev_title}",
- $open_revs);
-
- $ok = phutil_console_confirm(pht('Continue anyway?'));
- if (!$ok) {
- throw new ArcanistUserAbortException();
- }
- }
- }
- }
-
- $message = $this->getConduit()->callMethodSynchronous(
- 'differential.getcommitmessage',
- array(
- 'revision_id' => $rev_id,
- ));
-
- $this->messageFile = new TempFile();
- Filesystem::writeFile($this->messageFile, $message);
-
- echo pht(
- "Landing revision '%s'...",
- "D{$rev_id}: {$rev_title}")."\n";
-
- $diff_phid = idx($this->revision, 'activeDiffPHID');
- if ($diff_phid) {
- $this->checkForBuildables($diff_phid);
- }
- }
-
- private function pullFromRemote() {
- $repository_api = $this->getRepositoryAPI();
-
- $local_ahead_of_remote = false;
- if ($this->isGit) {
- $repository_api->execxLocal('checkout %s', $this->onto);
-
- echo phutil_console_format(pht(
- "Switched to branch **%s**. Updating branch...\n",
- $this->onto));
-
- try {
- $repository_api->execxLocal('pull --ff-only --no-stat');
- } catch (CommandException $ex) {
- if (!$this->isGitSvn) {
- throw $ex;
- }
- }
- list($out) = $repository_api->execxLocal(
- 'log %s..%s',
- $this->ontoRemoteBranch,
- $this->onto);
- if (strlen(trim($out))) {
- $local_ahead_of_remote = true;
- } else if ($this->isGitSvn) {
- $repository_api->execxLocal('svn rebase');
- }
-
- } else if ($this->isHg) {
- echo phutil_console_format(pht('Updating **%s**...', $this->onto)."\n");
-
- try {
- list($out, $err) = $repository_api->execxLocal('pull');
-
- $divergedbookmark = $this->onto.'@'.$repository_api->getBranchName();
- if (strpos($err, $divergedbookmark) !== false) {
- throw new ArcanistUsageException(phutil_console_format(pht(
- "Local bookmark **%s** has diverged from the server's **%s** ".
- "(now labeled **%s**). Please resolve this divergence and run ".
- "'%s' again.",
- $this->onto,
- $this->onto,
- $divergedbookmark,
- 'arc land')));
- }
- } catch (CommandException $ex) {
- $err = $ex->getError();
- $stdout = $ex->getStdout();
-
- // Copied from: PhabricatorRepositoryPullLocalDaemon.php
- // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the
- // behavior of "hg pull" to return 1 in case of a successful pull
- // with no changes. This behavior has been reverted, but users who
- // updated between Feb 1, 2012 and Mar 1, 2012 will have the
- // erroring version. Do a dumb test against stdout to check for this
- // possibility.
- // See: https://github.com/phacility/phabricator/issues/101/
-
- // NOTE: Mercurial has translated versions, which translate this error
- // string. In a translated version, the string will be something else,
- // like "aucun changement trouve". There didn't seem to be an easy way
- // to handle this (there are hard ways but this is not a common
- // problem and only creates log spam, not application failures).
- // Assume English.
-
- // TODO: Remove this once we're far enough in the future that
- // deployment of 2.1 is exceedingly rare?
- if ($err != 1 || !preg_match('/no changes found/', $stdout)) {
- throw $ex;
- }
- }
-
- // Pull succeeded. Now make sure master is not on an outgoing change
- if ($repository_api->supportsPhases()) {
- list($out) = $repository_api->execxLocal(
- 'log -r %s --template %s', $this->onto, '{phase}');
- if ($out != 'public') {
- $local_ahead_of_remote = true;
- }
- } else {
- // execManual instead of execx because outgoing returns
- // code 1 when there is nothing outgoing
- list($err, $out) = $repository_api->execManualLocal(
- 'outgoing -r %s',
- $this->onto);
-
- // $err === 0 means something is outgoing
- if ($err === 0) {
- $local_ahead_of_remote = true;
- }
- }
- }
-
- if ($local_ahead_of_remote) {
- throw new ArcanistUsageException(pht(
- "Local %s '%s' is ahead of remote %s '%s', so landing a feature ".
- "%s would push additional changes. Push or reset the changes in '%s' ".
- "before running '%s'.",
- $this->ontoType,
- $this->onto,
- $this->ontoType,
- $this->ontoRemoteBranch,
- $this->ontoType,
- $this->onto,
- 'arc land'));
- }
- }
-
- private function rebase() {
- $repository_api = $this->getRepositoryAPI();
-
- chdir($repository_api->getPath());
- if ($this->isHg) {
- $onto_tip = $repository_api->getCanonicalRevisionName($this->onto);
- $common_ancestor = $repository_api->getCanonicalRevisionName(
- hgsprintf('ancestor(%s, %s)', $this->onto, $this->branch));
-
- // Only rebase if the local branch is not at the tip of the onto branch.
- if ($onto_tip != $common_ancestor) {
- // keep branch here so later we can decide whether to remove it
- $err = $repository_api->execPassthru(
- 'rebase -d %s --keepbranches',
- $this->onto);
- if ($err) {
- echo phutil_console_format("%s\n", pht('Aborting rebase'));
- $repository_api->execManualLocal('rebase --abort');
- $this->restoreBranch();
- throw new ArcanistUsageException(pht(
- "'%s' failed and the rebase was aborted. This is most ".
- "likely due to conflicts. Manually rebase %s onto %s, resolve ".
- "the conflicts, then run '%s' again.",
- sprintf('hg rebase %s', $this->onto),
- $this->branch,
- $this->onto,
- 'arc land'));
- }
- }
- }
-
- $repository_api->reloadWorkingCopy();
- }
-
- private function squash() {
- $repository_api = $this->getRepositoryAPI();
-
- if ($this->isGit) {
- $repository_api->execxLocal('checkout %s', $this->onto);
- $repository_api->execxLocal(
- 'merge --no-stat --squash --ff-only %s',
- $this->branch);
- } else if ($this->isHg) {
- // The hg code is a little more complex than git's because we
- // need to handle the case where the landing branch has child branches:
- // -a--------b master
- // \
- // w--x mybranch
- // \--y subbranch1
- // \--z subbranch2
- //
- // arc land --branch mybranch --onto master :
- // -a--b--wx master
- // \--y subbranch1
- // \--z subbranch2
-
- $branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch);
-
- // At this point $this->onto has been pulled from remote and
- // $this->branch has been rebased on top of onto(by the rebase()
- // function). So we're guaranteed to have onto as an ancestor of branch
- // when we use first((onto::branch)-onto) below.
- $branch_root = $repository_api->getCanonicalRevisionName(
- hgsprintf('first((%s::%s)-%s)',
- $this->onto,
- $this->branch,
- $this->onto));
-
- $branch_range = hgsprintf(
- '(%s::%s)',
- $branch_root,
- $this->branch);
-
- if (!$this->keepBranch) {
- $this->handleAlternateBranches($branch_root, $branch_range);
- }
-
- // Collapse just the landing branch onto master.
- // Leave its children on the original branch.
- $err = $repository_api->execPassthru(
- 'rebase --collapse --keep --logfile %s -r %s -d %s',
- $this->messageFile,
- $branch_range,
- $this->onto);
-
- if ($err) {
- $repository_api->execManualLocal('rebase --abort');
- $this->restoreBranch();
- throw new ArcanistUsageException(
+ protected function newPrompts() {
+ return array(
+ $this->newPrompt('arc.land.large-working-set')
+ ->setDescription(
pht(
- "Squashing the commits under %s failed. ".
- "Manually squash your commits and run '%s' again.",
- $this->branch,
- 'arc land'));
- }
-
- if ($repository_api->isBookmark($this->branch)) {
- // a bug in mercurial means bookmarks end up on the revision prior
- // to the collapse when using --collapse with --keep,
- // so we manually move them to the correct spots
- // see: http://bz.selenic.com/show_bug.cgi?id=3716
- $repository_api->execxLocal(
- 'bookmark -f %s',
- $this->onto);
-
- $repository_api->execxLocal(
- 'bookmark -f %s -r %s',
- $this->branch,
- $branch_rev_id);
- }
-
- // check if the branch had children
- list($output) = $repository_api->execxLocal(
- 'log -r %s --template %s',
- hgsprintf('children(%s)', $this->branch),
- '{node}\n');
-
- $child_branch_roots = phutil_split_lines($output, false);
- $child_branch_roots = array_filter($child_branch_roots);
- if ($child_branch_roots) {
- // move the branch's children onto the collapsed commit
- foreach ($child_branch_roots as $child_root) {
- $repository_api->execxLocal(
- 'rebase -d %s -s %s --keep --keepbranches',
- $this->onto,
- $child_root);
- }
- }
-
- // All the rebases may have moved us to another branch
- // so we move back.
- $repository_api->execxLocal('checkout %s', $this->onto);
- }
- }
-
- /**
- * Detect alternate branches and prompt the user for how to handle
- * them. An alternate branch is a branch that forks from the landing
- * branch prior to the landing branch tip.
- *
- * In a situation like this:
- * -a--------b master
- * \
- * w--x landingbranch
- * \ \-- g subbranch
- * \--y altbranch1
- * \--z altbranch2
- *
- * y and z are alternate branches and will get deleted by the squash,
- * so we need to detect them and ask the user what they want to do.
- *
- * @param string The revision id of the landing branch's root commit.
- * @param string The revset specifying all the commits in the landing branch.
- * @return void
- */
- private function handleAlternateBranches($branch_root, $branch_range) {
- $repository_api = $this->getRepositoryAPI();
-
- // Using the tree in the doccomment, the revset below resolves as follows:
- // 1. roots(descendants(w) - descendants(x) - (w::x))
- // 2. roots({x,g,y,z} - {g} - {w,x})
- // 3. roots({y,z})
- // 4. {y,z}
- $alt_branch_revset = hgsprintf(
- 'roots(descendants(%s)-descendants(%s)-%R)',
- $branch_root,
- $this->branch,
- $branch_range);
- list($alt_branches) = $repository_api->execxLocal(
- 'log --template %s -r %s',
- '{node}\n',
- $alt_branch_revset);
-
- $alt_branches = phutil_split_lines($alt_branches, false);
- $alt_branches = array_filter($alt_branches);
-
- $alt_count = count($alt_branches);
- if ($alt_count > 0) {
- $input = phutil_console_prompt(pht(
- "%s '%s' has %s %s(s) forking off of it that would be deleted ".
- "during a squash. Would you like to keep a non-squashed copy, rebase ".
- "them on top of '%s', or abort and deal with them yourself? ".
- "(k)eep, (r)ebase, (a)bort:",
- ucfirst($this->branchType),
- $this->branch,
- $alt_count,
- $this->branchType,
- $this->branch));
-
- if ($input == 'k' || $input == 'keep') {
- $this->keepBranch = true;
- } else if ($input == 'r' || $input == 'rebase') {
- foreach ($alt_branches as $alt_branch) {
- $repository_api->execxLocal(
- 'rebase --keep --keepbranches -d %s -s %s',
- $this->branch,
- $alt_branch);
- }
- } else if ($input == 'a' || $input == 'abort') {
- $branch_string = implode("\n", $alt_branches);
- echo
- "\n",
+ 'Confirms landing more than %s commit(s) in a single operation.',
+ new PhutilNumber($this->getLargeWorkingSetLimit()))),
+ $this->newPrompt('arc.land.confirm')
+ ->setDescription(
pht(
- "Remove the %s starting at these revisions and run %s again:\n%s",
- $this->branchType.'s',
- $branch_string,
- 'arc land'),
- "\n\n";
- throw new ArcanistUserAbortException();
- } else {
- throw new ArcanistUsageException(
- pht('Invalid choice. Aborting arc land.'));
- }
- }
- }
-
- private function merge() {
- $repository_api = $this->getRepositoryAPI();
-
- // In immutable histories, do a --no-ff merge to force a merge commit with
- // the right message.
- $repository_api->execxLocal('checkout %s', $this->onto);
-
- chdir($repository_api->getPath());
- if ($this->isGit) {
- $err = phutil_passthru(
- 'git merge --no-stat --no-ff --no-commit %s',
- $this->branch);
-
- if ($err) {
- throw new ArcanistUsageException(pht(
- "'%s' failed. Your working copy has been left in a partially ".
- "merged state. You can: abort with '%s'; or follow the ".
- "instructions to complete the merge.",
- 'git merge',
- 'git merge --abort'));
- }
- } else if ($this->isHg) {
- // HG arc land currently doesn't support --merge.
- // When merging a bookmark branch to a master branch that
- // hasn't changed since the fork, mercurial fails to merge.
- // Instead of only working in some cases, we just disable --merge
- // until there is a demand for it.
- // The user should never reach this line, since --merge is
- // forbidden at the command line argument level.
- throw new ArcanistUsageException(
- pht('%s is not currently supported for hg repos.', '--merge'));
- }
- }
-
- private function push() {
- $repository_api = $this->getRepositoryAPI();
-
- // These commands can fail legitimately (e.g. commit hooks)
- try {
- if ($this->isGit) {
- $repository_api->execxLocal('commit -F %s', $this->messageFile);
- if (phutil_is_windows()) {
- // Occasionally on large repositories on Windows, Git can exit with
- // an unclean working copy here. This prevents reverts from being
- // pushed to the remote when this occurs.
- $this->requireCleanWorkingCopy();
- }
- } else if ($this->isHg) {
- // hg rebase produces a commit earlier as part of rebase
- if (!$this->useSquash) {
- $repository_api->execxLocal(
- 'commit --logfile %s',
- $this->messageFile);
- }
- }
- // We dispatch this event so we can run checks on the merged revision,
- // right before it gets pushed out. It's easier to do this in arc land
- // than to try to hook into git/hg.
- $this->didCommitMerge();
- } catch (Exception $ex) {
- $this->executeCleanupAfterFailedPush();
- throw $ex;
- }
-
- if ($this->getArgument('hold')) {
- echo phutil_console_format(pht(
- 'Holding change in **%s**: it has NOT been pushed yet.',
- $this->onto)."\n");
- } else {
- echo pht('Pushing change...'), "\n\n";
-
- chdir($repository_api->getPath());
-
- if ($this->isGitSvn) {
- $err = phutil_passthru('git svn dcommit');
- $cmd = 'git svn dcommit';
- } else if ($this->isGit) {
- $err = phutil_passthru('git push %s %s', $this->remote, $this->onto);
- $cmd = 'git push';
- } else if ($this->isHgSvn) {
- // hg-svn doesn't support 'push -r', so we do a normal push
- // which hg-svn modifies to only push the current branch and
- // ancestors.
- $err = $repository_api->execPassthru('push %s', $this->remote);
- $cmd = 'hg push';
- } else if ($this->isHg) {
- if (strlen($this->remote)) {
- $err = $repository_api->execPassthru(
- 'push -r %s %s',
- $this->onto,
- $this->remote);
- } else {
- $err = $repository_api->execPassthru(
- 'push -r %s',
- $this->onto);
- }
- $cmd = 'hg push';
- }
-
- if ($err) {
- echo phutil_console_format(
- "<bg:red>** %s **</bg>\n",
- pht('PUSH FAILED!'));
- $this->executeCleanupAfterFailedPush();
- if ($this->isGit) {
- throw new ArcanistUsageException(pht(
- "'%s' failed! Fix the error and run '%s' again.",
- $cmd,
- 'arc land'));
- }
- throw new ArcanistUsageException(pht(
- "'%s' failed! Fix the error and push this change manually.",
- $cmd));
- }
-
- $this->didPush();
-
- echo "\n";
- }
- }
-
- private function executeCleanupAfterFailedPush() {
- $repository_api = $this->getRepositoryAPI();
- if ($this->isGit) {
- $repository_api->execxLocal('reset --hard HEAD^');
- $this->restoreBranch();
- } else if ($this->isHg) {
- $repository_api->execxLocal(
- '--config extensions.mq= strip %s',
- $this->onto);
- $this->restoreBranch();
- }
- }
-
- private function cleanupBranch() {
- $repository_api = $this->getRepositoryAPI();
-
- echo pht('Cleaning up feature %s...', $this->branchType), "\n";
- if ($this->isGit) {
- list($ref) = $repository_api->execxLocal(
- 'rev-parse --verify %s',
- $this->branch);
- $ref = trim($ref);
- $recovery_command = csprintf(
- 'git checkout -b %s %s',
- $this->branch,
- $ref);
- echo pht('(Use `%s` if you want it back.)', $recovery_command), "\n";
- $repository_api->execxLocal('branch -D %s', $this->branch);
- } else if ($this->isHg) {
- $common_ancestor = $repository_api->getCanonicalRevisionName(
- hgsprintf('ancestor(%s,%s)', $this->onto, $this->branch));
-
- $branch_root = $repository_api->getCanonicalRevisionName(
- hgsprintf('first((%s::%s)-%s)',
- $common_ancestor,
- $this->branch,
- $common_ancestor));
-
- $repository_api->execxLocal(
- '--config extensions.mq= strip -r %s',
- $branch_root);
-
- if ($repository_api->isBookmark($this->branch)) {
- $repository_api->execxLocal('bookmark -d %s', $this->branch);
- }
- }
-
- if ($this->getArgument('delete-remote')) {
- if ($this->isHg) {
- // named branches were closed as part of the earlier commit
- // so only worry about bookmarks
- if ($repository_api->isBookmark($this->branch)) {
- $repository_api->execxLocal(
- 'push -B %s %s',
- $this->branch,
- $this->remote);
- }
- }
- }
- }
-
- public function getSupportedRevisionControlSystems() {
- return array('git', 'hg');
- }
-
- private function getBranchOrBookmark() {
- $repository_api = $this->getRepositoryAPI();
- if ($this->isGit) {
- $branch = $repository_api->getBranchName();
-
- // If we don't have a branch name, just use whatever's at HEAD.
- if (!strlen($branch) && !$this->isGitSvn) {
- $branch = $repository_api->getWorkingCopyRevision();
- }
- } else if ($this->isHg) {
- $branch = $repository_api->getActiveBookmark();
- if (!$branch) {
- $branch = $repository_api->getBranchName();
- }
- }
-
- return $branch;
- }
-
- private function getBranchType($branch) {
- $repository_api = $this->getRepositoryAPI();
- if ($this->isHg && $repository_api->isBookmark($branch)) {
- return 'bookmark';
- }
- return 'branch';
- }
-
- /**
- * Restore the original branch, e.g. after a successful land or a failed
- * pull.
- */
- private function restoreBranch() {
- $repository_api = $this->getRepositoryAPI();
- $repository_api->execxLocal('checkout %s', $this->oldBranch);
- if ($this->isGit) {
- $repository_api->execxLocal('submodule update --init --recursive');
- }
- echo pht(
- "Switched back to %s %s.\n",
- $this->branchType,
- phutil_console_format('**%s**', $this->oldBranch));
+ 'Confirms that the correct changes have been selected.')),
+ $this->newPrompt('arc.land.implicit')
+ ->setDescription(
+ pht(
+ 'Confirms that local commits which are not associated with '.
+ 'a revision 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.closed')
+ ->setDescription(
+ pht(
+ 'Confirms that revisions that are already closed 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.')),
+ $this->newPrompt('arc.land.ongoing-builds')
+ ->setDescription(
+ pht(
+ 'Confirms that revisions with ongoing builds.')),
+ );
}
-
- /**
- * Check if a diff has a running or failed buildable, and prompt the user
- * before landing if it does.
- */
- private function checkForBuildables($diff_phid) {
- // Try to use the more modern check which respects the "Warn on Land"
- // behavioral flag on build plans if we can. This newer check won't work
- // unless the server is running code from March 2019 or newer since the
- // API methods we need won't exist yet. We'll fall back to the older check
- // if this one doesn't work out.
- try {
- $this->checkForBuildablesWithPlanBehaviors($diff_phid);
- return;
- } catch (ArcanistUserAbortException $abort_ex) {
- throw $abort_ex;
- } catch (Exception $ex) {
- // Continue with the older approach, below.
- }
-
- // NOTE: Since Harbormaster is still beta and this stuff all got added
- // recently, just bail if we can't find a buildable. This is just an
- // advisory check intended to prevent human error.
-
- try {
- $buildables = $this->getConduit()->callMethodSynchronous(
- 'harbormaster.querybuildables',
- array(
- 'buildablePHIDs' => array($diff_phid),
- 'manualBuildables' => false,
- ));
- } catch (ConduitClientException $ex) {
- return;
- }
-
- if (!$buildables['data']) {
- // If there's no corresponding buildable, we're done.
- return;
- }
-
- $console = PhutilConsole::getConsole();
-
- $buildable = head($buildables['data']);
-
- if ($buildable['buildableStatus'] == 'passed') {
- $console->writeOut(
- "**<bg:green> %s </bg>** %s\n",
- pht('BUILDS PASSED'),
- pht('Harbormaster builds for the active diff completed successfully.'));
- return;
- }
-
- switch ($buildable['buildableStatus']) {
- case 'building':
- $message = pht(
- 'Harbormaster is still building the active diff for this revision.');
- $prompt = pht('Land revision anyway, despite ongoing build?');
- break;
- case 'failed':
- $message = pht(
- 'Harbormaster failed to build the active diff for this revision.');
- $prompt = pht('Land revision anyway, despite build failures?');
- break;
- default:
- // If we don't recognize the status, just bail.
- return;
- }
-
- $builds = $this->queryBuilds(
- array(
- 'buildablePHIDs' => array($buildable['phid']),
- ));
-
- $console->writeOut($message."\n\n");
-
- $builds = msortv($builds, 'getStatusSortVector');
- foreach ($builds as $build) {
- $ansi_color = $build->getStatusANSIColor();
- $status_name = $build->getStatusName();
- $object_name = $build->getObjectName();
- $build_name = $build->getName();
-
- echo tsprintf(
- " **<bg:".$ansi_color."> %s </bg>** %s: %s\n",
- $status_name,
- $object_name,
- $build_name);
- }
-
- $console->writeOut(
- "\n%s\n\n **%s**: __%s__",
- pht('You can review build details here:'),
- pht('Harbormaster URI'),
- $buildable['uri']);
-
- if (!phutil_console_confirm($prompt)) {
- throw new ArcanistUserAbortException();
- }
+ public function getLargeWorkingSetLimit() {
+ return 50;
}
- private function checkForBuildablesWithPlanBehaviors($diff_phid) {
- // TODO: These queries should page through all results instead of fetching
- // only the first page, but we don't have good primitives to support that
- // in "master" yet.
-
- $this->writeInfo(
- pht('BUILDS'),
- pht('Checking build status...'));
-
- $raw_buildables = $this->getConduit()->callMethodSynchronous(
- 'harbormaster.buildable.search',
- array(
- 'constraints' => array(
- 'objectPHIDs' => array(
- $diff_phid,
- ),
- 'manual' => false,
- ),
- ));
-
- if (!$raw_buildables['data']) {
- return;
- }
-
- $buildables = $raw_buildables['data'];
- $buildable_phids = ipull($buildables, 'phid');
-
- $raw_builds = $this->getConduit()->callMethodSynchronous(
- 'harbormaster.build.search',
- array(
- 'constraints' => array(
- 'buildables' => $buildable_phids,
- ),
- ));
-
- if (!$raw_builds['data']) {
- return;
- }
-
- $builds = array();
- foreach ($raw_builds['data'] as $raw_build) {
- $build_ref = ArcanistBuildRef::newFromConduit($raw_build);
- $build_phid = $build_ref->getPHID();
- $builds[$build_phid] = $build_ref;
- }
-
- $plan_phids = mpull($builds, 'getBuildPlanPHID');
- $plan_phids = array_values($plan_phids);
-
- $raw_plans = $this->getConduit()->callMethodSynchronous(
- 'harbormaster.buildplan.search',
- array(
- 'constraints' => array(
- 'phids' => $plan_phids,
- ),
- ));
-
- $plans = array();
- foreach ($raw_plans['data'] as $raw_plan) {
- $plan_ref = ArcanistBuildPlanRef::newFromConduit($raw_plan);
- $plan_phid = $plan_ref->getPHID();
- $plans[$plan_phid] = $plan_ref;
- }
-
- $ongoing_builds = array();
- $failed_builds = array();
-
- $builds = msortv($builds, 'getStatusSortVector');
- foreach ($builds as $build_ref) {
- $plan = idx($plans, $build_ref->getBuildPlanPHID());
- if (!$plan) {
- continue;
- }
-
- $plan_behavior = $plan->getBehavior('arc-land', 'always');
- $if_building = ($plan_behavior == 'building');
- $if_complete = ($plan_behavior == 'complete');
- $if_never = ($plan_behavior == 'never');
-
- // If the build plan "Never" warns when landing, skip it.
- if ($if_never) {
- continue;
- }
-
- // If the build plan warns when landing "If Complete" but the build is
- // not complete, skip it.
- if ($if_complete && !$build_ref->isComplete()) {
- continue;
- }
-
- // If the build plan warns when landing "If Building" but the build is
- // complete, skip it.
- if ($if_building && $build_ref->isComplete()) {
- continue;
- }
-
- // Ignore passing builds.
- if ($build_ref->isPassed()) {
- continue;
- }
-
- if (!$build_ref->isComplete()) {
- $ongoing_builds[] = $build_ref;
- } else {
- $failed_builds[] = $build_ref;
- }
- }
+ public function runWorkflow() {
+ $working_copy = $this->getWorkingCopy();
+ $repository_api = $working_copy->getRepositoryAPI();
- if (!$ongoing_builds && !$failed_builds) {
- return;
- }
-
- if ($failed_builds) {
- $this->writeWarn(
- pht('BUILD FAILURES'),
- pht(
- 'Harbormaster failed to build the active diff for this revision:'));
- $prompt = pht('Land revision anyway, despite build failures?');
- } else if ($ongoing_builds) {
- $this->writeWarn(
- pht('ONGOING BUILDS'),
+ $land_engine = $repository_api->getLandEngine();
+ if (!$land_engine) {
+ throw new PhutilArgumentUsageException(
pht(
- 'Harbormaster is still building the active diff for this revision:'));
- $prompt = pht('Land revision anyway, despite ongoing build?');
- }
-
- $show_builds = array_merge($failed_builds, $ongoing_builds);
- echo "\n";
- foreach ($show_builds as $build_ref) {
- $ansi_color = $build_ref->getStatusANSIColor();
- $status_name = $build_ref->getStatusName();
- $object_name = $build_ref->getObjectName();
- $build_name = $build_ref->getName();
-
- echo tsprintf(
- " **<bg:".$ansi_color."> %s </bg>** %s: %s\n",
- $status_name,
- $object_name,
- $build_name);
- }
-
- echo tsprintf(
- "\n%s\n\n",
- pht('You can review build details here:'));
-
- foreach ($buildables as $buildable) {
- $buildable_uri = id(new PhutilURI($this->getConduitURI()))
- ->setPath(sprintf('/B%d', $buildable['id']));
-
- echo tsprintf(
- " **%s**: __%s__\n",
- pht('Buildable %d', $buildable['id']),
- $buildable_uri);
- }
-
- if (!phutil_console_confirm($prompt)) {
- throw new ArcanistUserAbortException();
- }
- }
-
- public function buildEngineMessage(ArcanistLandEngine $engine) {
- // TODO: This is oh-so-gross.
- $this->findRevision();
- $engine->setCommitMessageFile($this->messageFile);
- }
-
- public function didCommitMerge() {
- $this->dispatchEvent(
- ArcanistEventType::TYPE_LAND_WILLPUSHREVISION,
- array());
- }
-
- public function didPush() {
- $this->askForRepositoryUpdate();
-
- $mark_workflow = $this->buildChildWorkflow(
- 'close-revision',
- array(
- '--finalize',
- '--quiet',
- $this->revision['id'],
- ));
- $mark_workflow->run();
+ '"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');
+
+ $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)
+ ->setIsIncremental($is_incremental)
+ ->setRevisionSymbol($revision);
+
+ $land_engine->execute();
}
- private function queryBuilds(array $constraints) {
- $conduit = $this->getConduit();
-
- // NOTE: This method only loads the 100 most recent builds. It's rare for
- // a revision to have more builds than that and there's currently no paging
- // wrapper for "*.search" Conduit API calls available in Arcanist.
-
- try {
- $raw_result = $conduit->callMethodSynchronous(
- 'harbormaster.build.search',
- array(
- 'constraints' => $constraints,
- ));
- } catch (Exception $ex) {
- // If the server doesn't have "harbormaster.build.search" yet (Aug 2016),
- // try the older "harbormaster.querybuilds" instead.
- $raw_result = $conduit->callMethodSynchronous(
- 'harbormaster.querybuilds',
- $constraints);
- }
-
- $refs = array();
- foreach ($raw_result['data'] as $raw_data) {
- $refs[] = ArcanistBuildRef::newFromConduit($raw_data);
- }
-
- return $refs;
- }
-
-
}
diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php
--- a/src/workflow/ArcanistWorkflow.php
+++ b/src/workflow/ArcanistWorkflow.php
@@ -244,7 +244,7 @@
return $err;
}
- final protected function getLogEngine() {
+ final public function getLogEngine() {
return $this->getRuntime()->getLogEngine();
}
@@ -2357,6 +2357,15 @@
$prompts = $this->newPrompts();
assert_instances_of($prompts, 'ArcanistPrompt');
+ $prompts[] = $this->newPrompt('arc.state.stash')
+ ->setDescription(
+ pht(
+ 'Prompts the user to stash changes and continue when the '.
+ 'working copy has untracked, uncommitted. or unstaged '.
+ 'changes.'));
+
+ // TODO: Swap to ArrayCheck?
+
$map = array();
foreach ($prompts as $prompt) {
$key = $prompt->getKey();
@@ -2380,7 +2389,7 @@
return $this->promptMap;
}
- protected function getPrompt($key) {
+ final public function getPrompt($key) {
$map = $this->getPromptMap();
$prompt = idx($map, $key);
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Wed, Feb 12, 11:56 PM (1 h, 59 m)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7125606
Default Alt Text
D21315.diff (212 KB)
Attached To
Mode
D21315: Substantially modernize the "arc land" workflow
Attached
Detach File
Event Timeline
Log In to Comment