Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F995699
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
50 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
index 6d5cfc78..e79e121d 100644
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1,784 +1,876 @@
<?php
/**
* Interfaces with the Mercurial working copies.
*
* @group workingcopy
*/
final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
private $status;
private $base;
private $relativeCommit;
private $branch;
private $workingCopyRevision;
private $localCommitInfo;
private $includeDirectoryStateInDiffs;
private $rawDiffCache = array();
protected function buildLocalFuture(array $argv) {
// Mercurial has a "defaults" feature which basically breaks automation by
// allowing the user to add random flags to any command. This feature is
// "deprecated" and "a bad idea" that you should "forget ... existed"
// according to project lead Matt Mackall:
//
// http://markmail.org/message/hl3d6eprubmkkqh5
//
// There is an HGPLAIN environmental variable which enables "plain mode"
// and hopefully disables this stuff.
if (phutil_is_windows()) {
$argv[0] = 'set HGPLAIN=1 & hg '.$argv[0];
} else {
$argv[0] = 'HGPLAIN=1 hg '.$argv[0];
}
$future = newv('ExecFuture', $argv);
$future->setCWD($this->getPath());
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';
}
public function getMetadataPath() {
return $this->getPath('.hg');
}
public function getSourceControlBaseRevision() {
return $this->getCanonicalRevisionName($this->getRelativeCommit());
}
public function getCanonicalRevisionName($string) {
list($stdout) = $this->execxLocal(
'log -l 1 --template %s -r %s --',
'{node}',
$string);
return $stdout;
}
public function getSourceControlPath() {
return '/';
}
public function getBranchName() {
if (!$this->branch) {
list($stdout) = $this->execxLocal('branch');
$this->branch = trim($stdout);
}
return $this->branch;
}
public function setRelativeCommit($commit) {
try {
$commit = $this->getCanonicalRevisionName($commit);
} catch (Exception $ex) {
throw new ArcanistUsageException(
"Commit '{$commit}' is not a valid Mercurial commit identifier.");
}
$this->relativeCommit = $commit;
$this->status = null;
$this->localCommitInfo = null;
return $this;
}
public function getRelativeCommit() {
if (empty($this->relativeCommit)) {
if ($this->getBaseCommitArgumentRules() ||
$this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly.");
}
$this->relativeCommit = $base;
return $this->relativeCommit;
}
list($err, $stdout) = $this->execManualLocal(
'outgoing --branch %s --style default',
$this->getBranchName());
if (!$err) {
$logs = ArcanistMercurialParser::parseMercurialLog($stdout);
} else {
// Mercurial (in some versions?) raises an error when there's nothing
// outgoing.
$logs = array();
}
if (!$logs) {
$this->setBaseCommitExplanation(
"you have no outgoing commits, so arc assumes you intend to submit ".
"uncommitted changes in the working copy.");
// In Mercurial, we support operations against uncommitted changes.
$this->setRelativeCommit($this->getWorkingCopyRevision());
return $this->relativeCommit;
}
$outgoing_revs = ipull($logs, 'rev');
// This is essentially an implementation of a theoretical `hg merge-base`
// command.
$against = $this->getWorkingCopyRevision();
while (true) {
// NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is
// new as of July 2011, so do this in a compatible way. Also, "hg log"
// and "hg outgoing" don't necessarily show parents (even if given an
// explicit template consisting of just the parents token) so we need
// to separately execute "hg parents".
list($stdout) = $this->execxLocal(
'parents --style default --rev %s',
$against);
$parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout);
list($p1, $p2) = array_merge($parents_logs, array(null, null));
if ($p1 && !in_array($p1['rev'], $outgoing_revs)) {
$against = $p1['rev'];
break;
} else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) {
$against = $p2['rev'];
break;
} else if ($p1) {
$against = $p1['rev'];
} else {
// This is the case where you have a new repository and the entire
// thing is outgoing; Mercurial literally accepts "--rev null" as
// meaning "diff against the empty state".
$against = 'null';
break;
}
}
if ($against == 'null') {
$this->setBaseCommitExplanation(
"this is a new repository (all changes are outgoing).");
} else {
$this->setBaseCommitExplanation(
"it is the first commit reachable from the working copy state ".
"which is not outgoing.");
}
$this->setRelativeCommit($against);
}
return $this->relativeCommit;
}
public function getLocalCommitInformation() {
if ($this->localCommitInfo === null) {
list($info) = $this->execxLocal(
"log --template '%C' --rev %s --branch %s --",
"{node}\1{rev}\1{author}\1{date|rfc822date}\1".
"{branch}\1{tag}\1{parents}\1{desc}\2",
'(ancestors(.) - ancestors('.$this->getRelativeCommit().'))',
$this->getBranchName());
$logs = array_filter(explode("\2", $info));
$last_node = null;
$futures = array();
$commits = array();
foreach ($logs as $log) {
list($node, $rev, $author, $date, $branch, $tag, $parents, $desc) =
explode("\1", $log);
// NOTE: If a commit has only one parent, {parents} returns empty.
// If it has two parents, {parents} returns revs and short hashes, not
// full hashes. Try to avoid making calls to "hg parents" because it's
// relatively expensive.
$commit_parents = null;
if (!$parents) {
if ($last_node) {
$commit_parents = array($last_node);
}
}
if (!$commit_parents) {
// We didn't get a cheap hit on previous commit, so do the full-cost
// "hg parents" call. We can run these in parallel, at least.
$futures[$node] = $this->execFutureLocal(
"parents --template='{node}\\n' --rev %s",
$node);
}
$commits[$node] = array(
'author' => $author,
'time' => strtotime($date),
'branch' => $branch,
'tag' => $tag,
'commit' => $node,
'rev' => $node, // TODO: Remove eventually.
'local' => $rev,
'parents' => $commit_parents,
'summary' => head(explode("\n", $desc)),
'message' => $desc,
);
$last_node = $node;
}
foreach (Futures($futures)->limit(4) as $node => $future) {
list($parents) = $future->resolvex();
$parents = array_filter(explode("\n", $parents));
$commits[$node]['parents'] = $parents;
}
// Put commits in newest-first order, to be consistent with Git and the
// expected order of "hg log" and "git log" under normal circumstances.
// The order of ancestors() is oldest-first.
$commits = array_reverse($commits);
$this->localCommitInfo = $commits;
}
return $this->localCommitInfo;
}
public function getAllFiles() {
// TODO: Handle paths with newlines.
$future = $this->buildLocalFuture(array('manifest'));
return new LinesOfALargeExecFuture($future);
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'status --rev %s',
$since_commit);
return ArcanistMercurialParser::parseMercurialStatus($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'annotate -u -v -c --rev %s -- %s',
$this->getRelativeCommit(),
$path);
$blame = array();
foreach (explode("\n", trim($stdout)) as $line) {
if (!strlen($line)) {
continue;
}
$matches = null;
$ok = preg_match('/^\s*([^:]+?) [a-f0-9]{12}: (.*)$/', $line, $matches);
if (!$ok) {
throw new Exception("Unable to parse Mercurial blame line: {$line}");
}
$revision = $matches[2];
$author = trim($matches[1]);
$blame[] = array($author, $revision);
}
return $blame;
}
public function getWorkingCopyStatus() {
if (!isset($this->status)) {
// A reviewable revision spans multiple local commits in Mercurial, but
// there is no way to get file change status across multiple commits, so
// just take the entire diff and parse it to figure out what's changed.
// Execute status in the background
$status_future = $this->buildLocalFuture(array('status'));
$status_future->start();
$diff = $this->getFullMercurialDiff();
if (!$diff) {
$this->status = array();
return $this->status;
}
$parser = new ArcanistDiffParser();
$changes = $parser->parseDiff($diff);
$status_map = array();
foreach ($changes as $change) {
$flags = 0;
switch ($change->getType()) {
case ArcanistDiffChangeType::TYPE_ADD:
case ArcanistDiffChangeType::TYPE_MOVE_HERE:
case ArcanistDiffChangeType::TYPE_COPY_HERE:
$flags |= self::FLAG_ADDED;
break;
case ArcanistDiffChangeType::TYPE_CHANGE:
case ArcanistDiffChangeType::TYPE_COPY_AWAY: // Check for changes?
$flags |= self::FLAG_MODIFIED;
break;
case ArcanistDiffChangeType::TYPE_DELETE:
case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
case ArcanistDiffChangeType::TYPE_MULTICOPY:
$flags |= self::FLAG_DELETED;
break;
}
$status_map[$change->getCurrentPath()] = $flags;
}
list($stdout) = $status_future->resolvex();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $status) {
if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) {
// If the file is untracked, don't mark it uncommitted.
continue;
}
$status |= self::FLAG_UNCOMMITTED;
if (!empty($status_map[$path])) {
$status_map[$path] |= $status;
} else {
$status_map[$path] = $status;
}
}
$this->status = $status_map;
}
return $this->status;
}
private function getDiffOptions() {
$options = array(
'--git',
'-U'.$this->getDiffLinesOfContext(),
);
return implode(' ', $options);
}
public function getRawDiffText($path) {
$options = $this->getDiffOptions();
// NOTE: In Mercurial, "--rev x" means "diff between x and the working
// copy state", while "--rev x..." means "diff between x and the working
// copy commit" (i.e., from 'x' to '.'). The latter excludes any dirty
// changes in the working copy.
$range = $this->getRelativeCommit();
if (!$this->includeDirectoryStateInDiffs) {
$range .= '...';
}
$raw_diff_cache_key = $options.' '.$range.' '.$path;
if (idx($this->rawDiffCache, $raw_diff_cache_key)) {
return idx($this->rawDiffCache, $raw_diff_cache_key);
}
list($stdout) = $this->execxLocal(
'diff %C --rev %s -- %s',
$options,
$range,
$path);
$this->rawDiffCache[$raw_diff_cache_key] = $stdout;
return $stdout;
}
public function getFullMercurialDiff() {
return $this->getRawDiffText('');
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getRelativeCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision(
$path,
$this->getWorkingCopyRevision());
}
private function getFileDataAtRevision($path, $revision) {
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s -- %s',
$revision,
$path);
if ($err) {
// Assume this is "no file at revision", i.e. a deleted or added file.
return null;
} else {
return $stdout;
}
}
public function getWorkingCopyRevision() {
return '.';
}
public function isHistoryDefaultImmutable() {
return true;
}
public function supportsAmend() {
list($err, $stdout) = $this->execManualLocal('help commit');
if ($err) {
return false;
} else {
return (strpos($stdout, "amend") !== false);
}
}
public function supportsRelativeLocalCommits() {
return true;
}
public function setDefaultBaseCommit() {
$this->setRelativeCommit('.^');
return $this;
}
public function hasLocalCommit($commit) {
try {
$this->getCanonicalRevisionName($commit);
return true;
} catch (Exception $ex) {
return false;
}
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log --template={desc} --rev %s',
$commit);
return $message;
}
public function parseRelativeLocalCommit(array $argv) {
if (count($argv) == 0) {
return;
}
if (count($argv) != 1) {
throw new ArcanistUsageException("Specify only one commit.");
}
$this->setBaseCommitExplanation("you explicitly specified it.");
// This does the "hg id" call we need to normalize/validate the revision
// identifier.
$this->setRelativeCommit(reset($argv));
}
public function getAllLocalChanges() {
$diff = $this->getFullMercurialDiff();
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function supportsLocalBranchMerge() {
return true;
}
public function performLocalBranchMerge($branch, $message) {
if ($branch) {
$err = phutil_passthru(
'(cd %s && HGPLAIN=1 hg merge --rev %s && hg commit -m %s)',
$this->getPath(),
$branch,
$message);
} else {
$err = phutil_passthru(
'(cd %s && HGPLAIN=1 hg merge && hg commit -m %s)',
$this->getPath(),
$message);
}
if ($err) {
throw new ArcanistUsageException("Merge failed!");
}
}
public function getFinalizedRevisionMessage() {
return "You may now push this commit upstream, as appropriate (e.g. with ".
"'hg push' or by printing and faxing it).";
}
public function getCommitMessageLog() {
list($stdout) = $this->execxLocal(
"log --template '{node}\\2{desc}\\1' --rev %s --branch %s --",
'ancestors(.) - ancestors('.$this->getRelativeCommit().')',
$this->getBranchName());
$map = array();
$logs = explode("\1", trim($stdout));
foreach (array_filter($logs) as $log) {
list($node, $desc) = explode("\2", $log);
$map[$node] = $desc;
}
return array_reverse($map);
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getCommitMessageLog();
$parser = new ArcanistDiffParser();
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $node_id => $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message);
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $node_id;
}
}
if ($revision_ids) {
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'ids' => $revision_ids,
));
foreach ($results as $key => $result) {
$hash = substr($reason_map[$result['id']], 0, 16);
$results[$key]['why'] =
"Commit message for '{$hash}' has explicit 'Differential Revision'.";
}
return $results;
}
// Try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('hgcm', $commit['commit']);
}
if ($hashes) {
// NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working
// copy with dirty changes, there may be no local commits.
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $hash) {
$results[$key]['why'] =
"A mercurial commit hash in the commit range is already attached ".
"to the Differential revision.";
}
return $results;
}
return array();
}
public function updateWorkingCopy() {
$this->execxLocal('up');
}
private function getMercurialConfig($key, $default = null) {
list($stdout) = $this->execxLocal('showconfig %s', $key);
if ($stdout == '') {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
return $this->getMercurialConfig('ui.username');
}
public function addToCommit(array $paths) {
$this->execxLocal(
'add -- %Ls',
$paths);
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit -l %s',
$tmp_file);
}
public function amendCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend -l %s',
$tmp_file);
}
public function setIncludeDirectoryStateInDiffs($include) {
$this->includeDirectoryStateInDiffs = $include;
return $this;
}
public function getCommitSummary($commit) {
if ($commit == 'null') {
return '(The Empty Void)';
}
list($summary) = $this->execxLocal(
'log --template {desc} --limit 1 --rev %s',
$commit);
$summary = head(explode("\n", $summary));
return trim($summary);
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
switch ($type) {
case 'hg':
$matches = null;
if (preg_match('/^gca\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'log --template={node} --rev %s',
sprintf('ancestor(., %s)', $matches[1]));
if (!$err) {
$this->setBaseCommitExplanation(
"it is the greatest common ancestor of '{$matches[1]}' and ., as".
"specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return trim($merge_base);
}
} else {
list($err) = $this->execManualLocal(
'id -r %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
"it is specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
"you specified '{$rule}' in your {$source} 'base' ".
"configuration.");
return 'null';
case 'outgoing':
list($err, $outgoing_base) = $this->execManualLocal(
'log --template={node} --rev %s',
'limit(reverse(ancestors(.) - outgoing()), 1)'
);
if (!$err) {
$this->setBaseCommitExplanation(
"it is the first ancestor of the working copy that is not ".
"outgoing, and it matched the rule {$rule} in your {$source} ".
"'base' configuration.");
return trim($outgoing_base);
}
case 'amended':
$text = $this->getCommitMessage('.');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
"'.' has been amended with 'Differential Revision:', ".
"as specified by '{$rule}' in your {$source} 'base' ".
"configuration.");
// NOTE: This should be safe because Mercurial doesn't support
// amend until 2.2.
return '.^';
}
break;
case 'bookmark':
$revset =
'limit('.
' sort('.
' (ancestors(.) and bookmark() - .) or'.
' (ancestors(.) - outgoing()), '.
' -rev),'.
'1)';
list($err, $bookmark_base) = $this->execManualLocal(
'log --template={node} --rev %s',
$revset);
if (!$err) {
$this->setBaseCommitExplanation(
"it is the first ancestor of . that either has a bookmark, or ".
"is already in the remote and it matched the rule {$rule} in ".
"your {$source} 'base' configuration");
return trim($bookmark_base);
}
}
break;
default:
return null;
}
return null;
}
public function getSubversionInfo() {
$info = array();
$base_path = null;
$revision = null;
list($err, $raw_info) = $this->execManualLocal('svn info');
if (!$err) {
foreach (explode("\n", trim($raw_info)) as $line) {
list($key, $value) = explode(': ', $line, 2);
switch ($key) {
case 'URL':
$info['base_path'] = $value;
$base_path = $value;
break;
case 'Repository UUID':
$info['uuid'] = $value;
break;
case 'Revision':
$revision = $value;
break;
default:
break;
}
}
if ($base_path && $revision) {
$info['base_revision'] = $base_path.'@'.$revision;
}
}
return $info;
}
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);
+ // 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 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));
+ }
}
diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php
index 4a650eef..e84bf610 100644
--- a/src/workflow/ArcanistLandWorkflow.php
+++ b/src/workflow/ArcanistLandWorkflow.php
@@ -1,459 +1,665 @@
<?php
/**
* Lands a branch by rebasing, merging and amending it.
*
* @group workflow
*/
final class ArcanistLandWorkflow extends ArcanistBaseWorkflow {
private $isGit;
+ private $isHg;
private $oldBranch;
private $branch;
private $onto;
private $ontoRemoteBranch;
private $remote;
private $useSquash;
private $keepBranch;
private $revision;
- private $message;
+ private $messageFile;
public function getWorkflowName() {
return 'land';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**land** [__options__] [__branch__] [--onto __master__]
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
the feature branch. If you omit __branch__, the current branch will
be used.
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).
+
+ Under hg, bookmarks can be landed the same way as branches.
EOTEXT
);
}
public function requiresWorkingCopy() {
return true;
}
public function requiresConduit() {
return true;
}
public function requiresAuthentication() {
return true;
}
public function requiresRepositoryAPI() {
return true;
}
public function getArguments() {
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 ".
"push it.",
),
'keep-branch' => array(
'help' => "Keep the feature branch after pushing changes to the ".
"remote (by default, it is deleted).",
),
'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 '.
'project is marked as having a mutable history, this is '.
'the default behavior.',
'conflicts' => array(
'merge' => '--merge and --squash are conflicting merge strategies.',
),
),
'delete-remote' => array(
'help' => 'Delete the feature branch in the remote after '.
'landing it.',
'conflicts' => array(
'keep-branch' => true,
),
),
'revision' => array(
'param' => 'id',
'help' => 'Use the message from a specific revision, rather than '.
'inferring the revision based on branch content.',
),
'*' => 'branch',
);
}
public function run() {
$this->readArguments();
$this->validate();
$this->pullFromRemote();
$this->checkoutBranch();
$this->findRevision();
if ($this->useSquash) {
$this->rebase();
$this->squash();
} else {
$this->merge();
}
$this->push();
if (!$this->keepBranch) {
$this->cleanupBranch();
}
// If we were on some branch A and the user ran "arc land B",
// switch back to A.
if ($this->oldBranch != $this->branch && $this->oldBranch != $this->onto) {
$repository_api = $this->getRepositoryAPI();
$repository_api->execxLocal(
'checkout %s',
$this->oldBranch);
echo phutil_console_format(
"Switched back to branch **%s**.\n",
$this->oldBranch);
}
echo "Done.\n";
return 0;
}
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";
$branch = array($branch);
}
}
if (count($branch) !== 1) {
throw new ArcanistUsageException(
"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;
} else if ($this->getArgument('squash')) {
$this->useSquash = true;
} else {
$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() {
$repository_api = $this->getRepositoryAPI();
if ($this->onto == $this->branch) {
$message =
"You can not land a branch onto itself -- you are trying to land ".
"'{$this->branch}' onto '{$this->onto}'. For more information on ".
"how to push changes, see 'Pushing and Closing Revisions' in ".
"'Arcanist User Guide: arc diff' in the documentation.";
if (!$this->isHistoryImmutable()) {
$message .= " You may be able to 'arc amend' instead.";
}
throw new ArcanistUsageException($message);
}
- list($err) = $repository_api->execManualLocal(
- 'rev-parse --verify %s',
- $this->branch);
+ 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 ($err) {
- throw new ArcanistUsageException(
- "Branch '{$this->branch}' does not exist.");
+ 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);
+
+ if ($err) {
+ throw new ArcanistUsageException(
+ "Branch '{$this->branch}' does not exist.");
+ }
}
$this->requireCleanWorkingCopy();
}
private function checkoutBranch() {
$repository_api = $this->getRepositoryAPI();
$repository_api->execxLocal(
'checkout %s',
$this->branch);
echo phutil_console_format(
"Switched to branch **%s**. Identifying and merging...\n",
$this->branch);
}
private function findRevision() {
$repository_api = $this->getRepositoryAPI();
$repository_api->parseRelativeLocalCommit(array($this->ontoRemoteBranch));
$revision_id = $this->getArgument('revision');
if ($revision_id) {
$revision_id = $this->normalizeRevisionID($revision_id);
$revisions = $this->getConduit()->callMethodSynchronous(
'differential.query',
array(
'ids' => array($revision_id),
));
if (!$revisions) {
throw new ArcanistUsageException("No such revision 'D{$revision_id}'!");
}
} else {
$revisions = $repository_api->loadWorkingCopyDifferentialRevisions(
$this->getConduit(),
array(
'authors' => array($this->getUserPHID()),
));
}
if (!count($revisions)) {
throw new ArcanistUsageException(
"arc can not identify which revision exists on branch ".
"'{$this->branch}'. Update the revision with recent changes ".
"to synchronize the branch name and hashes, or use 'arc amend' ".
"to amend the commit message at HEAD, or use '--revision <id>' ".
"to select a revision explicitly.");
} else if (count($revisions) > 1) {
$message =
"There are multiple revisions on feature branch '{$this->branch}' ".
"which are not present on '{$onto}':\n\n".
$this->renderRevisionList($revisions)."\n".
"Separate these revisions onto different branches, or use ".
"'--revision <id>' to select one.";
throw new ArcanistUsageException($message);
}
$this->revision = head($revisions);
$rev_status = $this->revision['status'];
$rev_id = $this->revision['id'];
$rev_title = $this->revision['title'];
if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) {
$ok = phutil_console_confirm(
"Revision 'D{$rev_id}: {$rev_title}' has not been ".
"accepted. Continue anyway?");
if (!$ok) {
throw new ArcanistUserAbortException();
}
}
- $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";
}
private function pullFromRemote() {
$repository_api = $this->getRepositoryAPI();
$repository_api->execxLocal('checkout %s', $this->onto);
echo phutil_console_format(
"Switched to branch **%s**. Updating branch...\n",
$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(
- 'log %s/%s..%s',
- $this->remote,
- $this->onto,
- $this->onto);
- if (strlen(trim($out))) {
+ list($out) = $repository_api->execxLocal(
+ 'log %s/%s..%s',
+ $this->remote,
+ $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 ".
"would push additional changes. Push or reset the changes ".
"in '{$this->onto}' before running 'arc land'.");
}
}
private function rebase() {
$repository_api = $this->getRepositoryAPI();
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) {
+ $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);
- $repository_api->execxLocal(
- 'merge --squash --ff-only %s',
- $this->branch);
+ 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();
// In immutable histories, do a --no-ff merge to force a merge commit with
// the right message.
$repository_api->execxLocal('checkout %s', $this->onto);
chdir($repository_api->getPath());
- $err = phutil_passthru(
- 'git merge --no-ff --no-commit %s',
- $this->branch);
+ if ($this->isGit) {
+ $err = phutil_passthru(
+ '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(
- "'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.");
+ "--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);
-
- $repository_api->execxLocal(
- 'commit -F %s',
- $tmp_file);
+ if ($this->isGit) {
+ $repository_api->execxLocal(
+ 'commit -F %s',
+ $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(
"Holding change in **%s**: it has NOT been pushed yet.\n",
$this->onto);
} else {
echo "Pushing change...\n\n";
chdir($repository_api->getPath());
- $err = phutil_passthru(
- 'git push %s %s',
- $this->remote,
- $this->onto);
+ 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();
throw new ArcanistUsageException("'{$repo_command} push' failed.");
}
$mark_workflow = $this->buildChildWorkflow(
'close-revision',
array(
'--finalize',
'--quiet',
$this->revision['id'],
));
$mark_workflow->run();
echo "\n";
}
}
private function cleanupBranch() {
$repository_api = $this->getRepositoryAPI();
echo "Cleaning up feature branch...\n";
- list($ref) = $repository_api->execxLocal(
- 'rev-parse --verify %s',
- $this->branch);
- $ref = trim($ref);
- $recovery_command = csprintf(
- 'git checkout -b %s %s',
- $this->branch,
- $ref);
- echo "(Use `{$recovery_command}` if you want it back.)\n";
- $repository_api->execxLocal(
- 'branch -D %s',
- $this->branch);
-
- if ($this->getArgument('delete-remote')) {
- list($err, $ref) = $repository_api->execManualLocal(
- 'rev-parse --verify %s/%s',
- $this->remote,
+ if ($this->isGit) {
+ list($ref) = $repository_api->execxLocal(
+ 'rev-parse --verify %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";
+ $ref = trim($ref);
+ $recovery_command = csprintf(
+ 'git checkout -b %s %s',
+ $this->branch,
+ $ref);
+ echo "(Use `{$recovery_command}` if you want it back.)\n";
+ $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(
- 'push %s :%s',
+ '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,
$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() {
- 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;
+ }
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Dec 23, 13:19 (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
556924
Default Alt Text
(50 KB)
Attached To
Mode
R118 Arcanist - fork
Attached
Detach File
Event Timeline
Log In to Comment