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 @@ -112,6 +112,7 @@ 'ArcanistGeneratedLinterTestCase' => 'lint/linter/__tests__/ArcanistGeneratedLinterTestCase.php', 'ArcanistGetConfigWorkflow' => 'workflow/ArcanistGetConfigWorkflow.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', + 'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php', 'ArcanistGlobalVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistGlobalVariableXHPASTLinterRule.php', 'ArcanistGoLintLinter' => 'lint/linter/ArcanistGoLintLinter.php', 'ArcanistGoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistGoLintLinterTestCase.php', @@ -144,6 +145,7 @@ 'ArcanistJscsLinterTestCase' => 'lint/linter/__tests__/ArcanistJscsLinterTestCase.php', 'ArcanistKeywordCasingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistKeywordCasingXHPASTLinterRule.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php', + 'ArcanistLandEngine' => 'land/ArcanistLandEngine.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php', 'ArcanistLesscLinter' => 'lint/linter/ArcanistLesscLinter.php', @@ -398,6 +400,7 @@ 'ArcanistGeneratedLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistGetConfigWorkflow' => 'ArcanistWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', + 'ArcanistGitLandEngine' => 'ArcanistLandEngine', 'ArcanistGlobalVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistGoLintLinter' => 'ArcanistExternalLinter', 'ArcanistGoLintLinterTestCase' => 'ArcanistExternalLinterTestCase', @@ -430,6 +433,7 @@ 'ArcanistJscsLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistKeywordCasingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', + 'ArcanistLandEngine' => 'Phobject', 'ArcanistLandWorkflow' => 'ArcanistWorkflow', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', diff --git a/src/internationalization/ArcanistUSEnglishTranslation.php b/src/internationalization/ArcanistUSEnglishTranslation.php --- a/src/internationalization/ArcanistUSEnglishTranslation.php +++ b/src/internationalization/ArcanistUSEnglishTranslation.php @@ -78,6 +78,11 @@ 'Ignore the changes to this submodule and continue?', 'Ignore the changes to these submodules and continue?', ), + + 'These %s commit(s) will be landed:' => array( + 'This commit will be landed:', + 'These commits will be landed:', + ), ); } diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistGitLandEngine.php @@ -0,0 +1,387 @@ +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->writeInfo( + pht('HOLD'), + pht('Holding change locally, it has not been pushed.')); + } else { + $this->pushChange(); + $this->reconcileLocalState(); + + 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) { + throw new Exception( + pht( + 'Branch "%s" does not exist in 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(); + + $this->writeInfo( + pht('FETCH'), + pht('Fetching %s...', $ref)); + + $api->execxLocal( + 'fetch -- %s %s', + $this->getTargetRemote(), + $this->getTargetOnto()); + } + + 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()) { + $api->execxLocal( + 'merge --no-stat --no-commit --squash -- %s', + $source); + } else { + $api->execxLocal( + 'merge --no-stat --no-commit --no-ff -- %s', + $source); + } + } catch (Exception $ex) { + $api->execManualLocal('merge --abort'); + + // TODO: Maybe throw a better or more helpful exception here? + + throw $ex; + } + + $api->execxLocal( + 'commit --author %s --date %s -F %s --', + $original_author, + $original_date, + $this->getCommitMessageFile()); + + list($stdout) = $api->execxLocal( + 'rev-parse --verify %s', + 'HEAD'); + $this->mergedRef = trim($stdout); + } + + private function pushChange() { + $api = $this->getRepositoryAPI(); + + $this->writeInfo( + pht('PUSHING'), + pht('Pushing changes to "%s".', $this->getTargetFullRef())); + + list($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 "%s" again.', + 'arc land')); + } + } + + 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. + echo tsprintf( + "%s\n", + pht('Switching back to "%s".', $this->localRef)); + $this->restoreLocalState(); + return; + } + + list($err) = $api->execManualLocal( + 'rev-parse --verify %s', + $this->getTargetOnto()); + if ($err) { + echo tsprintf( + "%s\n", + pht( + 'Local branch "%s" does not exist, staying on detached HEAD.', + $this->getTargetOnto())); + return; + } + + list($err, $upstream) = $api->execManualLocal( + 'rev-parse --verify --symbolic-full-name %s', + $this->getTargetOnto().'@{upstream}'); + if ($err) { + echo tsprintf( + "%s\n", + pht( + 'Local branch "%s" has no upstream, staying on detached HEAD.', + $this->getTargetOnto())); + return; + } + + $upstream = trim($upstream); + $expect_upstream = 'refs/remotes/'.$this->getTargetFullRef(); + if ($upstream != $expect_upstream) { + echo tsprintf( + "%s\n", + pht( + 'Local branch "%s" tracks remote "%s" (not target remote "%s"), '. + 'staying on detached HEAD.', + $this->getTargetOnto(), + $upstream, + $expect_upstream)); + return; + } + + list($stdout) = $api->execxLocal( + 'log %s..%s --', + $this->mergedRef, + $this->getTargetOnto()); + $stdout = trim($stdout); + + if (!strlen($stdout)) { + echo tsprintf( + "%s\n", + pht( + 'Local "%s" tracks target remote "%s", checking out and '. + 'pulling changes.', + $this->getTargetOnto(), + $this->getTargetFullRef())); + + $api->execxLocal('checkout %s --', $this->getTargetOnto()); + $api->execxLocal('pull --'); + $api->execxLocal('submodule update --init --recursive'); + + return; + } + + if ($this->getTargetOnto() !== $this->getSourceRef()) { + echo tsprintf( + "%s\n", + pht( + 'Local "%s" is ahead of remote "%s". Checking out but '. + 'not pulling changes.', + $this->getTargetOnto(), + $this->getTargetFullRef())); + + $api->execxLocal('checkout %s --', $this->getTargetOnto()); + $api->execxLocal('submodule update --init --recursive'); + + 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. + + echo tsprintf( + "%s\n", + pht( + 'Local "%s" landed into remote "%s", resetting local branch to '. + 'remote state.', + $this->getTargetOnto(), + $this->getTargetFullRef())); + + $api->execxLocal('checkout %s --', $this->getTargetOnto()); + $api->execxLocal('reset --hard %s --', $this->getTargetFullRef()); + $api->execxLocal('submodule update --init --recursive'); + } + + private function destroyLocalBranch() { + $api = $this->getRepositoryAPI(); + + if ($this->localRef == $this->getSourceRef()) { + // If we landed a branch onto itself, don't destroy it. + return; + } + + $recovery_command = csprintf( + 'git checkout -b %R %R', + $this->getSourceRef(), + $this->sourceCommit); + + echo tsprintf( + "%s\n", + pht('Cleaning up branch "%s"...', $this->getSourceRef())); + + echo tsprintf( + "%s\n", + pht('(Use `%s` if you want it back.)', $recovery_command)); + + $api->execxLocal('branch -D -- %s', $this->getSourceRef()); + } + + /** + * 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, + ); + } + +} diff --git a/src/land/ArcanistLandEngine.php b/src/land/ArcanistLandEngine.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistLandEngine.php @@ -0,0 +1,161 @@ +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; + } + + 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/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -196,6 +196,53 @@ public function run() { $this->readArguments(); + + $engine = null; + if ($this->isGit && !$this->isGitSvn) { + $engine = new ArcanistGitLandEngine(); + } + + if ($engine) { + $obsolete = array( + 'delete-remote', + 'update-with-merge', + 'update-with-rebase', + ); + + foreach ($obsolete as $flag) { + if ($this->getArgument($flag)) { + throw new ArcanistUsageException( + pht( + 'Flag "%s" is no longer supported under Git.', + '--'.$flag)); + } + } + + $this->requireCleanWorkingCopy(); + + $should_hold = $this->getArgument('hold'); + + $engine + ->setWorkflow($this) + ->setRepositoryAPI($this->getRepositoryAPI()) + ->setSourceRef($this->branch) + ->setTargetRemote($this->remote) + ->setTargetOnto($this->onto) + ->setShouldHold($should_hold) + ->setShouldKeep($this->keepBranch) + ->setShouldSquash($this->useSquash) + ->setShouldPreview($this->preview) + ->setBuildMessageCallback(array($this, 'buildEngineMessage')); + + $engine->execute(); + + if (!$should_hold) { + $this->didPush(); + } + + return 0; + } + $this->validate(); try { @@ -1085,16 +1132,7 @@ $cmd)); } - $this->askForRepositoryUpdate(); - - $mark_workflow = $this->buildChildWorkflow( - 'close-revision', - array( - '--finalize', - '--quiet', - $this->revision['id'], - )); - $mark_workflow->run(); + $this->didPush(); echo "\n"; } @@ -1193,6 +1231,11 @@ $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) { @@ -1317,4 +1360,23 @@ } } + public function buildEngineMessage(ArcanistLandEngine $engine) { + // TODO: This is oh-so-gross. + $this->findRevision(); + $engine->setCommitMessageFile($this->messageFile); + } + + public function didPush() { + $this->askForRepositoryUpdate(); + + $mark_workflow = $this->buildChildWorkflow( + 'close-revision', + array( + '--finalize', + '--quiet', + $this->revision['id'], + )); + $mark_workflow->run(); + } + } diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -1361,7 +1361,7 @@ fwrite(STDERR, $msg); } - final protected function writeInfo($title, $message) { + final public function writeInfo($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", @@ -1369,7 +1369,7 @@ $message)); } - final protected function writeWarn($title, $message) { + final public function writeWarn($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", @@ -1377,7 +1377,7 @@ $message)); } - final protected function writeOkay($title, $message) { + final public function writeOkay($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n",