diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php index 945a5f39..f2b5d632 100644 --- a/src/land/ArcanistGitLandEngine.php +++ b/src/land/ArcanistGitLandEngine.php @@ -1,393 +1,529 @@ 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(); $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) { 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'); + $api->execManualLocal('reset --hard HEAD --'); - // TODO: Maybe throw a better or more helpful exception here? + 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())); + } - throw $ex; + list($changes) = $api->execxLocal('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(); $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", + $this->writeInfo( + pht('RESTORE'), 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", + // 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()) { + $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" does not exist, staying on detached HEAD.', - $this->getTargetOnto())); + 'Local branch "%s" tracks an upstream but following it leads to '. + 'a local cycle, staying on detached HEAD.', + $local_branch)); return; } - list($err, $upstream) = $api->execManualLocal( - 'rev-parse --verify --symbolic-full-name %s', - $this->getTargetOnto().'@{upstream}'); - if ($err) { - echo tsprintf( - "%s\n", + if (!$path->isConnectedToRemote()) { + $this->writeInfo( + pht('UPDATE'), pht( - 'Local branch "%s" has no upstream, staying on detached HEAD.', - $this->getTargetOnto())); + 'Local branch "%s" is not connected to a remote, staying on '. + 'detached HEAD.', + $local_branch)); return; } - $upstream = trim($upstream); - $expect_upstream = 'refs/remotes/'.$this->getTargetFullRef(); - if ($upstream != $expect_upstream) { - echo tsprintf( - "%s\n", + $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" tracks remote "%s" (not target remote "%s"), '. - 'staying on detached HEAD.', - $this->getTargetOnto(), - $upstream, - $expect_upstream)); + '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; } - list($stdout) = $api->execxLocal( - 'log %s..%s --', - $this->mergedRef, - $this->getTargetOnto()); - $stdout = trim($stdout); + // 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. - if (!strlen($stdout)) { - echo tsprintf( - "%s\n", + $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" tracks target remote "%s", checking out and '. - 'pulling changes.', - $this->getTargetOnto(), - $this->getTargetFullRef())); + '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)); - $api->execxLocal('checkout %s --', $this->getTargetOnto()); - $api->execxLocal('pull --'); + $this->writeInfo( + pht('CHECKOUT'), + pht( + 'Checking out "%s".', + $local_branch)); + + $api->execxLocal('checkout %s --', $local_branch); return; } - if ($this->getTargetOnto() !== $this->getSourceRef()) { - echo tsprintf( - "%s\n", + // 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 ($cascade_targets) { + $this->writeInfo( + pht('UPDATE'), pht( - 'Local "%s" is ahead of remote "%s". Checking out but '. - 'not pulling changes.', - $this->getTargetOnto(), + 'Local "%s" tracks target remote "%s", checking out and '. + 'pulling changes.', + $local_branch, $this->getTargetFullRef())); - $api->execxLocal('checkout %s --', $this->getTargetOnto()); + foreach ($cascade_targets as $cascade_branch) { + $this->writeInfo( + pht('PULL'), + pht( + 'Checking out and pulling "%s".', + $cascade_branch)); - return; + $api->execxLocal('checkout %s --', $cascade_branch); + $api->execxLocal('pull --'); + } + + 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. - echo tsprintf( - "%s\n", + $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 --', $this->getTargetOnto()); + $api->execxLocal('checkout %s --', $local_branch); $api->execxLocal('reset --hard %s --', $this->getTargetFullRef()); + + return; } private function destroyLocalBranch() { $api = $this->getRepositoryAPI(); if ($this->getSourceRef() == $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? $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/repository/api/ArcanistGitUpstreamPath.php b/src/repository/api/ArcanistGitUpstreamPath.php index 1d3feed0..ad487106 100644 --- a/src/repository/api/ArcanistGitUpstreamPath.php +++ b/src/repository/api/ArcanistGitUpstreamPath.php @@ -1,82 +1,90 @@ path[$key] = $spec; return $this; } + public function removeUpstream($key) { + unset($this->path[$key]); + return $this; + } + public function getUpstream($key) { return idx($this->path, $key); } public function getLength() { return count($this->path); } /** * Test if this path eventually connects to a remote. * * @return bool True if the path connects to a remote. */ public function isConnectedToRemote() { $last = last($this->path); if (!$last) { return false; } return ($last['type'] == self::TYPE_REMOTE); } + public function getLocalBranches() { + return array_keys($this->path); + } public function getRemoteBranchName() { if (!$this->isConnectedToRemote()) { return null; } return idx(last($this->path), 'name'); } public function getRemoteRemoteName() { if (!$this->isConnectedToRemote()) { return null; } return idx(last($this->path), 'remote'); } /** * If this path contains a cycle, return a description of it. * * @return list|null Cycle, if the path contains one. */ public function getCycle() { $last = last($this->path); if (!$last) { return null; } if (empty($last['cycle'])) { return null; } $parts = array(); foreach ($this->path as $key => $item) { $parts[] = $key; } $parts[] = $item['name']; $parts[] = pht('...'); return $parts; } }