Page MenuHomeSealhub

No OneTemporary

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

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)

Event Timeline