mirror of
https://we.phorge.it/source/arcanist.git
synced 2024-11-29 10:12:41 +01:00
Add hg support for arc land
Summary: Makes arc land support hg repositories. Both bookmarks and named branches can be landed. For the most part all the arc land options work, but there are a few caveats: - bookmarks can only be landed on bookmarks - branches can only be landed on branches - landing a named branch with --merge creates a commit to close the branch before the merge. - since mercurial doesn't start with a default master bookmark, landing a bookmark requires specifying --onto or setting arc.land.onto.default Test Plan: Tested arc land with all permutations of --merge, --keep-branch on both bookmark branches and named branches. Also tested --hold, --revision, --onto, --remote. See https://secure.phabricator.com/P619 Also tested git arc land with --merge and --keep-branch. Reviewers: dschleimer, sid0, epriestley, bos Reviewed By: epriestley CC: aran, Korvin Differential Revision: https://secure.phabricator.com/D4068
This commit is contained in:
parent
5025c06f3d
commit
aada1440ef
2 changed files with 386 additions and 88 deletions
|
@ -39,6 +39,17 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
|
|||
return $future;
|
||||
}
|
||||
|
||||
public function execPassthru($pattern /* , ... */) {
|
||||
$args = func_get_args();
|
||||
if (phutil_is_windows()) {
|
||||
$args[0] = 'set HGPLAIN=1 & hg '.$args[0];
|
||||
} else {
|
||||
$args[0] = 'HGPLAIN=1 hg '.$args[0];
|
||||
}
|
||||
|
||||
return call_user_func_array("phutil_passthru", $args);
|
||||
}
|
||||
|
||||
public function getSourceControlSystemName() {
|
||||
return 'hg';
|
||||
}
|
||||
|
@ -768,17 +779,98 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
|
|||
}
|
||||
|
||||
public function getActiveBookmark() {
|
||||
$bookmarks = $this->getBookmarks();
|
||||
foreach ($bookmarks as $bookmark) {
|
||||
if ($bookmark['is_active']) {
|
||||
return $bookmark['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isBookmark($name) {
|
||||
$bookmarks = $this->getBookmarks();
|
||||
foreach ($bookmarks as $bookmark) {
|
||||
if ($bookmark['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isBranch($name) {
|
||||
$branches = $this->getBranches();
|
||||
foreach ($branches as $branch) {
|
||||
if ($branch['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getBranches() {
|
||||
$branches = array();
|
||||
|
||||
list($raw_output) = $this->execxLocal('branches');
|
||||
$raw_output = trim($raw_output);
|
||||
|
||||
foreach (explode("\n", $raw_output) as $line) {
|
||||
// example line: default 0:a5ead76cdf85 (inactive)
|
||||
list($name, $rev_line) = $this->splitBranchOrBookmarkLine($line);
|
||||
|
||||
// strip off the '(inactive)' bit if it exists
|
||||
$rev_parts = explode(' ', $rev_line);
|
||||
$revision = $rev_parts[0];
|
||||
|
||||
$branches[] = array(
|
||||
'name' => $name,
|
||||
'revision' => $revision);
|
||||
}
|
||||
|
||||
return $branches;
|
||||
}
|
||||
|
||||
public function getBookmarks() {
|
||||
$bookmarks = array();
|
||||
|
||||
list($raw_output) = $this->execxLocal('bookmarks');
|
||||
$raw_output = trim($raw_output);
|
||||
if ($raw_output !== 'no bookmarks set') {
|
||||
foreach (explode("\n", $raw_output) as $line) {
|
||||
$line = trim($line);
|
||||
if ('*' === $line[0]) {
|
||||
return idx(explode(' ', $line, 3), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
// example line: * mybook 2:6b274d49be97
|
||||
list($name, $revision) = $this->splitBranchOrBookmarkLine($line);
|
||||
|
||||
$is_active = false;
|
||||
if ('*' === $name[0]) {
|
||||
$is_active = true;
|
||||
$name = substr($name, 2);
|
||||
}
|
||||
|
||||
$bookmarks[] = array(
|
||||
'is_active' => $is_active,
|
||||
'name' => $name,
|
||||
'revision' => $revision);
|
||||
}
|
||||
}
|
||||
|
||||
return $bookmarks;
|
||||
}
|
||||
|
||||
private function splitBranchOrBookmarkLine($line) {
|
||||
// branches and bookmarks are printed in the format:
|
||||
// default 0:a5ead76cdf85 (inactive)
|
||||
// * mybook 2:6b274d49be97
|
||||
// this code divides the name half from the revision half
|
||||
// it does not parse the * and (inactive) bits
|
||||
$colon_index = strrpos($line, ':');
|
||||
$before_colon = substr($line, 0, $colon_index);
|
||||
$start_rev_index = strrpos($before_colon, ' ');
|
||||
$name = substr($line, 0, $start_rev_index);
|
||||
$rev = substr($line, $start_rev_index);
|
||||
|
||||
return array(trim($name), trim($rev));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
final class ArcanistLandWorkflow extends ArcanistBaseWorkflow {
|
||||
private $isGit;
|
||||
private $isHg;
|
||||
|
||||
private $oldBranch;
|
||||
private $branch;
|
||||
|
@ -17,7 +18,7 @@ final class ArcanistLandWorkflow extends ArcanistBaseWorkflow {
|
|||
private $keepBranch;
|
||||
|
||||
private $revision;
|
||||
private $message;
|
||||
private $messageFile;
|
||||
|
||||
public function getWorkflowName() {
|
||||
return 'land';
|
||||
|
@ -32,7 +33,7 @@ EOTEXT
|
|||
|
||||
public function getCommandHelp() {
|
||||
return phutil_console_format(<<<EOTEXT
|
||||
Supports: git
|
||||
Supports: git, hg
|
||||
|
||||
Land an accepted change (currently sitting in local feature branch
|
||||
__branch__) onto __master__ and push it to the remote. Then, delete
|
||||
|
@ -44,6 +45,8 @@ EOTEXT
|
|||
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).
|
||||
|
||||
Under hg, bookmarks can be landed the same way as branches.
|
||||
EOTEXT
|
||||
);
|
||||
}
|
||||
|
@ -68,10 +71,10 @@ EOTEXT
|
|||
return array(
|
||||
'onto' => array(
|
||||
'param' => 'master',
|
||||
'help' => "Land feature branch onto a branch other than ".
|
||||
"'master' (default). You can change the default by setting ".
|
||||
"'arc.land.onto.default' with `arc set-config` or for the ".
|
||||
"entire project in .arcconfig.",
|
||||
'help' => "Land feature branch onto a branch other than the default ".
|
||||
"('master' in git, 'default' in hg). You can change the ".
|
||||
"default by setting 'arc.land.onto.default' with ".
|
||||
"`arc set-config` or for the entire project in .arcconfig.",
|
||||
),
|
||||
'hold' => array(
|
||||
'help' => "Prepare the change to be pushed, but do not actually ".
|
||||
|
@ -83,12 +86,18 @@ EOTEXT
|
|||
),
|
||||
'remote' => array(
|
||||
'param' => 'origin',
|
||||
'help' => "Push to a remote other than 'origin' (default).",
|
||||
'help' => "Push to a remote other than the default ('origin' in git).",
|
||||
),
|
||||
'merge' => array(
|
||||
'help' => 'Perform a --no-ff merge, not a --squash merge. If the '.
|
||||
'project is marked as having an immutable history, this is '.
|
||||
'the default behavior.',
|
||||
'supports' => array(
|
||||
'git',
|
||||
),
|
||||
'nosupport' => array(
|
||||
'hg' => 'Use the --squash strategy when landing in mercurial.',
|
||||
),
|
||||
),
|
||||
'squash' => array(
|
||||
'help' => 'Perform a --squash merge, not a --no-ff merge. If the '.
|
||||
|
@ -154,14 +163,16 @@ EOTEXT
|
|||
private function readArguments() {
|
||||
$repository_api = $this->getRepositoryAPI();
|
||||
$this->isGit = $repository_api instanceof ArcanistGitAPI;
|
||||
$this->isHg = $repository_api instanceof ArcanistMercurialAPI;
|
||||
|
||||
if (!$this->isGit) {
|
||||
throw new ArcanistUsageException("'arc land' only supports git.");
|
||||
if (!$this->isGit && !$this->isHg) {
|
||||
throw new ArcanistUsageException(
|
||||
"'arc land' only supports git and mercurial.");
|
||||
}
|
||||
|
||||
$branch = $this->getArgument('branch');
|
||||
if (empty($branch)) {
|
||||
$branch = $repository_api->getBranchName();
|
||||
$branch = $this->getBranchOrBookmark();
|
||||
|
||||
if ($branch) {
|
||||
echo "Landing current branch '{$branch}'.\n";
|
||||
|
@ -174,14 +185,16 @@ EOTEXT
|
|||
"Specify exactly one branch to land changes from.");
|
||||
}
|
||||
$this->branch = head($branch);
|
||||
$this->keepBranch = $this->getArgument('keep-branch');
|
||||
|
||||
$onto_default = $this->isGit ? 'master' : 'default';
|
||||
$onto_default = nonempty(
|
||||
$this->getWorkingCopy()->getConfigFromAnySource('arc.land.onto.default'),
|
||||
'master');
|
||||
|
||||
$this->remote = $this->getArgument('remote', 'origin');
|
||||
$onto_default);
|
||||
$this->onto = $this->getArgument('onto', $onto_default);
|
||||
$this->keepBranch = $this->getArgument('keep-branch');
|
||||
|
||||
$remote_default = $this->isGit ? 'origin' : '';
|
||||
$this->remote = $this->getArgument('remote', $remote_default);
|
||||
|
||||
if ($this->getArgument('merge')) {
|
||||
$this->useSquash = false;
|
||||
|
@ -191,9 +204,12 @@ EOTEXT
|
|||
$this->useSquash = !$this->isHistoryImmutable();
|
||||
}
|
||||
|
||||
$this->ontoRemoteBranch = $this->onto;
|
||||
if ($this->isGit) {
|
||||
$this->ontoRemoteBranch = $this->remote.'/'.$this->onto;
|
||||
}
|
||||
|
||||
$this->oldBranch = $repository_api->getBranchName();
|
||||
$this->oldBranch = $this->getBranchOrBookmark();
|
||||
}
|
||||
|
||||
private function validate() {
|
||||
|
@ -211,6 +227,36 @@ EOTEXT
|
|||
throw new ArcanistUsageException($message);
|
||||
}
|
||||
|
||||
if ($this->isHg) {
|
||||
if ($this->useSquash) {
|
||||
list ($err) = $repository_api->execManualLocal("rebase --help");
|
||||
if ($err) {
|
||||
throw new ArcanistUsageException(
|
||||
"You must enable the rebase extension to use ".
|
||||
"the --squash strategy.");
|
||||
}
|
||||
}
|
||||
|
||||
if ($repository_api->isBookmark($this->branch) &&
|
||||
!$repository_api->isBookmark($this->onto)) {
|
||||
throw new ArcanistUsageException(
|
||||
"Source {$this->branch} is a bookmark but destination ".
|
||||
"{$this->onto} is not a bookmark. When landing a bookmark, ".
|
||||
"the destination must also be a bookmark. Use --onto to specify ".
|
||||
"a bookmark, or set arc.land.onto.default in .arcconfig.");
|
||||
}
|
||||
|
||||
if ($repository_api->isBranch($this->branch) &&
|
||||
!$repository_api->isBranch($this->onto)) {
|
||||
throw new ArcanistUsageException(
|
||||
"Source {$this->branch} is a branch but destination {$this->onto} ".
|
||||
"is not a branch. When landing a branch, the destination must also ".
|
||||
"be a branch. Use --onto to specify a branch, or set ".
|
||||
"arc.land.onto.default in .arcconfig.");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isGit) {
|
||||
list($err) = $repository_api->execManualLocal(
|
||||
'rev-parse --verify %s',
|
||||
$this->branch);
|
||||
|
@ -219,6 +265,7 @@ EOTEXT
|
|||
throw new ArcanistUsageException(
|
||||
"Branch '{$this->branch}' does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->requireCleanWorkingCopy();
|
||||
}
|
||||
|
@ -290,12 +337,15 @@ EOTEXT
|
|||
}
|
||||
}
|
||||
|
||||
$this->message = $this->getConduit()->callMethodSynchronous(
|
||||
$message = $this->getConduit()->callMethodSynchronous(
|
||||
'differential.getcommitmessage',
|
||||
array(
|
||||
'revision_id' => $rev_id,
|
||||
));
|
||||
|
||||
$this->messageFile = new TempFile();
|
||||
Filesystem::writeFile($this->messageFile, $message);
|
||||
|
||||
echo "Landing revision 'D{$rev_id}: ".
|
||||
"{$rev_title}'...\n";
|
||||
}
|
||||
|
@ -308,6 +358,8 @@ EOTEXT
|
|||
"Switched to branch **%s**. Updating branch...\n",
|
||||
$this->onto);
|
||||
|
||||
$local_ahead_of_remote = false;
|
||||
if ($this->isGit) {
|
||||
$repository_api->execxLocal('pull --ff-only');
|
||||
|
||||
list($out) = $repository_api->execxLocal(
|
||||
|
@ -316,6 +368,54 @@ EOTEXT
|
|||
$this->onto,
|
||||
$this->onto);
|
||||
if (strlen(trim($out))) {
|
||||
$local_ahead_of_remote = true;
|
||||
}
|
||||
} else if ($this->isHg) {
|
||||
// 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;
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$repository_api->execxLocal('pull -u');
|
||||
} 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/facebook/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)) {
|
||||
return;
|
||||
} else {
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($local_ahead_of_remote) {
|
||||
throw new ArcanistUsageException(
|
||||
"Local branch '{$this->onto}' is ahead of remote branch ".
|
||||
"'{$this->ontoRemoteBranch}', so landing a feature branch ".
|
||||
|
@ -328,26 +428,67 @@ EOTEXT
|
|||
$repository_api = $this->getRepositoryAPI();
|
||||
|
||||
chdir($repository_api->getPath());
|
||||
if ($this->isGit) {
|
||||
$err = phutil_passthru('git rebase %s', $this->onto);
|
||||
}
|
||||
else if ($this->isHg) {
|
||||
// keep branch here so later we can decide whether to remove it
|
||||
$err = $repository_api->execPassthru(
|
||||
'rebase -d %s --keepbranches',
|
||||
$this->onto);
|
||||
}
|
||||
|
||||
if ($err) {
|
||||
$command = $repository_api->getSourceControlSystemName();
|
||||
throw new ArcanistUsageException(
|
||||
"'git rebase {$this->onto}' failed. ".
|
||||
"You can abort with 'git rebase --abort', ".
|
||||
"or resolve conflicts and use 'git rebase ".
|
||||
"'{$command} rebase {$this->onto}' failed. ".
|
||||
"You can abort with '{$command} rebase --abort', ".
|
||||
"or resolve conflicts and use '{$command} rebase ".
|
||||
"--continue' to continue forward. After resolving the rebase, ".
|
||||
"run 'arc land' again.");
|
||||
}
|
||||
|
||||
// Now that we've rebased, the merge-base of origin/master and HEAD may
|
||||
// be different. Reparse the relative commit.
|
||||
$repository_api->parseRelativeLocalCommit(array($this->ontoRemoteBranch));
|
||||
}
|
||||
|
||||
private function squash() {
|
||||
$repository_api = $this->getRepositoryAPI();
|
||||
$repository_api->execxLocal('checkout %s', $this->onto);
|
||||
|
||||
if ($this->isGit) {
|
||||
$repository_api->execxLocal(
|
||||
'merge --squash --ff-only %s',
|
||||
$this->branch);
|
||||
}
|
||||
else if ($this->isHg) {
|
||||
$branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch);
|
||||
|
||||
$repository_api->execxLocal(
|
||||
'rebase --collapse --logfile %s -b %s -d %s %s',
|
||||
$this->messageFile,
|
||||
$this->branch,
|
||||
$this->onto,
|
||||
$this->keepBranch ? '--keep' : '');
|
||||
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);
|
||||
|
||||
if ($this->keepBranch) {
|
||||
$repository_api->execxLocal(
|
||||
'bookmark -f %s -r %s',
|
||||
$this->branch,
|
||||
$branch_rev_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function merge() {
|
||||
$repository_api = $this->getRepositoryAPI();
|
||||
|
@ -357,6 +498,7 @@ EOTEXT
|
|||
$repository_api->execxLocal('checkout %s', $this->onto);
|
||||
|
||||
chdir($repository_api->getPath());
|
||||
if ($this->isGit) {
|
||||
$err = phutil_passthru(
|
||||
'git merge --no-ff --no-commit %s',
|
||||
$this->branch);
|
||||
|
@ -368,16 +510,35 @@ EOTEXT
|
|||
"the instructions to complete the merge.");
|
||||
}
|
||||
}
|
||||
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(
|
||||
"--merge is not currently supported for hg repos.");
|
||||
}
|
||||
}
|
||||
|
||||
private function push() {
|
||||
$repository_api = $this->getRepositoryAPI();
|
||||
|
||||
$tmp_file = new TempFile();
|
||||
Filesystem::writeFile($tmp_file, $this->message);
|
||||
|
||||
if ($this->isGit) {
|
||||
$repository_api->execxLocal(
|
||||
'commit -F %s',
|
||||
$tmp_file);
|
||||
$this->messageFile);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->getArgument('hold')) {
|
||||
echo phutil_console_format(
|
||||
|
@ -388,10 +549,18 @@ EOTEXT
|
|||
|
||||
chdir($repository_api->getPath());
|
||||
|
||||
if ($this->isGit) {
|
||||
$err = phutil_passthru(
|
||||
'git push %s %s',
|
||||
$this->remote,
|
||||
$this->onto);
|
||||
}
|
||||
else if ($this->isHg) {
|
||||
$err = $repository_api->execPassthru(
|
||||
'push --new-branch -r %s %s',
|
||||
$this->onto,
|
||||
$this->remote);
|
||||
}
|
||||
|
||||
if ($err) {
|
||||
$repo_command = $repository_api->getSourceControlSystemName();
|
||||
|
@ -415,6 +584,7 @@ EOTEXT
|
|||
$repository_api = $this->getRepositoryAPI();
|
||||
|
||||
echo "Cleaning up feature branch...\n";
|
||||
if ($this->isGit) {
|
||||
list($ref) = $repository_api->execxLocal(
|
||||
'rev-parse --verify %s',
|
||||
$this->branch);
|
||||
|
@ -427,8 +597,19 @@ EOTEXT
|
|||
$repository_api->execxLocal(
|
||||
'branch -D %s',
|
||||
$this->branch);
|
||||
}
|
||||
else 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(
|
||||
'bookmark -d %s',
|
||||
$this->branch);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->getArgument('delete-remote')) {
|
||||
if ($this->isGit) {
|
||||
list($err, $ref) = $repository_api->execManualLocal(
|
||||
'rev-parse --verify %s/%s',
|
||||
$this->remote,
|
||||
|
@ -450,10 +631,35 @@ EOTEXT
|
|||
$this->branch);
|
||||
}
|
||||
}
|
||||
else 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function getSupportedRevisionControlSystems() {
|
||||
return array('git');
|
||||
return array('git', 'hg');
|
||||
}
|
||||
|
||||
private function getBranchOrBookmark() {
|
||||
$repository_api = $this->getRepositoryAPI();
|
||||
if ($this->isGit) {
|
||||
$branch = $repository_api->getBranchName();
|
||||
}
|
||||
else if ($this->isHg) {
|
||||
$branch = $repository_api->getActiveBookmark();
|
||||
if (!$branch) {
|
||||
$branch = $repository_api->getBranchName();
|
||||
}
|
||||
}
|
||||
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue