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 @@ -1333,4 +1333,71 @@ $this->resolvedHeadCommit = null; } + /** + * Follow the chain of tracking branches upstream until we reach a remote + * or cycle locally. + * + * @param string Ref to start from. + * @return list<wild> Path to an upstream. + */ + public function getPathToUpstream($start) { + $cursor = $start; + $path = array(); + while (true) { + list($err, $upstream) = $this->execManualLocal( + 'rev-parse --symbolic-full-name %s@{upstream}', + $cursor); + + if ($err) { + // We ended up somewhere with no tracking branch, so we're done. + break; + } + + $upstream = trim($upstream); + + if (preg_match('(^refs/heads/)', $upstream)) { + $upstream = preg_replace('(^refs/heads/)', '', $upstream); + + $is_cycle = isset($path[$upstream]); + + $path[$cursor] = array( + 'type' => 'local', + 'name' => $upstream, + 'cycle' => $is_cycle, + ); + + if ($is_cycle) { + // We ran into a local cycle, so we're done. + break; + } + + // We found another local branch, so follow that one upriver. + $cursor = $upstream; + continue; + } + + if (preg_match('(^refs/remotes/)', $upstream)) { + $upstream = preg_replace('(^refs/remotes/)', '', $upstream); + list($remote, $branch) = explode('/', $upstream, 2); + + $path[$cursor] = array( + 'type' => 'remote', + 'name' => $branch, + 'remote' => $remote, + ); + + // We found a remote, so we're done. + break; + } + + throw new Exception( + pht( + 'Got unrecognized upstream format ("%s") from Git, expected '. + '"refs/heads/..." or "refs/remotes/...".', + $upstream)); + } + + return $path; + } + } diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -38,7 +38,7 @@ public function getCommandSynopses() { return phutil_console_format(<<<EOTEXT - **land** [__options__] [__branch__] [--onto __master__] + **land** [__options__] [__ref__] EOTEXT ); } @@ -47,18 +47,68 @@ return phutil_console_format(<<<EOTEXT Supports: git, hg - Land an accepted change (currently sitting in local feature branch - __branch__) onto __master__ and push it to the remote. Then, delete - the feature branch. If you omit __branch__, the current branch will - be used. + Publish an accepted revision after review. This command is the last + step in the standard Differential pre-publish code review workflow. - In mutable repositories, this will perform a --squash merge (the - entire branch will be represented by one commit on __master__). In - immutable repositories (or when --merge is provided), it will perform - a --no-ff merge (the branch will always be merged into __master__ with - a merge commit). + This workflow 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 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. + + A target branch is selected by examining these sources in order: + + - the **--onto** flag; + - the upstream of the current branch, recursively (Git only); + - the __arc.land.onto.default__ configuration setting; + - or falling back to a "master" in Git or "default" in Mercurial. + + A remote is selected by examining these sources in order: + + - the **--remote** flag; + - the upstream of the current branch, recursively (Git only); + - or falling back to "origin" in Git or the default remote in + Mercurial. + + 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. + + The change is merged into the target branch, following these rules: + + In mutable repositories or with **--squash**, this will perform a + squash merge (the entire branch will be represented as one commit on + the target branch). + + In immutable repositories 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 state of the revision in Differential. + + With **--hold**, execution stops here, before the change is pushed. + + 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 land 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. - Under hg, bookmarks can be landed the same way as branches. EOTEXT ); } @@ -203,6 +253,8 @@ } if ($engine) { + $this->readEngineArguments(); + $obsolete = array( 'delete-remote', 'update-with-merge', @@ -236,7 +288,7 @@ $engine->execute(); - if (!$should_hold) { + if (!$should_hold && !$this->preview) { $this->didPush(); } @@ -303,6 +355,137 @@ return null; } + private function readEngineArguments() { + // NOTE: This is hard-coded for Git right now. + // TODO: Clean this up and move it into LandEngines. + + $onto = $this->getEngineOnto(); + $remote = $this->getEngineRemote(); + + // This just overwrites work we did earlier, but it has to be up in this + // class for now because other parts of the workflow still depend on it. + $this->onto = $onto; + $this->remote = $remote; + $this->ontoRemoteBranch = $this->remote.'/'.$onto; + } + + private function getEngineOnto() { + $onto = $this->getArgument('onto'); + if ($onto !== null) { + $this->writeInfo( + pht('TARGET'), + pht( + 'Landing onto "%s", selected by the --onto flag.', + $onto)); + return $onto; + } + + $api = $this->getRepositoryAPI(); + $path = $api->getPathToUpstream($this->branch); + + if ($path) { + $last = last($path); + if (isset($last['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", + $this->formatUpstreamPathCycle($path)); + + } else { + if ($last['type'] == 'remote') { + $onto = $last['name']; + $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.')); + } + } + } + + $config_key = 'arc.land.onto.default'; + $onto = $this->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() { + $remote = $this->getArgument('remote'); + if ($remote !== null) { + $this->writeInfo( + pht('REMOTE'), + pht( + 'Using remote "%s", selected by the --remote flag.', + $remote)); + return $remote; + } + + $api = $this->getRepositoryAPI(); + $path = $api->getPathToUpstream($this->branch); + + if ($path) { + $last = last($path); + if ($last['type'] == 'remote') { + $remote = $last['remote']; + $this->writeInfo( + pht('REMOTE'), + pht( + 'Using remote "%s", selected by following tracking branches '. + 'upstream to the closest remote.', + $remote)); + return $remote; + } + } + + $remote = 'origin'; + $this->writeInfo( + pht('REMOTE'), + pht( + 'Using remote "%s", the default remote under git.', + $remote)); + return $remote; + } + + private function formatUpstreamPathCycle(array $cycle) { + $parts = array(); + foreach ($cycle as $key => $value) { + $parts[] = $key; + } + $parts[] = idx(last($cycle), 'name'); + $parts[] = pht('...'); + + return implode(' -> ', $parts); + } + + private function readArguments() { $repository_api = $this->getRepositoryAPI(); $this->isGit = $repository_api instanceof ArcanistGitAPI; @@ -320,9 +503,12 @@ $branch = $this->getArgument('branch'); if (empty($branch)) { $branch = $this->getBranchOrBookmark(); - if ($branch) { $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); }