1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-11-10 08:52:39 +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:
durham 2012-12-03 17:59:12 -08:00
parent 5025c06f3d
commit aada1440ef
2 changed files with 386 additions and 88 deletions

View file

@ -39,6 +39,17 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
return $future; 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() { public function getSourceControlSystemName() {
return 'hg'; return 'hg';
} }
@ -768,17 +779,98 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
} }
public function getActiveBookmark() { 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'); list($raw_output) = $this->execxLocal('bookmarks');
$raw_output = trim($raw_output); $raw_output = trim($raw_output);
if ($raw_output !== 'no bookmarks set') { if ($raw_output !== 'no bookmarks set') {
foreach (explode("\n", $raw_output) as $line) { foreach (explode("\n", $raw_output) as $line) {
$line = trim($line); // example line: * mybook 2:6b274d49be97
if ('*' === $line[0]) { list($name, $revision) = $this->splitBranchOrBookmarkLine($line);
return idx(explode(' ', $line, 3), 1);
$is_active = false;
if ('*' === $name[0]) {
$is_active = true;
$name = substr($name, 2);
} }
$bookmarks[] = array(
'is_active' => $is_active,
'name' => $name,
'revision' => $revision);
} }
} }
return null;
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));
}
} }

View file

@ -7,6 +7,7 @@
*/ */
final class ArcanistLandWorkflow extends ArcanistBaseWorkflow { final class ArcanistLandWorkflow extends ArcanistBaseWorkflow {
private $isGit; private $isGit;
private $isHg;
private $oldBranch; private $oldBranch;
private $branch; private $branch;
@ -17,7 +18,7 @@ final class ArcanistLandWorkflow extends ArcanistBaseWorkflow {
private $keepBranch; private $keepBranch;
private $revision; private $revision;
private $message; private $messageFile;
public function getWorkflowName() { public function getWorkflowName() {
return 'land'; return 'land';
@ -32,7 +33,7 @@ EOTEXT
public function getCommandHelp() { public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT return phutil_console_format(<<<EOTEXT
Supports: git Supports: git, hg
Land an accepted change (currently sitting in local feature branch Land an accepted change (currently sitting in local feature branch
__branch__) onto __master__ and push it to the remote. Then, delete __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 immutable repositories (or when --merge is provided), it will perform
a --no-ff merge (the branch will always be merged into __master__ with a --no-ff merge (the branch will always be merged into __master__ with
a merge commit). a merge commit).
Under hg, bookmarks can be landed the same way as branches.
EOTEXT EOTEXT
); );
} }
@ -68,10 +71,10 @@ EOTEXT
return array( return array(
'onto' => array( 'onto' => array(
'param' => 'master', 'param' => 'master',
'help' => "Land feature branch onto a branch other than ". 'help' => "Land feature branch onto a branch other than the default ".
"'master' (default). You can change the default by setting ". "('master' in git, 'default' in hg). You can change the ".
"'arc.land.onto.default' with `arc set-config` or for the ". "default by setting 'arc.land.onto.default' with ".
"entire project in .arcconfig.", "`arc set-config` or for the entire project in .arcconfig.",
), ),
'hold' => array( 'hold' => array(
'help' => "Prepare the change to be pushed, but do not actually ". 'help' => "Prepare the change to be pushed, but do not actually ".
@ -83,12 +86,18 @@ EOTEXT
), ),
'remote' => array( 'remote' => array(
'param' => 'origin', '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( 'merge' => array(
'help' => 'Perform a --no-ff merge, not a --squash merge. If the '. 'help' => 'Perform a --no-ff merge, not a --squash merge. If the '.
'project is marked as having an immutable history, this is '. 'project is marked as having an immutable history, this is '.
'the default behavior.', 'the default behavior.',
'supports' => array(
'git',
),
'nosupport' => array(
'hg' => 'Use the --squash strategy when landing in mercurial.',
),
), ),
'squash' => array( 'squash' => array(
'help' => 'Perform a --squash merge, not a --no-ff merge. If the '. 'help' => 'Perform a --squash merge, not a --no-ff merge. If the '.
@ -154,14 +163,16 @@ EOTEXT
private function readArguments() { private function readArguments() {
$repository_api = $this->getRepositoryAPI(); $repository_api = $this->getRepositoryAPI();
$this->isGit = $repository_api instanceof ArcanistGitAPI; $this->isGit = $repository_api instanceof ArcanistGitAPI;
$this->isHg = $repository_api instanceof ArcanistMercurialAPI;
if (!$this->isGit) { if (!$this->isGit && !$this->isHg) {
throw new ArcanistUsageException("'arc land' only supports git."); throw new ArcanistUsageException(
"'arc land' only supports git and mercurial.");
} }
$branch = $this->getArgument('branch'); $branch = $this->getArgument('branch');
if (empty($branch)) { if (empty($branch)) {
$branch = $repository_api->getBranchName(); $branch = $this->getBranchOrBookmark();
if ($branch) { if ($branch) {
echo "Landing current branch '{$branch}'.\n"; echo "Landing current branch '{$branch}'.\n";
@ -174,14 +185,16 @@ EOTEXT
"Specify exactly one branch to land changes from."); "Specify exactly one branch to land changes from.");
} }
$this->branch = head($branch); $this->branch = head($branch);
$this->keepBranch = $this->getArgument('keep-branch');
$onto_default = $this->isGit ? 'master' : 'default';
$onto_default = nonempty( $onto_default = nonempty(
$this->getWorkingCopy()->getConfigFromAnySource('arc.land.onto.default'), $this->getWorkingCopy()->getConfigFromAnySource('arc.land.onto.default'),
'master'); $onto_default);
$this->remote = $this->getArgument('remote', 'origin');
$this->onto = $this->getArgument('onto', $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')) { if ($this->getArgument('merge')) {
$this->useSquash = false; $this->useSquash = false;
@ -191,9 +204,12 @@ EOTEXT
$this->useSquash = !$this->isHistoryImmutable(); $this->useSquash = !$this->isHistoryImmutable();
} }
$this->ontoRemoteBranch = $this->remote.'/'.$this->onto; $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() { private function validate() {
@ -211,13 +227,44 @@ EOTEXT
throw new ArcanistUsageException($message); throw new ArcanistUsageException($message);
} }
list($err) = $repository_api->execManualLocal( if ($this->isHg) {
'rev-parse --verify %s', if ($this->useSquash) {
$this->branch); list ($err) = $repository_api->execManualLocal("rebase --help");
if ($err) {
throw new ArcanistUsageException(
"You must enable the rebase extension to use ".
"the --squash strategy.");
}
}
if ($err) { if ($repository_api->isBookmark($this->branch) &&
throw new ArcanistUsageException( !$repository_api->isBookmark($this->onto)) {
"Branch '{$this->branch}' does not exist."); 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);
if ($err) {
throw new ArcanistUsageException(
"Branch '{$this->branch}' does not exist.");
}
} }
$this->requireCleanWorkingCopy(); $this->requireCleanWorkingCopy();
@ -290,12 +337,15 @@ EOTEXT
} }
} }
$this->message = $this->getConduit()->callMethodSynchronous( $message = $this->getConduit()->callMethodSynchronous(
'differential.getcommitmessage', 'differential.getcommitmessage',
array( array(
'revision_id' => $rev_id, 'revision_id' => $rev_id,
)); ));
$this->messageFile = new TempFile();
Filesystem::writeFile($this->messageFile, $message);
echo "Landing revision 'D{$rev_id}: ". echo "Landing revision 'D{$rev_id}: ".
"{$rev_title}'...\n"; "{$rev_title}'...\n";
} }
@ -308,14 +358,64 @@ EOTEXT
"Switched to branch **%s**. Updating branch...\n", "Switched to branch **%s**. Updating branch...\n",
$this->onto); $this->onto);
$repository_api->execxLocal('pull --ff-only'); $local_ahead_of_remote = false;
if ($this->isGit) {
$repository_api->execxLocal('pull --ff-only');
list($out) = $repository_api->execxLocal( list($out) = $repository_api->execxLocal(
'log %s/%s..%s', 'log %s/%s..%s',
$this->remote, $this->remote,
$this->onto, $this->onto,
$this->onto); $this->onto);
if (strlen(trim($out))) { 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( throw new ArcanistUsageException(
"Local branch '{$this->onto}' is ahead of remote branch ". "Local branch '{$this->onto}' is ahead of remote branch ".
"'{$this->ontoRemoteBranch}', so landing a feature branch ". "'{$this->ontoRemoteBranch}', so landing a feature branch ".
@ -328,25 +428,66 @@ EOTEXT
$repository_api = $this->getRepositoryAPI(); $repository_api = $this->getRepositoryAPI();
chdir($repository_api->getPath()); chdir($repository_api->getPath());
$err = phutil_passthru('git rebase %s', $this->onto); 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) { if ($err) {
$command = $repository_api->getSourceControlSystemName();
throw new ArcanistUsageException( throw new ArcanistUsageException(
"'git rebase {$this->onto}' failed. ". "'{$command} rebase {$this->onto}' failed. ".
"You can abort with 'git rebase --abort', ". "You can abort with '{$command} rebase --abort', ".
"or resolve conflicts and use 'git rebase ". "or resolve conflicts and use '{$command} rebase ".
"--continue' to continue forward. After resolving the rebase, ". "--continue' to continue forward. After resolving the rebase, ".
"run 'arc land' again."); "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() { private function squash() {
$repository_api = $this->getRepositoryAPI(); $repository_api = $this->getRepositoryAPI();
$repository_api->execxLocal('checkout %s', $this->onto); $repository_api->execxLocal('checkout %s', $this->onto);
$repository_api->execxLocal( if ($this->isGit) {
'merge --squash --ff-only %s', $repository_api->execxLocal(
$this->branch); '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() { private function merge() {
@ -357,27 +498,47 @@ EOTEXT
$repository_api->execxLocal('checkout %s', $this->onto); $repository_api->execxLocal('checkout %s', $this->onto);
chdir($repository_api->getPath()); chdir($repository_api->getPath());
$err = phutil_passthru( if ($this->isGit) {
'git merge --no-ff --no-commit %s', $err = phutil_passthru(
$this->branch); 'git merge --no-ff --no-commit %s',
$this->branch);
if ($err) { if ($err) {
throw new ArcanistUsageException(
"'git merge' failed. Your working copy has been left in a partially ".
"merged state. You can: abort with 'git merge --abort'; or follow ".
"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( throw new ArcanistUsageException(
"'git merge' failed. Your working copy has been left in a partially ". "--merge is not currently supported for hg repos.");
"merged state. You can: abort with 'git merge --abort'; or follow ".
"the instructions to complete the merge.");
} }
} }
private function push() { private function push() {
$repository_api = $this->getRepositoryAPI(); $repository_api = $this->getRepositoryAPI();
$tmp_file = new TempFile(); if ($this->isGit) {
Filesystem::writeFile($tmp_file, $this->message); $repository_api->execxLocal(
'commit -F %s',
$repository_api->execxLocal( $this->messageFile);
'commit -F %s', }
$tmp_file); 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')) { if ($this->getArgument('hold')) {
echo phutil_console_format( echo phutil_console_format(
@ -388,10 +549,18 @@ EOTEXT
chdir($repository_api->getPath()); chdir($repository_api->getPath());
$err = phutil_passthru( if ($this->isGit) {
'git push %s %s', $err = phutil_passthru(
$this->remote, 'git push %s %s',
$this->onto); $this->remote,
$this->onto);
}
else if ($this->isHg) {
$err = $repository_api->execPassthru(
'push --new-branch -r %s %s',
$this->onto,
$this->remote);
}
if ($err) { if ($err) {
$repo_command = $repository_api->getSourceControlSystemName(); $repo_command = $repository_api->getSourceControlSystemName();
@ -415,45 +584,82 @@ EOTEXT
$repository_api = $this->getRepositoryAPI(); $repository_api = $this->getRepositoryAPI();
echo "Cleaning up feature branch...\n"; echo "Cleaning up feature branch...\n";
list($ref) = $repository_api->execxLocal( if ($this->isGit) {
'rev-parse --verify %s', list($ref) = $repository_api->execxLocal(
$this->branch); 'rev-parse --verify %s',
$ref = trim($ref); $this->branch);
$recovery_command = csprintf( $ref = trim($ref);
'git checkout -b %s %s', $recovery_command = csprintf(
$this->branch, 'git checkout -b %s %s',
$ref); $this->branch,
echo "(Use `{$recovery_command}` if you want it back.)\n"; $ref);
$repository_api->execxLocal( echo "(Use `{$recovery_command}` if you want it back.)\n";
'branch -D %s', $repository_api->execxLocal(
$this->branch); '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->getArgument('delete-remote')) {
list($err, $ref) = $repository_api->execManualLocal( if ($this->isGit) {
'rev-parse --verify %s/%s', list($err, $ref) = $repository_api->execManualLocal(
$this->remote, 'rev-parse --verify %s/%s',
$this->branch);
if ($err) {
echo "No remote feature branch to clean up.\n";
} else {
// NOTE: In Git, you delete a remote branch by pushing it with a
// colon in front of its name:
//
// git push <remote> :<branch>
echo "Cleaning up remote feature branch...\n";
$repository_api->execxLocal(
'push %s :%s',
$this->remote, $this->remote,
$this->branch); $this->branch);
if ($err) {
echo "No remote feature branch to clean up.\n";
} else {
// NOTE: In Git, you delete a remote branch by pushing it with a
// colon in front of its name:
//
// git push <remote> :<branch>
echo "Cleaning up remote feature branch...\n";
$repository_api->execxLocal(
'push %s :%s',
$this->remote,
$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() { 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;
}
} }