Page MenuHomeSealhub

No OneTemporary

diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php
index c8eda270..1e237991 100644
--- a/src/land/engine/ArcanistGitLandEngine.php
+++ b/src/land/engine/ArcanistGitLandEngine.php
@@ -1,1436 +1,1432 @@
<?php
final class ArcanistGitLandEngine
extends ArcanistLandEngine {
private $isGitPerforce;
private $landTargetCommitMap = array();
private $deletedBranches = array();
private function setIsGitPerforce($is_git_perforce) {
$this->isGitPerforce = $is_git_perforce;
return $this;
}
private function getIsGitPerforce() {
return $this->isGitPerforce;
}
protected function pruneBranches(array $sets) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$old_commits = array();
foreach ($sets as $set) {
$hash = last($set->getCommits())->getHash();
$old_commits[] = $hash;
}
$branch_map = $this->getBranchesForCommits(
$old_commits,
$is_contains = false);
foreach ($branch_map as $branch_name => $branch_hash) {
$recovery_command = csprintf(
'git checkout -b %s %s',
$branch_name,
$this->getDisplayHash($branch_hash));
$log->writeStatus(
pht('CLEANUP'),
pht('Destroying branch "%s". To recover, run:', $branch_name));
echo tsprintf(
"\n **$** %s\n\n",
$recovery_command);
$api->execxLocal('branch -D -- %s', $branch_name);
$this->deletedBranches[$branch_name] = true;
}
}
private function getBranchesForCommits(array $hashes, $is_contains) {
$api = $this->getRepositoryAPI();
$format = '%(refname) %(objectname)';
$result = array();
foreach ($hashes as $hash) {
if ($is_contains) {
$command = csprintf(
'for-each-ref --contains %s --format %s --',
$hash,
$format);
} else {
$command = csprintf(
'for-each-ref --points-at %s --format %s --',
$hash,
$format);
}
list($foreach_lines) = $api->execxLocal('%C', $command);
$foreach_lines = phutil_split_lines($foreach_lines, false);
foreach ($foreach_lines as $line) {
if (!strlen($line)) {
continue;
}
$expect_parts = 2;
$parts = explode(' ', $line, $expect_parts);
if (count($parts) !== $expect_parts) {
throw new Exception(
pht(
'Failed to explode line "%s".',
$line));
}
$ref_name = $parts[0];
$ref_hash = $parts[1];
$matches = null;
$ok = preg_match('(^refs/heads/(.*)\z)', $ref_name, $matches);
if ($ok === false) {
throw new Exception(
pht(
'Failed to match against branch pattern "%s".',
$line));
}
if (!$ok) {
continue;
}
$result[$matches[1]] = $ref_hash;
}
}
// Sort the result so that branches are processed in natural order.
$names = array_keys($result);
natcasesort($names);
$result = array_select_keys($result, $names);
return $result;
}
protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// This has no effect when we're executing a merge strategy.
if (!$this->isSquashStrategy()) {
return;
}
$old_commit = last($set->getCommits())->getHash();
$new_commit = $into_commit;
$branch_map = $this->getBranchesForCommits(
array($old_commit),
$is_contains = true);
$log = $this->getLogEngine();
foreach ($branch_map as $branch_name => $branch_head) {
// If this branch just points at the old state, don't bother rebasing
// it. We'll update or delete it later.
if ($branch_head === $old_commit) {
continue;
}
$log->writeStatus(
pht('CASCADE'),
pht(
'Rebasing "%s" onto landed state...',
$branch_name));
try {
$api->execxLocal(
'rebase --onto %s -- %s %s',
$new_commit,
$old_commit,
$branch_name);
} catch (CommandException $ex) {
// TODO: If we have a stashed state or are not running in incremental
// mode: abort the rebase, restore the local state, and pop the stash.
// Otherwise, drop the user out here.
throw $ex;
}
}
}
private function fetchTarget(ArcanistLandTarget $target) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// NOTE: Although this output isn't hugely useful, we need to passthru
// instead of using a subprocess here because `git fetch` may prompt the
// user to enter a password if they're fetching over HTTP with basic
// authentication. See T10314.
if ($this->getIsGitPerforce()) {
$log->writeStatus(
pht('P4 SYNC'),
pht(
'Synchronizing "%s" from Perforce...',
$target->getRef()));
- $err = $api->execPassthru(
+ $err = $this->newPassthru(
'p4 sync --silent --branch %s --',
$target->getRemote().'/'.$target->getRef());
-
if ($err) {
throw new ArcanistUsageException(
pht(
'Perforce sync failed! Fix the error and run "arc land" again.'));
}
return $this->getLandTargetLocalCommit($target);
}
$exists = $this->getLandTargetLocalExists($target);
if (!$exists) {
$log->writeWarning(
pht('TARGET'),
pht(
'No local copy of ref "%s" in remote "%s" exists, attempting '.
'fetch...',
$target->getRef(),
$target->getRemote()));
$this->fetchLandTarget($target, $ignore_failure = true);
$exists = $this->getLandTargetLocalExists($target);
if (!$exists) {
return null;
}
$log->writeStatus(
pht('FETCHED'),
pht(
'Fetched ref "%s" from remote "%s".',
$target->getRef(),
$target->getRemote()));
return $this->getLandTargetLocalCommit($target);
}
$log->writeStatus(
pht('FETCH'),
pht(
'Fetching "%s" from remote "%s"...',
$target->getRef(),
$target->getRemote()));
$this->fetchLandTarget($target, $ignore_failure = false);
return $this->getLandTargetLocalCommit($target);
}
protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$is_empty = ($into_commit === null);
if ($is_empty) {
$empty_commit = ArcanistGitRawCommit::newEmptyCommit();
$into_commit = $api->writeRawCommit($empty_commit);
}
$api->execxLocal('checkout %s --', $into_commit);
$commits = $set->getCommits();
$max_commit = last($commits);
$source_commit = $max_commit->getHash();
// NOTE: See T11435 for some history. See PHI1727 for a case where a user
// modified their working copy while running "arc land". This attempts to
// resist incorrectly detecting simultaneous working copy modifications
// as changes.
list($changes) = $api->execxLocal(
'diff --no-ext-diff %s..%s --',
$into_commit,
$source_commit);
$changes = trim($changes);
if (!strlen($changes)) {
// TODO: We could make a more significant effort to identify the
// human-readable symbol which led us to try to land this ref.
throw new PhutilArgumentUsageException(
pht(
'Merging local "%s" into "%s" produces an empty diff. '.
'This usually means these changes have already landed.',
$this->getDisplayHash($source_commit),
$this->getDisplayHash($into_commit)));
}
$log->writeStatus(
pht('MERGING'),
pht(
'%s %s',
$this->getDisplayHash($source_commit),
$max_commit->getDisplaySummary()));
$argv = array();
$argv[] = '--no-stat';
$argv[] = '--no-commit';
// When we're merging into the empty state, Git refuses to perform the
// merge until we tell it explicitly that we're doing something unusual.
if ($is_empty) {
$argv[] = '--allow-unrelated-histories';
}
if ($this->isSquashStrategy()) {
// NOTE: We're explicitly specifying "--ff" to override the presence
// of "merge.ff" options in user configuration.
$argv[] = '--ff';
$argv[] = '--squash';
} else {
$argv[] = '--no-ff';
}
$argv[] = '--';
$argv[] = $source_commit;
try {
$api->execxLocal('merge %Ls', $argv);
} catch (CommandException $ex) {
// TODO: If we previously succeeded with at least one merge, we could
// provide a hint that "--incremental" can do some of the work.
$api->execManualLocal('merge --abort');
$api->execManualLocal('reset --hard HEAD --');
$direct_symbols = $max_commit->getDirectSymbols();
$indirect_symbols = $max_commit->getIndirectSymbols();
if ($direct_symbols) {
$message = pht(
'Local commit "%s" (%s) does not merge cleanly into "%s". '.
'Merge or rebase local changes so they can merge cleanly.',
$this->getDisplayHash($source_commit),
$this->getDisplaySymbols($direct_symbols),
$this->getDisplayHash($into_commit));
} else if ($indirect_symbols) {
$message = pht(
'Local commit "%s" (reachable from: %s) does not merge cleanly '.
'into "%s". Merge or rebase local changes so they can merge '.
'cleanly.',
$this->getDisplayHash($source_commit),
$this->getDisplaySymbols($indirect_symbols),
$this->getDisplayHash($into_commit));
} else {
$message = pht(
'Local commit "%s" does not merge cleanly into "%s". Merge or '.
'rebase local changes so they can merge cleanly.',
$this->getDisplayHash($source_commit),
$this->getDisplayHash($into_commit));
}
throw new PhutilArgumentUsageException($message);
}
list($original_author, $original_date) = $this->getAuthorAndDate(
$source_commit);
$revision_ref = $set->getRevisionRef();
$commit_message = $revision_ref->getCommitMessage();
$future = $api->execFutureLocal(
'commit --author %s --date %s -F - --',
$original_author,
$original_date);
$future->write($commit_message);
$future->resolvex();
list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD');
$new_cursor = trim($stdout);
if ($is_empty) {
// See T12876. If we're landing into the empty state, we just did a fake
// merge on top of an empty commit. We're now on a commit with all of the
// right details except that it has an extra empty commit as a parent.
// Create a new commit which is the same as the current HEAD, except that
// it doesn't have the extra parent.
$raw_commit = $api->readRawCommit($new_cursor);
if ($this->isSquashStrategy()) {
$raw_commit->setParents(array());
} else {
$raw_commit->setParents(array($source_commit));
}
$new_cursor = $api->writeRawCommit($raw_commit);
$api->execxLocal('checkout %s --', $new_cursor);
}
return $new_cursor;
}
protected function pushChange($into_commit) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
if ($this->getIsGitPerforce()) {
// TODO: Specifying "--onto" more than once is almost certainly an error
// in Perforce.
$log->writeStatus(
pht('SUBMITTING'),
pht(
'Submitting changes to "%s".',
$this->getOntoRemote()));
$config_argv = array();
// Skip the "git p4 submit" interactive editor workflow. We expect
// the commit message that "arc land" has built to be satisfactory.
$config_argv[] = '-c';
$config_argv[] = 'git-p4.skipSubmitEdit=true';
// Skip the "git p4 submit" confirmation prompt if the user does not edit
// the submit message.
$config_argv[] = '-c';
$config_argv[] = 'git-p4.skipSubmitEditCheck=true';
$flags_argv = array();
// Disable implicit "git p4 rebase" as part of submit. We're allowing
// the implicit "git p4 sync" to go through since this puts us in a
// state which is generally similar to the state after "git push", with
// updated remotes.
// We could do a manual "git p4 sync" with a more narrow "--branch"
// instead, but it's not clear that this is beneficial.
$flags_argv[] = '--disable-rebase';
// Detect moves and submit them to Perforce as move operations.
$flags_argv[] = '-M';
// If we run into a conflict, abort the operation. We expect users to
// fix conflicts and run "arc land" again.
$flags_argv[] = '--conflict=quit';
- $err = $api->execPassthru(
+ $err = $this->newPassthru(
'%LR p4 submit %LR --commit %R --',
$config_argv,
$flags_argv,
$into_commit);
-
if ($err) {
throw new ArcanistUsageException(
pht(
'Submit failed! Fix the error and run "arc land" again.'));
}
return;
}
$log->writeStatus(
pht('PUSHING'),
pht('Pushing changes to "%s".', $this->getOntoRemote()));
- $err = $api->execPassthru(
+ $err = $this->newPassthru(
'push -- %s %Ls',
$this->getOntoRemote(),
$this->newOntoRefArguments($into_commit));
if ($err) {
throw new ArcanistUsageException(
pht(
'Push failed! Fix the error and run "arc land" again.'));
}
// TODO
// if ($this->isGitSvn) {
// $err = phutil_passthru('git svn dcommit');
// $cmd = 'git svn dcommit';
}
protected function reconcileLocalState(
$into_commit,
ArcanistRepositoryLocalState $state) {
$api = $this->getRepositoryAPI();
$log = $this->getWorkflow()->getLogEngine();
// Try to put the user into the best final state we can. This is very
// complicated because users are incredibly creative and their local
// branches may, for example, have the same names as branches in the
// remote but no relationship to them.
// First, we're going to try to update these local branches:
//
// - the branch we started on originally; and
// - the local upstreams of the branch we started on originally; and
// - the local branch with the same name as the "into" ref; and
// - the local branch with the same name as the "onto" ref.
//
// These branches may not all exist and may not all be unique.
//
// To be updated, these branches must:
//
// - exist;
// - have not been deleted; and
// - be connected to the remote we pushed into.
$update_branches = array();
$local_ref = $state->getLocalRef();
if ($local_ref !== null) {
$update_branches[] = $local_ref;
}
$local_path = $state->getLocalPath();
if ($local_path) {
foreach ($local_path->getLocalBranches() as $local_branch) {
$update_branches[] = $local_branch;
}
}
if (!$this->getIntoEmpty() && !$this->getIntoLocal()) {
$update_branches[] = $this->getIntoRef();
}
foreach ($this->getOntoRefs() as $onto_ref) {
$update_branches[] = $onto_ref;
}
$update_branches = array_fuse($update_branches);
// Remove any branches we know we deleted.
foreach ($update_branches as $key => $update_branch) {
if (isset($this->deletedBranches[$update_branch])) {
unset($update_branches[$key]);
}
}
// Now, remove any branches which don't actually exist.
foreach ($update_branches as $key => $update_branch) {
list($err) = $api->execManualLocal(
'rev-parse --verify %s',
$update_branch);
if ($err) {
unset($update_branches[$key]);
}
}
$is_perforce = $this->getIsGitPerforce();
if ($is_perforce) {
// If we're in Perforce mode, we don't expect to have a meaningful
// path to the remote: the "p4" remote is not a real remote, and
// "git p4" commands do not configure branch upstreams to provide
// a path.
// Additionally, we've already set the remote to the right state with an
// implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a
// meaningful operation.
// We're going to skip everything here and just switch to the most
// desirable branch (if we can find one), then reset the state (if that
// operation is safe).
if (!$update_branches) {
$log->writeStatus(
pht('DETACHED HEAD'),
pht(
'Unable to find any local branches to update, staying on '.
'detached head.'));
$state->discardLocalState();
return;
}
$dst_branch = head($update_branches);
if (!$this->isAncestorOf($dst_branch, $into_commit)) {
$log->writeStatus(
pht('CHECKOUT'),
pht(
'Local branch "%s" has unpublished changes, checking it out '.
'but leaving them in place.',
$dst_branch));
$do_reset = false;
} else {
$log->writeStatus(
pht('UPDATE'),
pht(
'Switching to local branch "%s".',
$dst_branch));
$do_reset = true;
}
$api->execxLocal('checkout %s --', $dst_branch);
if ($do_reset) {
$api->execxLocal('reset --hard %s --', $into_commit);
}
$state->discardLocalState();
return;
}
$onto_refs = array_fuse($this->getOntoRefs());
$pull_branches = array();
foreach ($update_branches as $update_branch) {
$update_path = $api->getPathToUpstream($update_branch);
// Remove any branches which contain upstream cycles.
if ($update_path->getCycle()) {
$log->writeWarning(
pht('LOCAL CYCLE'),
pht(
'Local branch "%s" tracks an upstream but following it leads to '.
'a local cycle, ignoring branch.',
$update_branch));
continue;
}
// Remove any branches not connected to a remote.
if (!$update_path->isConnectedToRemote()) {
continue;
}
// Remove any branches connected to a remote other than the remote
// we actually pushed to.
$remote_name = $update_path->getRemoteRemoteName();
if ($remote_name !== $this->getOntoRemote()) {
continue;
}
// Remove any branches not connected to a branch we pushed to.
$remote_branch = $update_path->getRemoteBranchName();
if (!isset($onto_refs[$remote_branch])) {
continue;
}
// This is the most-desirable path between some local branch and
// an impacted upstream. Select it and continue.
$pull_branches = $update_path->getLocalBranches();
break;
}
// When we update these branches later, we want to start with the branch
// closest to the upstream and work our way down.
$pull_branches = array_reverse($pull_branches);
$pull_branches = array_fuse($pull_branches);
// If we started on a branch and it still exists but is not impacted
// by the changes we made to the remote (i.e., we aren't actually going
// to pull or update it if we continue), just switch back to it now. It's
// okay if this branch is completely unrelated to the changes we just
// landed.
if ($local_ref !== null) {
if (isset($update_branches[$local_ref])) {
if (!isset($pull_branches[$local_ref])) {
$log->writeStatus(
pht('RETURN'),
pht(
'Returning to original branch "%s" in original state.',
$local_ref));
$state->restoreLocalState();
return;
}
}
}
// Otherwise, if we don't have any path from the upstream to any local
// branch, we don't want to switch to some unrelated branch which happens
// to have the same name as a branch we interacted with. Just stay where
// we ended up.
$dst_branch = null;
if ($pull_branches) {
$dst_branch = null;
foreach ($pull_branches as $pull_branch) {
if (!$this->isAncestorOf($pull_branch, $into_commit)) {
$log->writeStatus(
pht('LOCAL CHANGES'),
pht(
'Local branch "%s" has unpublished changes, ending updates.',
$pull_branch));
break;
}
$log->writeStatus(
pht('UPDATE'),
pht(
'Updating local branch "%s"...',
$pull_branch));
$api->execxLocal(
'branch -f %s %s --',
$pull_branch,
$into_commit);
$dst_branch = $pull_branch;
}
}
if ($dst_branch) {
$log->writeStatus(
pht('CHECKOUT'),
pht(
'Checking out "%s".',
$dst_branch));
$api->execxLocal('checkout %s --', $dst_branch);
} else {
$log->writeStatus(
pht('DETACHED HEAD'),
pht(
'Unable to find any local branches to update, staying on '.
'detached head.'));
}
$state->discardLocalState();
}
private function isAncestorOf($branch, $commit) {
$api = $this->getRepositoryAPI();
list($stdout) = $api->execxLocal(
'merge-base %s %s',
$branch,
$commit);
$merge_base = trim($stdout);
list($stdout) = $api->execxLocal(
'rev-parse --verify %s',
$branch);
$branch_hash = trim($stdout);
return ($merge_base === $branch_hash);
}
private function getAuthorAndDate($commit) {
$api = $this->getRepositoryAPI();
list($info) = $api->execxLocal(
'log -n1 --format=%s %s --',
'%aD%n%an%n%ae',
$commit);
$info = trim($info);
list($date, $author, $email) = explode("\n", $info, 3);
return array(
"$author <{$email}>",
$date,
);
}
protected function didHoldChanges($into_commit) {
$log = $this->getLogEngine();
$local_state = $this->getLocalState();
if ($this->getIsGitPerforce()) {
$message = pht(
'Holding changes locally, they have not been submitted.');
$push_command = csprintf(
'git p4 submit -M --commit %s --',
$into_commit);
} else {
$message = pht(
'Holding changes locally, they have not been pushed.');
$push_command = csprintf(
'git push -- %s %Ls',
$this->getOntoRemote(),
$this->newOntoRefArguments($into_commit));
}
echo tsprintf(
"\n%!\n%s\n\n",
pht('HOLD CHANGES'),
$message);
echo tsprintf(
"%s\n\n%>\n",
pht('To push changes manually, run this command:'),
$push_command);
$restore_commands = $local_state->getRestoreCommandsForDisplay();
if ($restore_commands) {
echo tsprintf(
"%s\n\n",
pht(
'To go back to how things were before you ran "arc land", run '.
'these %s command(s):',
phutil_count($restore_commands)));
foreach ($restore_commands as $restore_command) {
echo tsprintf('%>', $restore_command);
}
echo tsprintf("\n");
}
echo tsprintf(
"%s\n",
pht(
'Local branches have not been changed, and are still in the '.
'same state as before.'));
}
protected function resolveSymbols(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
foreach ($symbols as $symbol) {
$raw_symbol = $symbol->getSymbol();
list($err, $stdout) = $api->execManualLocal(
'rev-parse --verify %s',
$raw_symbol);
if ($err) {
throw new PhutilArgumentUsageException(
pht(
'Branch "%s" does not exist in the local working copy.',
$raw_symbol));
}
$commit = trim($stdout);
$symbol->setCommit($commit);
}
}
protected function confirmOntoRefs(array $onto_refs) {
foreach ($onto_refs as $onto_ref) {
if (!strlen($onto_ref)) {
throw new PhutilArgumentUsageException(
pht(
'Selected "onto" ref "%s" is invalid: the empty string is not '.
'a valid ref.',
$onto_ref));
}
}
}
protected function selectOntoRefs(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$log = $this->getLogEngine();
$onto = $this->getOntoArguments();
if ($onto) {
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected with the "--onto" flag: %s.',
implode(', ', $onto)));
return $onto;
}
$onto = $this->getOntoFromConfiguration();
if ($onto) {
$onto_key = $this->getOntoConfigurationKey();
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected by reading "%s" configuration: %s.',
$onto_key,
implode(', ', $onto)));
return $onto;
}
$api = $this->getRepositoryAPI();
$remote_onto = array();
foreach ($symbols as $symbol) {
$raw_symbol = $symbol->getSymbol();
$path = $api->getPathToUpstream($raw_symbol);
if (!$path->getLength()) {
continue;
}
$cycle = $path->getCycle();
if ($cycle) {
$log->writeWarning(
pht('LOCAL CYCLE'),
pht(
'Local branch "%s" tracks an upstream, but following it leads '.
'to a local cycle; ignoring branch upstream.',
$raw_symbol));
$log->writeWarning(
pht('LOCAL CYCLE'),
implode(' -> ', $cycle));
continue;
}
if (!$path->isConnectedToRemote()) {
$log->writeWarning(
pht('NO PATH TO REMOTE'),
pht(
'Local branch "%s" tracks an upstream, but there is no path '.
'to a remote; ignoring branch upstream.',
$raw_symbol));
continue;
}
$onto = $path->getRemoteBranchName();
$remote_onto[$onto] = $onto;
}
if (count($remote_onto) > 1) {
throw new PhutilArgumentUsageException(
pht(
'The branches you are landing are connected to multiple different '.
'remote branches via Git branch upstreams. Use "--onto" to select '.
'the refs you want to push to.'));
}
if ($remote_onto) {
$remote_onto = array_values($remote_onto);
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Landing onto target "%s", selected by following tracking branches '.
'upstream to the closest remote branch.',
head($remote_onto)));
return $remote_onto;
}
$default_onto = 'master';
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Landing onto target "%s", the default target under Git.',
$default_onto));
return array($default_onto);
}
protected function selectOntoRemote(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$remote = $this->newOntoRemote($symbols);
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$is_pushable = $api->isPushableRemote($remote);
$is_perforce = $api->isPerforceRemote($remote);
if (!$is_pushable && !$is_perforce) {
throw new PhutilArgumentUsageException(
pht(
'No pushable remote "%s" exists. Use the "--onto-remote" flag to '.
'choose a valid, pushable remote to land changes onto.',
$remote));
}
if ($is_perforce) {
$this->setIsGitPerforce(true);
$log->writeWarning(
pht('P4 MODE'),
pht(
'Operating in Git/Perforce mode after selecting a Perforce '.
'remote.'));
if (!$this->isSquashStrategy()) {
throw new PhutilArgumentUsageException(
pht(
'Perforce mode does not support the "merge" land strategy. '.
'Use the "squash" land strategy when landing to a Perforce '.
'remote (you can use "--squash" to select this strategy).'));
}
}
return $remote;
}
private function newOntoRemote(array $onto_symbols) {
assert_instances_of($onto_symbols, 'ArcanistLandSymbol');
$log = $this->getLogEngine();
$remote = $this->getOntoRemoteArgument();
if ($remote !== null) {
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected with the "--onto-remote" flag.',
$remote));
return $remote;
}
$remote = $this->getOntoRemoteFromConfiguration();
if ($remote !== null) {
$remote_key = $this->getOntoRemoteConfigurationKey();
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected by reading "%s" configuration.',
$remote,
$remote_key));
return $remote;
}
$api = $this->getRepositoryAPI();
$upstream_remotes = array();
foreach ($onto_symbols as $onto_symbol) {
$path = $api->getPathToUpstream($onto_symbol->getSymbol());
$remote = $path->getRemoteRemoteName();
if ($remote !== null) {
$upstream_remotes[$remote][] = $onto_symbol;
}
}
if (count($upstream_remotes) > 1) {
throw new PhutilArgumentUsageException(
pht(
'The "onto" refs you have selected are connected to multiple '.
'different remotes via Git branch upstreams. Use "--onto-remote" '.
'to select a single remote.'));
}
if ($upstream_remotes) {
$upstream_remote = head_key($upstream_remotes);
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected by following tracking branches '.
'upstream to the closest remote.',
$remote));
return $upstream_remote;
}
$perforce_remote = 'p4';
if ($api->isPerforceRemote($remote)) {
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Peforce remote "%s" was selected because the existence of '.
'this remote implies this working copy was synchronized '.
'from a Perforce repository.',
$remote));
return $remote;
}
$default_remote = 'origin';
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Landing onto remote "%s", the default remote under Git.',
$default_remote));
return $default_remote;
}
protected function selectIntoRemote() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
if ($this->getIntoLocalArgument()) {
$this->setIntoLocal(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into local state, selected with the "--into-local" '.
'flag.'));
return;
}
$into = $this->getIntoRemoteArgument();
if ($into !== null) {
// TODO: We could allow users to pass a URI argument instead, but
// this also requires some updates to the fetch logic elsewhere.
if (!$api->isFetchableRemote($into)) {
throw new PhutilArgumentUsageException(
pht(
'Remote "%s", specified with "--into", is not a valid fetchable '.
'remote.',
$into));
}
$this->setIntoRemote($into);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s", selected with the "--into" flag.',
$into));
return;
}
$onto = $this->getOntoRemote();
$this->setIntoRemote($onto);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s" by default, because this is the remote '.
'the change is landing onto.',
$onto));
}
protected function selectIntoRef() {
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
$into = $this->getIntoArgument();
if ($into !== null) {
$this->setIntoRef($into);
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s", selected with the "--into" flag.',
$into));
return;
}
$ontos = $this->getOntoRefs();
$onto = head($ontos);
$this->setIntoRef($onto);
if (count($ontos) > 1) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the first '.
'"onto" target.',
$onto));
} else {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the "onto" '.
'target.',
$onto));
}
}
protected function selectIntoCommit() {
// Make sure that our "into" target is valid.
$log = $this->getLogEngine();
if ($this->getIntoEmpty()) {
// If we're running under "--into-empty", we don't have to do anything.
$log->writeStatus(
pht('INTO COMMIT'),
pht('Preparing merge into the empty state.'));
return null;
}
if ($this->getIntoLocal()) {
// If we're running under "--into-local", just make sure that the
// target identifies some actual commit.
$api = $this->getRepositoryAPI();
$local_ref = $this->getIntoRef();
list($err, $stdout) = $api->execManualLocal(
'rev-parse --verify %s',
$local_ref);
if ($err) {
throw new PhutilArgumentUsageException(
pht(
'Local ref "%s" does not exist.',
$local_ref));
}
$into_commit = trim($stdout);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into local target "%s", at commit "%s".',
$local_ref,
$this->getDisplayHash($into_commit)));
return $into_commit;
}
$target = id(new ArcanistLandTarget())
->setRemote($this->getIntoRemote())
->setRef($this->getIntoRef());
$commit = $this->fetchTarget($target);
if ($commit !== null) {
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into "%s" from remote "%s", at commit "%s".',
$target->getRef(),
$target->getRemote(),
$this->getDisplayHash($commit)));
return $commit;
}
// If we have no valid target and the user passed "--into" explicitly,
// treat this as an error. For example, "arc land --into Q --onto Q",
// where "Q" does not exist, is an error.
if ($this->getIntoArgument()) {
throw new PhutilArgumentUsageException(
pht(
'Ref "%s" does not exist in remote "%s".',
$target->getRef(),
$target->getRemote()));
}
// Otherwise, treat this as implying "--into-empty". For example,
// "arc land --onto Q", where "Q" does not exist, is equivalent to
// "arc land --into-empty --onto Q".
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into the empty state to create target "%s" '.
'in remote "%s".',
$target->getRef(),
$target->getRemote()));
return null;
}
private function getLandTargetLocalCommit(ArcanistLandTarget $target) {
$commit = $this->resolveLandTargetLocalCommit($target);
if ($commit === null) {
throw new Exception(
pht(
'No ref "%s" exists in remote "%s".',
$target->getRef(),
$target->getRemote()));
}
return $commit;
}
private function getLandTargetLocalExists(ArcanistLandTarget $target) {
$commit = $this->resolveLandTargetLocalCommit($target);
return ($commit !== null);
}
private function resolveLandTargetLocalCommit(ArcanistLandTarget $target) {
$target_key = $target->getLandTargetKey();
if (!array_key_exists($target_key, $this->landTargetCommitMap)) {
$full_ref = sprintf(
'refs/remotes/%s/%s',
$target->getRemote(),
$target->getRef());
$api = $this->getRepositoryAPI();
list($err, $stdout) = $api->execManualLocal(
'rev-parse --verify %s',
$full_ref);
if ($err) {
$result = null;
} else {
$result = trim($stdout);
}
$this->landTargetCommitMap[$target_key] = $result;
}
return $this->landTargetCommitMap[$target_key];
}
private function fetchLandTarget(
ArcanistLandTarget $target,
$ignore_failure = false) {
$api = $this->getRepositoryAPI();
- // TODO: Format this fetch nicely as a workflow command.
-
- $err = $api->execPassthru(
+ $err = $this->newPassthru(
'fetch --no-tags --quiet -- %s %s',
$target->getRemote(),
$target->getRef());
if ($err && !$ignore_failure) {
throw new ArcanistUsageException(
pht(
'Fetch of "%s" from remote "%s" failed! Fix the error and '.
'run "arc land" again.',
$target->getRef(),
$target->getRemote()));
}
// TODO: If the remote is a bare URI, we could read ".git/FETCH_HEAD"
// here and write the commit into the map. For now, settle for clearing
// the cache.
// We could also fetch into some named "refs/arc-land-temporary" named
// ref, then read that.
if (!$err) {
$target_key = $target->getLandTargetKey();
unset($this->landTargetCommitMap[$target_key]);
}
}
protected function selectCommits($into_commit, array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
$commit_map = array();
foreach ($symbols as $symbol) {
$symbol_commit = $symbol->getCommit();
$format = '%H%x00%P%x00%s%x00';
if ($into_commit === null) {
list($commits) = $api->execxLocal(
'log %s --format=%s',
$symbol_commit,
$format);
} else {
list($commits) = $api->execxLocal(
'log %s --not %s --format=%s',
$symbol_commit,
$into_commit,
$format);
}
$commits = phutil_split_lines($commits, false);
$is_first = true;
foreach ($commits as $line) {
if (!strlen($line)) {
continue;
}
$parts = explode("\0", $line, 4);
if (count($parts) < 3) {
throw new Exception(
pht(
'Unexpected output from "git log ...": %s',
$line));
}
$hash = $parts[0];
if (!isset($commit_map[$hash])) {
$parents = $parts[1];
$parents = trim($parents);
if (strlen($parents)) {
$parents = explode(' ', $parents);
} else {
$parents = array();
}
$summary = $parts[2];
$commit_map[$hash] = id(new ArcanistLandCommit())
->setHash($hash)
->setParents($parents)
->setSummary($summary);
}
$commit = $commit_map[$hash];
if ($is_first) {
$commit->addDirectSymbol($symbol);
$is_first = false;
}
$commit->addIndirectSymbol($symbol);
}
}
return $this->confirmCommits($into_commit, $symbols, $commit_map);
}
protected function getDefaultSymbols() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$branch = $api->getBranchName();
if ($branch !== null) {
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the current branch, "%s".',
$branch));
return array($branch);
}
$commit = $api->getCurrentCommitRef();
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the current HEAD, "%s".',
$commit->getCommitHash()));
return array($commit->getCommitHash());
}
private function newOntoRefArguments($into_commit) {
$refspecs = array();
foreach ($this->getOntoRefs() as $onto_ref) {
$refspecs[] = sprintf(
'%s:%s',
$this->getDisplayHash($into_commit),
$onto_ref);
}
return $refspecs;
}
}
diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php
index 2c920374..bb3d5f95 100644
--- a/src/land/engine/ArcanistLandEngine.php
+++ b/src/land/engine/ArcanistLandEngine.php
@@ -1,1551 +1,1567 @@
<?php
abstract class ArcanistLandEngine extends Phobject {
private $workflow;
private $viewer;
private $logEngine;
private $repositoryAPI;
private $sourceRefs;
private $shouldHold;
private $shouldKeep;
private $shouldPreview;
private $isIncremental;
private $ontoRemoteArgument;
private $ontoArguments;
private $intoEmptyArgument;
private $intoLocalArgument;
private $intoRemoteArgument;
private $intoArgument;
private $strategyArgument;
private $strategy;
private $revisionSymbol;
private $revisionSymbolRef;
private $ontoRemote;
private $ontoRefs;
private $intoRemote;
private $intoRef;
private $intoEmpty;
private $intoLocal;
private $localState;
final public function setViewer($viewer) {
$this->viewer = $viewer;
return $this;
}
final public function getViewer() {
return $this->viewer;
}
final public function setOntoRemote($onto_remote) {
$this->ontoRemote = $onto_remote;
return $this;
}
final public function getOntoRemote() {
return $this->ontoRemote;
}
final public function setOntoRefs($onto_refs) {
$this->ontoRefs = $onto_refs;
return $this;
}
final public function getOntoRefs() {
return $this->ontoRefs;
}
final public function setIntoRemote($into_remote) {
$this->intoRemote = $into_remote;
return $this;
}
final public function getIntoRemote() {
return $this->intoRemote;
}
final public function setIntoRef($into_ref) {
$this->intoRef = $into_ref;
return $this;
}
final public function getIntoRef() {
return $this->intoRef;
}
final public function setIntoEmpty($into_empty) {
$this->intoEmpty = $into_empty;
return $this;
}
final public function getIntoEmpty() {
return $this->intoEmpty;
}
final public function setIntoLocal($into_local) {
$this->intoLocal = $into_local;
return $this;
}
final public function getIntoLocal() {
return $this->intoLocal;
}
final public function setWorkflow($workflow) {
$this->workflow = $workflow;
return $this;
}
final public function getWorkflow() {
return $this->workflow;
}
final public function setRepositoryAPI(
ArcanistRepositoryAPI $repository_api) {
$this->repositoryAPI = $repository_api;
return $this;
}
final public function getRepositoryAPI() {
return $this->repositoryAPI;
}
final public function setLogEngine(ArcanistLogEngine $log_engine) {
$this->logEngine = $log_engine;
return $this;
}
final public function getLogEngine() {
return $this->logEngine;
}
final public function setShouldHold($should_hold) {
$this->shouldHold = $should_hold;
return $this;
}
final public function getShouldHold() {
return $this->shouldHold;
}
final public function setShouldKeep($should_keep) {
$this->shouldKeep = $should_keep;
return $this;
}
final public function getShouldKeep() {
return $this->shouldKeep;
}
final public function setStrategyArgument($strategy_argument) {
$this->strategyArgument = $strategy_argument;
return $this;
}
final public function getStrategyArgument() {
return $this->strategyArgument;
}
final public function setStrategy($strategy) {
$this->strategy = $strategy;
return $this;
}
final public function getStrategy() {
return $this->strategy;
}
final public function setRevisionSymbol($revision_symbol) {
$this->revisionSymbol = $revision_symbol;
return $this;
}
final public function getRevisionSymbol() {
return $this->revisionSymbol;
}
final public function setRevisionSymbolRef(
ArcanistRevisionSymbolRef $revision_ref) {
$this->revisionSymbolRef = $revision_ref;
return $this;
}
final public function getRevisionSymbolRef() {
return $this->revisionSymbolRef;
}
final public function setShouldPreview($should_preview) {
$this->shouldPreview = $should_preview;
return $this;
}
final public function getShouldPreview() {
return $this->shouldPreview;
}
final public function setSourceRefs(array $source_refs) {
$this->sourceRefs = $source_refs;
return $this;
}
final public function getSourceRefs() {
return $this->sourceRefs;
}
final public function setOntoRemoteArgument($remote_argument) {
$this->ontoRemoteArgument = $remote_argument;
return $this;
}
final public function getOntoRemoteArgument() {
return $this->ontoRemoteArgument;
}
final public function setOntoArguments(array $onto_arguments) {
$this->ontoArguments = $onto_arguments;
return $this;
}
final public function getOntoArguments() {
return $this->ontoArguments;
}
final public function setIsIncremental($is_incremental) {
$this->isIncremental = $is_incremental;
return $this;
}
final public function getIsIncremental() {
return $this->isIncremental;
}
final public function setIntoEmptyArgument($into_empty_argument) {
$this->intoEmptyArgument = $into_empty_argument;
return $this;
}
final public function getIntoEmptyArgument() {
return $this->intoEmptyArgument;
}
final public function setIntoLocalArgument($into_local_argument) {
$this->intoLocalArgument = $into_local_argument;
return $this;
}
final public function getIntoLocalArgument() {
return $this->intoLocalArgument;
}
final public function setIntoRemoteArgument($into_remote_argument) {
$this->intoRemoteArgument = $into_remote_argument;
return $this;
}
final public function getIntoRemoteArgument() {
return $this->intoRemoteArgument;
}
final public function setIntoArgument($into_argument) {
$this->intoArgument = $into_argument;
return $this;
}
final public function getIntoArgument() {
return $this->intoArgument;
}
private function setLocalState(ArcanistRepositoryLocalState $local_state) {
$this->localState = $local_state;
return $this;
}
final protected function getLocalState() {
return $this->localState;
}
final protected function getOntoFromConfiguration() {
$config_key = $this->getOntoConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getOntoConfigurationKey() {
return 'arc.land.onto';
}
final protected function getOntoRemoteFromConfiguration() {
$config_key = $this->getOntoRemoteConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getOntoRemoteConfigurationKey() {
return 'arc.land.onto-remote';
}
final protected function confirmRevisions(array $sets) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
$revision_refs = mpull($sets, 'getRevisionRef');
$viewer = $this->getViewer();
$viewer_phid = $viewer->getPHID();
$unauthored = array();
foreach ($revision_refs as $revision_ref) {
$author_phid = $revision_ref->getAuthorPHID();
if ($author_phid !== $viewer_phid) {
$unauthored[] = $revision_ref;
}
}
if ($unauthored) {
$this->getWorkflow()->loadHardpoints(
$unauthored,
array(
ArcanistRevisionRef::HARDPOINT_AUTHORREF,
));
echo tsprintf(
"\n%!\n%W\n\n",
pht('NOT REVISION AUTHOR'),
pht(
'You are landing revisions which you ("%s") are not the author of:',
$viewer->getMonogram()));
foreach ($unauthored as $revision_ref) {
$display_ref = $revision_ref->newDisplayRef();
$author_ref = $revision_ref->getAuthorRef();
if ($author_ref) {
$display_ref->appendLine(
pht(
'Author: %s',
$author_ref->getMonogram()));
}
echo tsprintf('%s', $display_ref);
}
echo tsprintf(
"\n%?\n",
pht(
'Use "Commandeer" in the web interface to become the author of '.
'a revision.'));
$query = pht('Land revisions you are not the author of?');
$this->getWorkflow()
->getPrompt('arc.land.unauthored')
->setQuery($query)
->execute();
}
$planned = array();
$closed = array();
$not_accepted = array();
foreach ($revision_refs as $revision_ref) {
if ($revision_ref->isStatusChangesPlanned()) {
$planned[] = $revision_ref;
} else if ($revision_ref->isStatusClosed()) {
$closed[] = $revision_ref;
} else if (!$revision_ref->isStatusAccepted()) {
$not_accepted[] = $revision_ref;
}
}
// See T10233. Previously, this prompt was bundled with the generic "not
// accepted" prompt, but users found it confusing and interpreted the
// prompt as a bug.
if ($planned) {
$example_ref = head($planned);
echo tsprintf(
"\n%!\n%W\n\n%W\n\n%W\n\n",
pht('%s REVISION(S) HAVE CHANGES PLANNED', phutil_count($planned)),
pht(
'You are landing %s revision(s) which are currently in the state '.
'"%s", indicating that you expect to revise them before moving '.
'forward.',
phutil_count($planned),
$example_ref->getStatusDisplayName()),
pht(
'Normally, you should update these %s revision(s), submit them '.
'for review, and wait for reviewers to accept them before '.
'you continue. To resubmit a revision for review, either: '.
'update the revision with revised changes; or use '.
'"Request Review" from the web interface.',
phutil_count($planned)),
pht(
'These %s revision(s) have changes planned:',
phutil_count($planned)));
foreach ($planned as $revision_ref) {
echo tsprintf('%s', $revision_ref->newDisplayRef());
}
$query = pht(
'Land %s revision(s) with changes planned?',
phutil_count($planned));
$this->getWorkflow()
->getPrompt('arc.land.changes-planned')
->setQuery($query)
->execute();
}
// See PHI1727. Previously, this prompt was bundled with the generic
// "not accepted" prompt, but at least one user found it confusing.
if ($closed) {
$example_ref = head($closed);
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s REVISION(S) ARE ALREADY CLOSED', phutil_count($closed)),
pht(
'You are landing %s revision(s) which are already in the state '.
'"%s", indicating that they have previously landed:',
phutil_count($closed),
$example_ref->getStatusDisplayName()));
foreach ($closed as $revision_ref) {
echo tsprintf('%s', $revision_ref->newDisplayRef());
}
$query = pht(
'Land %s revision(s) that are already closed?',
phutil_count($closed));
$this->getWorkflow()
->getPrompt('arc.land.closed')
->setQuery($query)
->execute();
}
if ($not_accepted) {
$example_ref = head($not_accepted);
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s REVISION(S) ARE NOT ACCEPTED', phutil_count($not_accepted)),
pht(
'You are landing %s revision(s) which are not in state "Accepted", '.
'indicating that they have not been accepted by reviewers. '.
'Normally, you should land changes only once they have been '.
'accepted. These revisions are in the wrong state:',
phutil_count($not_accepted)));
foreach ($not_accepted as $revision_ref) {
$display_ref = $revision_ref->newDisplayRef();
$display_ref->appendLine(
pht(
'Status: %s',
$revision_ref->getStatusDisplayName()));
echo tsprintf('%s', $display_ref);
}
$query = pht(
'Land %s revision(s) in the wrong state?',
phutil_count($not_accepted));
$this->getWorkflow()
->getPrompt('arc.land.not-accepted')
->setQuery($query)
->execute();
}
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_PARENTREVISIONREFS,
));
$open_parents = array();
foreach ($revision_refs as $revision_phid => $revision_ref) {
$parent_refs = $revision_ref->getParentRevisionRefs();
foreach ($parent_refs as $parent_ref) {
$parent_phid = $parent_ref->getPHID();
// If we're landing a parent revision in this operation, we don't need
// to complain that it hasn't been closed yet.
if (isset($revision_refs[$parent_phid])) {
continue;
}
if ($parent_ref->isClosed()) {
continue;
}
if (!isset($open_parents[$parent_phid])) {
$open_parents[$parent_phid] = array(
'ref' => $parent_ref,
'children' => array(),
);
}
$open_parents[$parent_phid]['children'][] = $revision_ref;
}
}
if ($open_parents) {
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s OPEN PARENT REVISION(S) ', phutil_count($open_parents)),
pht(
'The changes you are landing depend on %s open parent revision(s). '.
'Usually, you should land parent revisions before landing the '.
'changes which depend on them. These parent revisions are open:',
phutil_count($open_parents)));
foreach ($open_parents as $parent_phid => $spec) {
$parent_ref = $spec['ref'];
$display_ref = $parent_ref->newDisplayRef();
$display_ref->appendLine(
pht(
'Status: %s',
$parent_ref->getStatusDisplayName()));
foreach ($spec['children'] as $child_ref) {
$display_ref->appendLine(
pht(
'Parent of: %s %s',
$child_ref->getMonogram(),
$child_ref->getName()));
}
echo tsprintf('%s', $display_ref);
}
$query = pht(
'Land changes that depend on %s open revision(s)?',
phutil_count($open_parents));
$this->getWorkflow()
->getPrompt('arc.land.open-parents')
->setQuery($query)
->execute();
}
$this->confirmBuilds($revision_refs);
// This is a reasonable place to bulk-load the commit messages, which
// we'll need soon.
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE,
));
}
private function confirmBuilds(array $revision_refs) {
assert_instances_of($revision_refs, 'ArcanistRevisionRef');
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_BUILDABLEREF,
));
$buildable_refs = array();
foreach ($revision_refs as $revision_ref) {
$ref = $revision_ref->getBuildableRef();
if ($ref) {
$buildable_refs[] = $ref;
}
}
$this->getWorkflow()->loadHardpoints(
$buildable_refs,
array(
ArcanistBuildableRef::HARDPOINT_BUILDREFS,
));
$build_refs = array();
foreach ($buildable_refs as $buildable_ref) {
foreach ($buildable_ref->getBuildRefs() as $build_ref) {
$build_refs[] = $build_ref;
}
}
$this->getWorkflow()->loadHardpoints(
$build_refs,
array(
ArcanistBuildRef::HARDPOINT_BUILDPLANREF,
));
$problem_builds = array();
$has_failures = false;
$has_ongoing = false;
$build_refs = msortv($build_refs, 'getStatusSortVector');
foreach ($build_refs as $build_ref) {
$plan_ref = $build_ref->getBuildPlanRef();
if (!$plan_ref) {
continue;
}
$plan_behavior = $plan_ref->getBehavior('arc-land', 'always');
$if_building = ($plan_behavior == 'building');
$if_complete = ($plan_behavior == 'complete');
$if_never = ($plan_behavior == 'never');
// If the build plan "Never" warns when landing, skip it.
if ($if_never) {
continue;
}
// If the build plan warns when landing "If Complete" but the build is
// not complete, skip it.
if ($if_complete && !$build_ref->isComplete()) {
continue;
}
// If the build plan warns when landing "If Building" but the build is
// complete, skip it.
if ($if_building && $build_ref->isComplete()) {
continue;
}
// Ignore passing builds.
if ($build_ref->isPassed()) {
continue;
}
if ($build_ref->isComplete()) {
$has_failures = true;
} else {
$has_ongoing = true;
}
$problem_builds[] = $build_ref;
}
if (!$problem_builds) {
return;
}
$build_map = array();
$failure_map = array();
$buildable_map = mpull($buildable_refs, null, 'getPHID');
$revision_map = mpull($revision_refs, null, 'getDiffPHID');
foreach ($problem_builds as $build_ref) {
$buildable_phid = $build_ref->getBuildablePHID();
$buildable_ref = $buildable_map[$buildable_phid];
$object_phid = $buildable_ref->getObjectPHID();
$revision_ref = $revision_map[$object_phid];
$revision_phid = $revision_ref->getPHID();
if (!isset($build_map[$revision_phid])) {
$build_map[$revision_phid] = array(
'revisionRef' => $revision_phid,
'buildRefs' => array(),
);
}
$build_map[$revision_phid]['buildRefs'][] = $build_ref;
}
$log = $this->getLogEngine();
if ($has_failures) {
if ($has_ongoing) {
$message = pht(
'%s revision(s) have build failures or ongoing builds:',
phutil_count($build_map));
$query = pht(
'Land %s revision(s) anyway, despite ongoing and failed builds?',
phutil_count($build_map));
} else {
$message = pht(
'%s revision(s) have build failures:',
phutil_count($build_map));
$query = pht(
'Land %s revision(s) anyway, despite failed builds?',
phutil_count($build_map));
}
echo tsprintf(
"%!\n%s\n\n",
pht('BUILD FAILURES'),
$message);
$prompt_key = 'arc.land.failed-builds';
} else if ($has_ongoing) {
echo tsprintf(
"%!\n%s\n\n",
pht('ONGOING BUILDS'),
pht(
'%s revision(s) have ongoing builds:',
phutil_count($build_map)));
$query = pht(
'Land %s revision(s) anyway, despite ongoing builds?',
phutil_count($build_map));
$prompt_key = 'arc.land.ongoing-builds';
}
echo tsprintf("\n");
foreach ($build_map as $build_item) {
$revision_ref = $build_item['revisionRef'];
echo tsprintf('%s', $revision_ref->newDisplayRef());
foreach ($build_item['buildRefs'] as $build_ref) {
echo tsprintf('%s', $build_ref->newDisplayRef());
}
echo tsprintf("\n");
}
echo tsprintf(
"\n%s\n\n",
pht('You can review build details here:'));
// TODO: Only show buildables with problem builds.
foreach ($buildable_refs as $buildable) {
$display_ref = $buildable->newDisplayRef();
// TODO: Include URI here.
echo tsprintf('%s', $display_ref);
}
$this->getWorkflow()
->getPrompt($prompt_key)
->setQuery($query)
->execute();
}
final protected function confirmImplicitCommits(array $sets, array $symbols) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
assert_instances_of($symbols, 'ArcanistLandSymbol');
$implicit = array();
foreach ($sets as $set) {
if ($set->hasImplicitCommits()) {
$implicit[] = $set;
}
}
if (!$implicit) {
return;
}
echo tsprintf(
"\n%!\n%W\n",
pht('IMPLICIT COMMITS'),
pht(
'Some commits reachable from the specified sources (%s) are not '.
'associated with revisions, and may not have been reviewed. These '.
'commits will be landed as though they belong to the nearest '.
'ancestor revision:',
$this->getDisplaySymbols($symbols)));
foreach ($implicit as $set) {
$this->printCommitSet($set);
}
$query = pht(
'Continue with this mapping between commits and revisions?');
$this->getWorkflow()
->getPrompt('arc.land.implicit')
->setQuery($query)
->execute();
}
final protected function getDisplaySymbols(array $symbols) {
$display = array();
foreach ($symbols as $symbol) {
$display[] = sprintf('"%s"', addcslashes($symbol->getSymbol(), '\\"'));
}
return implode(', ', $display);
}
final protected function printCommitSet(ArcanistLandCommitSet $set) {
$revision_ref = $set->getRevisionRef();
echo tsprintf(
"\n%s",
$revision_ref->newDisplayRef());
foreach ($set->getCommits() as $commit) {
$is_implicit = $commit->getIsImplicitCommit();
$display_hash = $this->getDisplayHash($commit->getHash());
$display_summary = $commit->getDisplaySummary();
if ($is_implicit) {
echo tsprintf(
" <bg:yellow> %s </bg> %s\n",
$display_hash,
$display_summary);
} else {
echo tsprintf(
" %s %s\n",
$display_hash,
$display_summary);
}
}
}
final protected function loadRevisionRefs(array $commit_map) {
assert_instances_of($commit_map, 'ArcanistLandCommit');
$workflow = $this->getWorkflow();
$state_refs = array();
foreach ($commit_map as $commit) {
$hash = $commit->getHash();
$commit_ref = id(new ArcanistCommitRef())
->setCommitHash($hash);
$state_ref = id(new ArcanistWorkingCopyStateRef())
->setCommitRef($commit_ref);
$state_refs[$hash] = $state_ref;
}
$force_symbol_ref = $this->getRevisionSymbolRef();
$force_ref = null;
if ($force_symbol_ref) {
$workflow->loadHardpoints(
$force_symbol_ref,
ArcanistSymbolRef::HARDPOINT_OBJECT);
$force_ref = $force_symbol_ref->getObject();
if (!$force_ref) {
throw new PhutilArgumentUsageException(
pht(
'Symbol "%s" does not identify a valid revision.',
$force_symbol_ref->getSymbol()));
}
}
$workflow->loadHardpoints(
$state_refs,
ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS);
foreach ($commit_map as $commit) {
$hash = $commit->getHash();
$state_ref = $state_refs[$hash];
$revision_refs = $state_ref->getRevisionRefs();
$commit->setRelatedRevisionRefs($revision_refs);
}
// For commits which have exactly one related revision, select it now.
foreach ($commit_map as $commit) {
$revision_refs = $commit->getRelatedRevisionRefs();
if (count($revision_refs) !== 1) {
continue;
}
$revision_ref = head($revision_refs);
$commit->setExplicitRevisionRef($revision_ref);
}
// If we have a "--revision", select that revision for any commits with
// no known related revisions.
// Also select that revision for any commits which have several possible
// revisions including that revision. This is relatively safe and
// reasonable and doesn't require a warning.
if ($force_ref) {
$force_phid = $force_ref->getPHID();
foreach ($commit_map as $commit) {
if ($commit->getExplicitRevisionRef()) {
continue;
}
$revision_refs = $commit->getRelatedRevisionRefs();
if ($revision_refs) {
$revision_refs = mpull($revision_refs, null, 'getPHID');
if (!isset($revision_refs[$force_phid])) {
continue;
}
}
$commit->setExplicitRevisionRef($force_ref);
}
}
// If we have a "--revision", identify any commits which it is not yet
// selected for. These are commits which are not associated with the
// identified revision but are associated with one or more other revisions.
if ($force_ref) {
$force_phid = $force_ref->getPHID();
$confirm_force = array();
foreach ($commit_map as $key => $commit) {
$revision_ref = $commit->getExplicitRevisionRef();
if (!$revision_ref) {
continue;
}
if ($revision_ref->getPHID() === $force_phid) {
continue;
}
$confirm_force[] = $commit;
}
if ($confirm_force) {
// TODO: Make this more clear.
// TODO: Show all the commits.
throw new PhutilArgumentUsageException(
pht(
'TODO: You are forcing a revision, but commits are associated '.
'with some other revision. Are you REALLY sure you want to land '.
'ALL these commits wiht a different unrelated revision???'));
}
foreach ($confirm_force as $commit) {
$commit->setExplicitRevisionRef($force_ref);
}
}
// Finally, raise an error if we're left with ambiguous revisions. This
// happens when we have no "--revision" and some commits in the range
// that are associated with more than one revision.
$ambiguous = array();
foreach ($commit_map as $commit) {
if ($commit->getExplicitRevisionRef()) {
continue;
}
if (!$commit->getRelatedRevisionRefs()) {
continue;
}
$ambiguous[] = $commit;
}
if ($ambiguous) {
foreach ($ambiguous as $commit) {
$symbols = $commit->getIndirectSymbols();
$raw_symbols = mpull($symbols, 'getSymbol');
$symbol_list = implode(', ', $raw_symbols);
$display_hash = $this->getDisplayHash($hash);
$revision_refs = $commit->getRelatedRevisionRefs();
// TODO: Include "use 'arc look --type commit abc' to figure out why"
// once that works?
// TODO: We could print all the ambiguous commits.
// TODO: Suggest "--pick" as a remedy once it exists?
echo tsprintf(
"\n%!\n%W\n\n",
pht('AMBIGUOUS REVISION'),
pht(
'The revision associated with commit "%s" (an ancestor of: %s) '.
'is ambiguous. These %s revision(s) are associated with the '.
'commit:',
$display_hash,
implode(', ', $raw_symbols),
phutil_count($revision_refs)));
foreach ($revision_refs as $revision_ref) {
echo tsprintf(
'%s',
$revision_ref->newDisplayRef());
}
echo tsprintf("\n");
throw new PhutilArgumentUsageException(
pht(
'Revision for commit "%s" is ambiguous. Use "--revision" to force '.
'selection of a particular revision.',
$display_hash));
}
}
// NOTE: We may exit this method with commits that are still unassociated.
// These will be handled later by the "implicit commits" mechanism.
}
final protected function getDisplayHash($hash) {
// TODO: This should be on the API object.
return substr($hash, 0, 12);
}
final protected function confirmCommits(
$into_commit,
array $symbols,
array $commit_map) {
$commit_count = count($commit_map);
if (!$commit_count) {
$message = pht(
'There are no commits reachable from the specified sources (%s) '.
'which are not already present in the state you are merging '.
'into ("%s"), so nothing can land.',
$this->getDisplaySymbols($symbols),
$this->getDisplayHash($into_commit));
echo tsprintf(
"\n%!\n%W\n\n",
pht('NOTHING TO LAND'),
$message);
throw new PhutilArgumentUsageException(
pht('There are no commits to land.'));
}
// Reverse the commit list so that it's oldest-first, since this is the
// order we'll use to show revisions.
$commit_map = array_reverse($commit_map, true);
$warn_limit = $this->getWorkflow()->getLargeWorkingSetLimit();
$show_limit = 5;
if ($commit_count > $warn_limit) {
if ($into_commit === null) {
$message = pht(
'There are %s commit(s) reachable from the specified sources (%s). '.
'You are landing into the empty state, so all of these commits '.
'will land:',
new PhutilNumber($commit_count),
$this->getDisplaySymbols($symbols));
} else {
$message = pht(
'There are %s commit(s) reachable from the specified sources (%s) '.
'that are not present in the repository state you are merging '.
'into ("%s"). All of these commits will land:',
new PhutilNumber($commit_count),
$this->getDisplaySymbols($symbols),
$this->getDisplayHash($into_commit));
}
echo tsprintf(
"\n%!\n%W\n",
pht('LARGE WORKING SET'),
$message);
$display_commits = array_merge(
array_slice($commit_map, 0, $show_limit),
array(null),
array_slice($commit_map, -$show_limit));
echo tsprintf("\n");
foreach ($display_commits as $commit) {
if ($commit === null) {
echo tsprintf(
" %s\n",
pht(
'< ... %s more commits ... >',
new PhutilNumber($commit_count - ($show_limit * 2))));
} else {
echo tsprintf(
" %s %s\n",
$this->getDisplayHash($commit->getHash()),
$commit->getDisplaySummary());
}
}
$query = pht(
'Land %s commit(s)?',
new PhutilNumber($commit_count));
$this->getWorkflow()
->getPrompt('arc.land.large-working-set')
->setQuery($query)
->execute();
}
// Build the commit objects into a tree.
foreach ($commit_map as $commit_hash => $commit) {
$parent_map = array();
foreach ($commit->getParents() as $parent) {
if (isset($commit_map[$parent])) {
$parent_map[$parent] = $commit_map[$parent];
}
}
$commit->setParentCommits($parent_map);
}
// Identify the commits which are heads (have no children).
$child_map = array();
foreach ($commit_map as $commit_hash => $commit) {
foreach ($commit->getParents() as $parent) {
$child_map[$parent][$commit_hash] = $commit;
}
}
foreach ($commit_map as $commit_hash => $commit) {
if (isset($child_map[$commit_hash])) {
continue;
}
$commit->setIsHeadCommit(true);
}
return $commit_map;
}
public function execute() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$this->validateArguments();
$raw_symbols = $this->getSourceRefs();
if (!$raw_symbols) {
$raw_symbols = $this->getDefaultSymbols();
}
$symbols = array();
foreach ($raw_symbols as $raw_symbol) {
$symbols[] = id(new ArcanistLandSymbol())
->setSymbol($raw_symbol);
}
$this->resolveSymbols($symbols);
$onto_remote = $this->selectOntoRemote($symbols);
$this->setOntoRemote($onto_remote);
$onto_refs = $this->selectOntoRefs($symbols);
$this->confirmOntoRefs($onto_refs);
$this->setOntoRefs($onto_refs);
$this->selectIntoRemote();
$this->selectIntoRef();
$into_commit = $this->selectIntoCommit();
$commit_map = $this->selectCommits($into_commit, $symbols);
$this->loadRevisionRefs($commit_map);
// TODO: It's possible we have a list of commits which includes disjoint
// groups of commits associated with the same revision, or groups of
// commits which do not form a range. We should test that here, since we
// can't land commit groups which are not a single contiguous range.
$revision_groups = array();
foreach ($commit_map as $commit_hash => $commit) {
$revision_ref = $commit->getRevisionRef();
if (!$revision_ref) {
echo tsprintf(
"\n%!\n%W\n\n",
pht('UNKNOWN REVISION'),
pht(
'Unable to determine which revision is associated with commit '.
'"%s". Use "arc diff" to create or update a revision with this '.
'commit, or "--revision" to force selection of a particular '.
'revision.',
$this->getDisplayHash($commit_hash)));
throw new PhutilArgumentUsageException(
pht(
'Unable to determine revision for commit "%s".',
$this->getDisplayHash($commit_hash)));
}
$revision_groups[$revision_ref->getPHID()][] = $commit;
}
$commit_heads = array();
foreach ($commit_map as $commit) {
if ($commit->getIsHeadCommit()) {
$commit_heads[] = $commit;
}
}
$revision_order = array();
foreach ($commit_heads as $head) {
foreach ($head->getAncestorRevisionPHIDs() as $phid) {
$revision_order[$phid] = true;
}
}
$revision_groups = array_select_keys(
$revision_groups,
array_keys($revision_order));
$sets = array();
foreach ($revision_groups as $revision_phid => $group) {
$revision_ref = head($group)->getRevisionRef();
$set = id(new ArcanistLandCommitSet())
->setRevisionRef($revision_ref)
->setCommits($group);
$sets[$revision_phid] = $set;
}
$sets = $this->filterCommitSets($sets);
if (!$this->getShouldPreview()) {
$this->confirmImplicitCommits($sets, $symbols);
}
$log->writeStatus(
pht('LANDING'),
pht('These changes will land:'));
foreach ($sets as $set) {
$this->printCommitSet($set);
}
if ($this->getShouldPreview()) {
$log->writeStatus(
pht('PREVIEW'),
pht('Completed preview of land operation.'));
return;
}
$query = pht('Land these changes?');
$this->getWorkflow()
->getPrompt('arc.land.confirm')
->setQuery($query)
->execute();
$this->confirmRevisions($sets);
$workflow = $this->getWorkflow();
$is_incremental = $this->getIsIncremental();
$is_hold = $this->getShouldHold();
$is_keep = $this->getShouldKeep();
$local_state = $api->newLocalState()
->setWorkflow($workflow)
->saveLocalState();
$this->setLocalState($local_state);
$seen_into = array();
try {
$last_key = last_key($sets);
$need_cascade = array();
$need_prune = array();
foreach ($sets as $set_key => $set) {
// Add these first, so we don't add them multiple times if we need
// to retry a push.
$need_prune[] = $set;
$need_cascade[] = $set;
while (true) {
$into_commit = $this->executeMerge($set, $into_commit);
if ($is_hold) {
$should_push = false;
} else if ($is_incremental) {
$should_push = true;
} else {
$is_last = ($set_key === $last_key);
$should_push = $is_last;
}
if ($should_push) {
try {
$this->pushChange($into_commit);
} catch (Exception $ex) {
// TODO: If the push fails, fetch and retry if the remote ref
// has moved ahead of us.
if ($this->getIntoLocal()) {
$can_retry = false;
} else if ($this->getIntoEmpty()) {
$can_retry = false;
} else if ($this->getIntoRemote() !== $this->getOntoRemote()) {
$can_retry = false;
} else {
$can_retry = false;
}
if ($can_retry) {
// New commit state here
$into_commit = '..';
continue;
}
throw $ex;
}
if ($need_cascade) {
// NOTE: We cascade each set we've pushed, but we're going to
// cascade them from most recent to least recent. This way,
// branches which descend from more recent changes only cascade
// once, directly in to the correct state.
$need_cascade = array_reverse($need_cascade);
foreach ($need_cascade as $cascade_set) {
$this->cascadeState($set, $into_commit);
}
$need_cascade = array();
}
if (!$is_keep) {
$this->pruneBranches($need_prune);
$need_prune = array();
}
}
break;
}
}
if ($is_hold) {
$this->didHoldChanges($into_commit);
$local_state->discardLocalState();
} else {
// TODO: Restore this.
// $this->getWorkflow()->askForRepositoryUpdate();
$this->reconcileLocalState($into_commit, $local_state);
$log->writeSuccess(
pht('DONE'),
pht('Landed changes.'));
}
} catch (Exception $ex) {
$local_state->restoreLocalState();
throw $ex;
} catch (Throwable $ex) {
$local_state->restoreLocalState();
throw $ex;
}
}
protected function validateArguments() {
$log = $this->getLogEngine();
$into_local = $this->getIntoLocalArgument();
$into_empty = $this->getIntoEmptyArgument();
$into_remote = $this->getIntoRemoteArgument();
$into_count = 0;
if ($into_remote !== null) {
$into_count++;
}
if ($into_local) {
$into_count++;
}
if ($into_empty) {
$into_count++;
}
if ($into_count > 1) {
throw new PhutilArgumentUsageException(
pht(
'Arguments "--into-local", "--into-remote", and "--into-empty" '.
'are mutually exclusive.'));
}
$into = $this->getIntoArgument();
if ($into && ($into_empty !== null)) {
throw new PhutilArgumentUsageException(
pht(
'Arguments "--into" and "--into-empty" are mutually exclusive.'));
}
$strategy = $this->selectMergeStrategy();
$this->setStrategy($strategy);
// Build the symbol ref here (which validates the format of the symbol),
// but don't load the object until later on when we're sure we actually
// need it, since loading it requires a relatively expensive Conduit call.
$revision_symbol = $this->getRevisionSymbol();
if ($revision_symbol) {
$symbol_ref = id(new ArcanistRevisionSymbolRef())
->setSymbol($revision_symbol);
$this->setRevisionSymbolRef($symbol_ref);
}
// NOTE: When a user provides: "--hold" or "--preview"; and "--incremental"
// or various combinations of remote flags, the flags affecting push/remote
// behavior have no effect.
// These combinations are allowed to support adding "--preview" or "--hold"
// to any command to run the same command with fewer side effects.
}
abstract protected function getDefaultSymbols();
abstract protected function resolveSymbols(array $symbols);
abstract protected function selectOntoRemote(array $symbols);
abstract protected function selectOntoRefs(array $symbols);
abstract protected function confirmOntoRefs(array $onto_refs);
abstract protected function selectIntoRemote();
abstract protected function selectIntoRef();
abstract protected function selectIntoCommit();
abstract protected function selectCommits($into_commit, array $symbols);
abstract protected function executeMerge(
ArcanistLandCommitSet $set,
$into_commit);
abstract protected function pushChange($into_commit);
abstract protected function cascadeState(
ArcanistLandCommitSet $set,
$into_commit);
protected function isSquashStrategy() {
return ($this->getStrategy() === 'squash');
}
abstract protected function pruneBranches(array $sets);
abstract protected function reconcileLocalState(
$into_commit,
ArcanistRepositoryLocalState $state);
abstract protected function didHoldChanges($into_commit);
private function selectMergeStrategy() {
$log = $this->getLogEngine();
$supported_strategies = array(
'merge',
'squash',
);
$supported_strategies = array_fuse($supported_strategies);
$strategy_list = implode(', ', $supported_strategies);
$strategy = $this->getStrategyArgument();
if ($strategy !== null) {
if (!isset($supported_strategies[$strategy])) {
throw new PhutilArgumentUsageException(
pht(
'Merge strategy "%s" specified with "--strategy" is unknown. '.
'Supported merge strategies are: %s.',
$strategy,
$strategy_list));
}
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, selected with "--strategy".',
$strategy));
return $strategy;
}
$strategy_key = 'arc.land.strategy';
$strategy = $this->getWorkflow()->getConfig($strategy_key);
if ($strategy !== null) {
if (!isset($supported_strategies[$strategy])) {
throw new PhutilArgumentUsageException(
pht(
'Merge strategy "%s" specified in "%s" configuration is '.
'unknown. Supported merge strategies are: %s.',
$strategy,
$strategy_list));
}
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, configured with "%s".',
$strategy,
$strategy_key));
return $strategy;
}
$strategy = 'squash';
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, the default strategy.',
$strategy));
return $strategy;
}
private function filterCommitSets(array $sets) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
$log = $this->getLogEngine();
// If some of the ancestor revisions are already closed, and the user did
// not specifically indicate that we should land them, and we are using
// a "squash" strategy, discard those sets.
if ($this->isSquashStrategy()) {
$discard = array();
foreach ($sets as $key => $set) {
$revision_ref = $set->getRevisionRef();
if (!$revision_ref->isClosed()) {
continue;
}
$symbols = null;
foreach ($set->getCommits() as $commit) {
$commit_symbols = $commit->getDirectSymbols();
if ($commit_symbols) {
$symbols = $commit_symbols;
break;
}
}
if ($symbols) {
continue;
}
$discard[] = $set;
unset($sets[$key]);
}
if ($discard) {
echo tsprintf(
"\n%!\n%W\n",
pht('DISCARDING ANCESTORS'),
pht(
'Some ancestor commits are associated with revisions that have '.
'already been closed. These changes will be skipped:'));
foreach ($discard as $set) {
$this->printCommitSet($set);
}
echo tsprintf("\n");
}
}
// TODO: Some of the revisions we've identified may be mapped to an
// outdated set of commits. We should look in local branches for a better
// set of commits, and try to confirm that the state we're about to land
// is the current state in Differential.
return $sets;
}
+ final protected function newPassthru($pattern /* , ... */) {
+ $workflow = $this->getWorkflow();
+ $argv = func_get_args();
+
+ $api = $this->getRepositoryAPI();
+
+ $passthru = call_user_func_array(
+ array($api, 'newPassthru'),
+ $argv);
+
+ $command = $workflow->newCommand($passthru)
+ ->setResolveOnError(true);
+
+ return $command->execute();
+ }
+
}
diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php
index b0ae9dc0..c6b6df9d 100644
--- a/src/land/engine/ArcanistMercurialLandEngine.php
+++ b/src/land/engine/ArcanistMercurialLandEngine.php
@@ -1,655 +1,655 @@
<?php
final class ArcanistMercurialLandEngine
extends ArcanistLandEngine {
protected function getDefaultSymbols() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$bookmark = $api->getActiveBookmark();
if ($bookmark !== null) {
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the active bookmark, "%s".',
$bookmark));
return array($bookmark);
}
$branch = $api->getBranchName();
if ($branch !== null) {
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the current branch, "%s".',
$branch));
return array($branch);
}
throw new Exception(pht('TODO: Operate on raw revision.'));
}
protected function resolveSymbols(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
foreach ($symbols as $symbol) {
$raw_symbol = $symbol->getSymbol();
if ($api->isBookmark($raw_symbol)) {
$hash = $api->getBookmarkCommitHash($raw_symbol);
$symbol->setCommit($hash);
// TODO: Set that this is a bookmark?
continue;
}
if ($api->isBranch($raw_symbol)) {
$hash = $api->getBranchCommitHash($raw_symbol);
$symbol->setCommit($hash);
// TODO: Set that this is a branch?
continue;
}
throw new PhutilArgumentUsageException(
pht(
'Symbol "%s" is not a bookmark or branch name.',
$raw_symbol));
}
}
protected function selectOntoRemote(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$remote = $this->newOntoRemote($symbols);
// TODO: Verify this remote actually exists.
return $remote;
}
private function newOntoRemote(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$remote = $this->getOntoRemoteArgument();
if ($remote !== null) {
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected with the "--onto-remote" flag.',
$remote));
return $remote;
}
$remote = $this->getOntoRemoteFromConfiguration();
if ($remote !== null) {
$remote_key = $this->getOntoRemoteConfigurationKey();
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected by reading "%s" configuration.',
$remote,
$remote_key));
return $remote;
}
$api = $this->getRepositoryAPI();
$default_remote = 'default';
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Landing onto remote "%s", the default remote under Mercurial.',
$default_remote));
return $default_remote;
}
protected function selectOntoRefs(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$log = $this->getLogEngine();
$onto = $this->getOntoArguments();
if ($onto) {
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected with the "--onto" flag: %s.',
implode(', ', $onto)));
return $onto;
}
$onto = $this->getOntoFromConfiguration();
if ($onto) {
$onto_key = $this->getOntoConfigurationKey();
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected by reading "%s" configuration: %s.',
$onto_key,
implode(', ', $onto)));
return $onto;
}
$api = $this->getRepositoryAPI();
$default_onto = 'default';
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Landing onto target "%s", the default target under Mercurial.',
$default_onto));
return array($default_onto);
}
protected function confirmOntoRefs(array $onto_refs) {
foreach ($onto_refs as $onto_ref) {
if (!strlen($onto_ref)) {
throw new PhutilArgumentUsageException(
pht(
'Selected "onto" ref "%s" is invalid: the empty string is not '.
'a valid ref.',
$onto_ref));
}
}
}
protected function selectIntoRemote() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
if ($this->getIntoLocalArgument()) {
$this->setIntoLocal(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into local state, selected with the "--into-local" '.
'flag.'));
return;
}
$into = $this->getIntoRemoteArgument();
if ($into !== null) {
// TODO: Verify that this is a valid path.
// TODO: Allow a raw URI?
$this->setIntoRemote($into);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s", selected with the "--into" flag.',
$into));
return;
}
$onto = $this->getOntoRemote();
$this->setIntoRemote($onto);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s" by default, because this is the remote '.
'the change is landing onto.',
$onto));
}
protected function selectIntoRef() {
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
$into = $this->getIntoArgument();
if ($into !== null) {
$this->setIntoRef($into);
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s", selected with the "--into" flag.',
$into));
return;
}
$ontos = $this->getOntoRefs();
$onto = head($ontos);
$this->setIntoRef($onto);
if (count($ontos) > 1) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the first '.
'"onto" target.',
$onto));
} else {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the "onto" '.
'target.',
$onto));
}
}
protected function selectIntoCommit() {
// Make sure that our "into" target is valid.
$log = $this->getLogEngine();
if ($this->getIntoEmpty()) {
// If we're running under "--into-empty", we don't have to do anything.
$log->writeStatus(
pht('INTO COMMIT'),
pht('Preparing merge into the empty state.'));
return null;
}
if ($this->getIntoLocal()) {
// If we're running under "--into-local", just make sure that the
// target identifies some actual commit.
$api = $this->getRepositoryAPI();
$local_ref = $this->getIntoRef();
// TODO: This error handling could probably be cleaner.
$into_commit = $api->getCanonicalRevisionName($local_ref);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into local target "%s", at commit "%s".',
$local_ref,
$this->getDisplayHash($into_commit)));
return $into_commit;
}
$target = id(new ArcanistLandTarget())
->setRemote($this->getIntoRemote())
->setRef($this->getIntoRef());
$commit = $this->fetchTarget($target);
if ($commit !== null) {
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into "%s" from remote "%s", at commit "%s".',
$target->getRef(),
$target->getRemote(),
$this->getDisplayHash($commit)));
return $commit;
}
// If we have no valid target and the user passed "--into" explicitly,
// treat this as an error. For example, "arc land --into Q --onto Q",
// where "Q" does not exist, is an error.
if ($this->getIntoArgument()) {
throw new PhutilArgumentUsageException(
pht(
'Ref "%s" does not exist in remote "%s".',
$target->getRef(),
$target->getRemote()));
}
// Otherwise, treat this as implying "--into-empty". For example,
// "arc land --onto Q", where "Q" does not exist, is equivalent to
// "arc land --into-empty --onto Q".
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into the empty state to create target "%s" '.
'in remote "%s".',
$target->getRef(),
$target->getRemote()));
return null;
}
private function fetchTarget(ArcanistLandTarget $target) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// TODO: Support bookmarks.
// TODO: Deal with bookmark save/restore behavior.
- // TODO: Format this nicely with passthru.
// TODO: Raise a good error message when the ref does not exist.
- $api->execPassthru(
+ $err = $this->newPassthru(
'pull -b %s -- %s',
$target->getRef(),
$target->getRemote());
+ // TODO: Deal with errors.
// TODO: Deal with multiple branch heads.
list($stdout) = $api->execxLocal(
'log --rev %s --template %s --',
hgsprintf(
'last(ancestors(%s) and !outgoing(%s))',
$target->getRef(),
$target->getRemote()),
'{node}');
return trim($stdout);
}
protected function selectCommits($into_commit, array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
$commit_map = array();
foreach ($symbols as $symbol) {
$symbol_commit = $symbol->getCommit();
$template = '{node}-{parents}-';
if ($into_commit === null) {
list($commits) = $api->execxLocal(
'log --rev %s --template %s --',
hgsprintf('reverse(ancestors(%s))', $into_commit),
$template);
} else {
list($commits) = $api->execxLocal(
'log --rev %s --template %s --',
hgsprintf(
'reverse(ancestors(%s) - ancestors(%s))',
$symbol_commit,
$into_commit),
$template);
}
$commits = phutil_split_lines($commits, false);
$is_first = true;
foreach ($commits as $line) {
if (!strlen($line)) {
continue;
}
$parts = explode('-', $line, 3);
if (count($parts) < 3) {
throw new Exception(
pht(
'Unexpected output from "hg log ...": %s',
$line));
}
$hash = $parts[0];
if (!isset($commit_map[$hash])) {
$parents = $parts[1];
$parents = trim($parents);
if (strlen($parents)) {
$parents = explode(' ', $parents);
} else {
$parents = array();
}
$summary = $parts[2];
$commit_map[$hash] = id(new ArcanistLandCommit())
->setHash($hash)
->setParents($parents)
->setSummary($summary);
}
$commit = $commit_map[$hash];
if ($is_first) {
$commit->addDirectSymbol($symbol);
$is_first = false;
}
$commit->addIndirectSymbol($symbol);
}
}
return $this->confirmCommits($into_commit, $symbols, $commit_map);
}
protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) {
$api = $this->getRepositoryAPI();
if ($this->getStrategy() !== 'squash') {
throw new Exception(pht('TODO: Support merge strategies'));
}
// TODO: Add a Mercurial version check requiring 2.1.1 or newer.
$api->execxLocal(
'update --rev %s',
hgsprintf('%s', $into_commit));
$commits = $set->getCommits();
$min_commit = last($commits)->getHash();
$max_commit = head($commits)->getHash();
$revision_ref = $set->getRevisionRef();
$commit_message = $revision_ref->getCommitMessage();
try {
$argv = array();
$argv[] = '--dest';
$argv[] = hgsprintf('%s', $into_commit);
$argv[] = '--rev';
$argv[] = hgsprintf('%s..%s', $min_commit, $max_commit);
$argv[] = '--logfile';
$argv[] = '-';
$argv[] = '--keep';
$argv[] = '--collapse';
$future = $api->execFutureLocal('rebase %Ls', $argv);
$future->write($commit_message);
$future->resolvex();
} catch (CommandException $ex) {
// TODO
// $api->execManualLocal('rebase --abort');
throw $ex;
}
list($stdout) = $api->execxLocal('log --rev tip --template %s', '{node}');
$new_cursor = trim($stdout);
return $new_cursor;
}
protected function pushChange($into_commit) {
$api = $this->getRepositoryAPI();
// TODO: This does not respect "--into" or "--onto" properly.
- $api->execxLocal(
+ $this->newPassthru(
'push --rev %s -- %s',
hgsprintf('%s', $into_commit),
$this->getOntoRemote());
}
protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// This has no effect when we're executing a merge strategy.
if (!$this->isSquashStrategy()) {
return;
}
$old_commit = last($set->getCommits())->getHash();
$new_commit = $into_commit;
list($output) = $api->execxLocal(
'log --rev %s --template %s',
hgsprintf('children(%s)', $old_commit),
'{node}\n');
$child_hashes = phutil_split_lines($output, false);
foreach ($child_hashes as $child_hash) {
if (!strlen($child_hash)) {
continue;
}
// TODO: If the only heads which are descendants of this child will
// be deleted, we can skip this rebase?
try {
$api->execxLocal(
'rebase --source %s --dest %s --keep --keepbranches',
$child_hash,
$new_commit);
} catch (CommandException $ex) {
// TODO: Recover state.
throw $ex;
}
}
}
protected function pruneBranches(array $sets) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// This has no effect when we're executing a merge strategy.
if (!$this->isSquashStrategy()) {
return;
}
$strip = array();
// We've rebased all descendants already, so we can safely delete all
// of these commits.
$sets = array_reverse($sets);
foreach ($sets as $set) {
$commits = $set->getCommits();
$min_commit = head($commits)->getHash();
$max_commit = last($commits)->getHash();
$strip[] = hgsprintf('%s::%s', $min_commit, $max_commit);
}
$rev_set = '('.implode(') or (', $strip).')';
// See PHI45. If we have "hg evolve", get rid of old commits using
// "hg prune" instead of "hg strip".
// If we "hg strip" a commit which has an obsolete predecessor, it
// removes the obsolescence marker and revives the predecessor. This is
// not desirable: we want to destroy all predecessors of these commits.
try {
$api->execxLocal(
'--config extensions.evolve= prune --rev %s',
$rev_set);
} catch (CommandException $ex) {
$api->execxLocal(
'--config extensions.strip= strip --rev %s',
$rev_set);
}
}
protected function reconcileLocalState(
$into_commit,
ArcanistRepositoryLocalState $state) {
// TODO: For now, just leave users wherever they ended up.
$state->discardLocalState();
}
protected function didHoldChanges($into_commit) {
$log = $this->getLogEngine();
$local_state = $this->getLocalState();
$message = pht(
'Holding changes locally, they have not been pushed.');
// TODO: This is only vaguely correct.
$push_command = csprintf(
'$ hg push --rev %s -- %s',
hgsprintf('%s', $this->getDisplayHash($into_commit)),
$this->getOntoRemote());
echo tsprintf(
"\n%!\n%s\n\n",
pht('HOLD CHANGES'),
$message);
echo tsprintf(
"%s\n\n **%s**\n\n",
pht('To push changes manually, run this command:'),
$push_command);
$restore_commands = $local_state->getRestoreCommandsForDisplay();
if ($restore_commands) {
echo tsprintf(
"%s\n\n",
pht(
'To go back to how things were before you ran "arc land", run '.
'these %s command(s):',
phutil_count($restore_commands)));
foreach ($restore_commands as $restore_command) {
echo tsprintf(" **%s**\n", $restore_command);
}
echo tsprintf("\n");
}
echo tsprintf(
"%s\n",
pht(
'Local branches and bookmarks have not been changed, and are still '.
'in the same state as before.'));
}
}
diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
index 3610dda0..0cf1713e 100644
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1,1774 +1,1773 @@
<?php
/**
* Interfaces with Git working copies.
*/
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
private $repositoryHasNoCommits = false;
const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
/**
* For the repository's initial commit, 'git diff HEAD^' and similar do
* not work. Using this instead does work; it is the hash of the empty tree.
*/
const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
private $symbolicHeadCommit;
private $resolvedHeadCommit;
protected function buildLocalFuture(array $argv) {
$argv[0] = 'git '.$argv[0];
- $future = newv('ExecFuture', $argv);
- $future->setCWD($this->getPath());
- return $future;
+ return newv('ExecFuture', $argv)
+ ->setCWD($this->getPath());
}
- public function execPassthru($pattern /* , ... */) {
+ public function newPassthru($pattern /* , ... */) {
$args = func_get_args();
static $git = null;
if ($git === null) {
if (phutil_is_windows()) {
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
// everything goes to hell if we don't. We must provide an absolute
// path to Git for this to work properly.
$git = Filesystem::resolveBinary('git');
$git = csprintf('%s', $git);
} else {
$git = 'git';
}
}
$args[0] = $git.' '.$args[0];
- return call_user_func_array('phutil_passthru', $args);
+ return newv('PhutilExecPassthru', $args)
+ ->setCWD($this->getPath());
}
-
public function getSourceControlSystemName() {
return 'git';
}
public function getGitVersion() {
static $version = null;
if ($version === null) {
list($stdout) = $this->execxLocal('--version');
$version = rtrim(str_replace('git version ', '', $stdout));
}
return $version;
}
public function getMetadataPath() {
static $path = null;
if ($path === null) {
list($stdout) = $this->execxLocal('rev-parse --git-dir');
$path = rtrim($stdout, "\n");
// the output of git rev-parse --git-dir is an absolute path, unless
// the cwd is the root of the repository, in which case it uses the
// relative path of .git. If we get this relative path, turn it into
// an absolute path.
if ($path === '.git') {
$path = $this->getPath('.git');
}
}
return $path;
}
public function getHasCommits() {
return !$this->repositoryHasNoCommits;
}
/**
* Tests if a child commit is descendant of a parent commit.
* If child and parent are the same, it returns false.
* @param Child commit SHA.
* @param Parent commit SHA.
* @return bool True if the child is a descendant of the parent.
*/
private function isDescendant($child, $parent) {
list($common_ancestor) = $this->execxLocal(
'merge-base %s %s',
$child,
$parent);
$common_ancestor = trim($common_ancestor);
return ($common_ancestor == $parent) && ($common_ancestor != $child);
}
public function getLocalCommitInformation() {
if ($this->repositoryHasNoCommits) {
// Zero commits.
throw new Exception(
pht(
"You can't get local commit information for a repository with no ".
"commits."));
} else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
// One commit.
$against = 'HEAD';
} else {
// 2..N commits. We include commits reachable from HEAD which are
// not reachable from the base commit; this is consistent with user
// expectations even though it is not actually the diff range.
// Particularly:
//
// |
// D <----- master branch
// |
// C Y <- feature branch
// | /|
// B X
// | /
// A
// |
//
// If "A, B, C, D" are master, and the user is at Y, when they run
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
// this as being the commits X and Y. If we log "B..Y", we only show
// Y. With "Y --not B", we show X and Y.
if ($this->symbolicHeadCommit !== null) {
$base_commit = $this->getBaseCommit();
$resolved_base = $this->resolveCommit($base_commit);
$head_commit = $this->symbolicHeadCommit;
$resolved_head = $this->getHeadCommit();
if (!$this->isDescendant($resolved_head, $resolved_base)) {
// NOTE: Since the base commit will have been resolved as the
// merge-base of the specified base and the specified HEAD, we can't
// easily tell exactly what's wrong with the range.
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
// is reversed, but resolving the commit "HEAD" will compute its
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
// appear empty.
throw new ArcanistUsageException(
pht(
'The specified commit range is empty, backward or invalid: the '.
'base (%s) is not an ancestor of the head (%s). You can not '.
'diff an empty or reversed commit range.',
$base_commit,
$head_commit));
}
}
$against = csprintf(
'%s --not %s',
$this->getHeadCommit(),
$this->getBaseCommit());
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed through escapeshellarg() they are replaced with spaces.
// TODO: Learn how cmd.exe works and find some clever workaround?
// NOTE: If we use "%x00", output is truncated in Windows.
list($info) = $this->execxLocal(
phutil_is_windows()
? 'log %C --format=%C --'
: 'log %C --format=%s --',
$against,
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02');
$commits = array();
$info = trim($info, " \n\2");
if (!strlen($info)) {
return array();
}
$info = explode("\2", $info);
foreach ($info as $line) {
list($commit, $tree, $parents, $time, $author, $author_email,
$title, $message) = explode("\1", trim($line), 8);
$message = rtrim($message);
$commits[$commit] = array(
'commit' => $commit,
'tree' => $tree,
'parents' => array_filter(explode(' ', $parents)),
'time' => $time,
'author' => $author,
'summary' => $title,
'message' => $message,
'authorEmail' => $author_email,
);
}
return $commits;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) {
$this->setBaseCommitExplanation(
pht('you explicitly specified the empty tree.'));
return $symbolic_commit;
}
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s %s',
$symbolic_commit,
$this->getHeadCommit());
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
if ($this->symbolicHeadCommit === null) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and HEAD.",
$symbolic_commit));
} else {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and the explicitly specified head commit '%s'.",
$symbolic_commit,
$this->symbolicHeadCommit));
}
return trim($merge_base);
}
// Detect zero-commit or one-commit repositories. There is only one
// relative-commit value that makes any sense in these repositories: the
// empty tree.
list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
if ($err) {
list($err) = $this->execManualLocal('rev-parse --verify HEAD');
if ($err) {
$this->repositoryHasNoCommits = true;
}
if ($this->repositoryHasNoCommits) {
$this->setBaseCommitExplanation(pht('the repository has no commits.'));
} else {
$this->setBaseCommitExplanation(
pht('the repository has only one commit.'));
}
return self::GIT_MAGIC_ROOT_COMMIT;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
$do_write = false;
$default_relative = null;
$working_copy = $this->getWorkingCopyIdentity();
if ($working_copy) {
$default_relative = $working_copy->getProjectConfig(
'git.default-relative-commit');
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s' in ".
"'%s'. This setting overrides other settings.",
$default_relative,
'git.default-relative-commit',
'.arcconfig'));
}
if (!$default_relative) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$default_relative = trim($upstream);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' (the Git upstream ".
"of the current branch) HEAD.",
$default_relative));
}
}
if (!$default_relative) {
$default_relative = $this->readScratchFile('default-relative-commit');
$default_relative = trim($default_relative);
if ($default_relative) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s'.",
$default_relative,
'.git/arc/default-relative-commit'));
}
}
if (!$default_relative) {
// TODO: Remove the history lesson soon.
echo phutil_console_format(
"<bg:green>** %s **</bg>\n\n",
pht('Select a Default Commit Range'));
echo phutil_console_wrap(
pht(
"You're running a command which operates on a range of revisions ".
"(usually, from some revision to HEAD) but have not specified the ".
"revision that should determine the start of the range.\n\n".
"Previously, arc assumed you meant '%s' when you did not specify ".
"a start revision, but this behavior does not make much sense in ".
"most workflows outside of Facebook's historic %s workflow.\n\n".
"arc no longer assumes '%s'. You must specify a relative commit ".
"explicitly when you invoke a command (e.g., `%s`, not just `%s`) ".
"or select a default for this working copy.\n\nIn most cases, the ".
"best default is '%s'. You can also select '%s' to preserve the ".
"old behavior, or some other remote or branch. But you almost ".
"certainly want to select 'origin/master'.\n\n".
"(Technically: the merge-base of the selected revision and HEAD is ".
"used to determine the start of the commit range.)",
'HEAD^',
'git-svn',
'HEAD^',
'arc diff HEAD^',
'arc diff',
'origin/master',
'HEAD^'));
$prompt = pht('What default do you want to use? [origin/master]');
$default = phutil_console_prompt($prompt);
if (!strlen(trim($default))) {
$default = 'origin/master';
}
$default_relative = $default;
$do_write = true;
}
list($object_type) = $this->execxLocal(
'cat-file -t %s',
$default_relative);
if (trim($object_type) !== 'commit') {
throw new Exception(
pht(
"Relative commit '%s' is not the name of a commit!",
$default_relative));
}
if ($do_write) {
// Don't perform this write until we've verified that the object is a
// valid commit name.
$this->writeScratchFile('default-relative-commit', $default_relative);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as you just specified.",
$default_relative));
}
list($merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$default_relative);
return trim($merge_base);
}
public function getHeadCommit() {
if ($this->resolvedHeadCommit === null) {
$this->resolvedHeadCommit = $this->resolveCommit(
coalesce($this->symbolicHeadCommit, 'HEAD'));
}
return $this->resolvedHeadCommit;
}
public function setHeadCommit($symbolic_commit) {
$this->symbolicHeadCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
/**
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
* @param string_symbol commit.
* @return string the commit SHA.
*/
private function resolveCommit($symbolic_commit) {
list($err, $commit_hash) = $this->execManualLocal(
'rev-parse %s',
$symbolic_commit);
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
return trim($commit_hash);
}
private function getDiffFullOptions($detect_moves_and_renames = true) {
$options = array(
self::getDiffBaseOptions(),
'--no-color',
'--src-prefix=a/',
'--dst-prefix=b/',
'-U'.$this->getDiffLinesOfContext(),
);
if ($detect_moves_and_renames) {
$options[] = '-M';
$options[] = '-C';
}
return implode(' ', $options);
}
private function getDiffBaseOptions() {
$options = array(
// Disable external diff drivers, like graphical differs, since Arcanist
// needs to capture the diff text.
'--no-ext-diff',
// Disable textconv so we treat binary files as binary, even if they have
// an alternative textual representation. TODO: Ideally, Differential
// would ship up the binaries for 'arc patch' but display the textconv
// output in the visual diff.
'--no-textconv',
// Provide a standard view of submodule changes; the 'log' and 'diff'
// values do not parse by the diff parser.
'--submodule=short',
);
return implode(' ', $options);
}
/**
* @param the base revision
* @param head revision. If this is null, the generated diff will include the
* working copy
*/
public function getFullGitDiff($base, $head = null) {
$options = $this->getDiffFullOptions();
$config_options = array();
// See T13432. Disable the rare "diff.suppressBlankEmpty" configuration
// option, which discards the " " (space) change type prefix on unchanged
// blank lines. At time of writing the parser does not handle these
// properly, but generating a more-standard diff is generally desirable
// even if a future parser handles this case more gracefully.
$config_options[] = '-c';
$config_options[] = 'diff.suppressBlankEmpty=false';
if ($head !== null) {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s %s --",
$config_options,
$base,
$head);
} else {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s --",
$config_options,
$base);
}
return $stdout;
}
/**
* @param string Path to generate a diff for.
* @param bool If true, detect moves and renames. Otherwise, ignore
* moves/renames; this is useful because it prompts git to
* generate real diff text.
*/
public function getRawDiffText($path, $detect_moves_and_renames = true) {
$options = $this->getDiffFullOptions($detect_moves_and_renames);
list($stdout) = $this->execxLocal(
"diff {$options} %s -- %s",
$this->getBaseCommit(),
$path);
return $stdout;
}
private function getBranchNameFromRef($ref) {
$count = 0;
$branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count);
if ($count !== 1) {
return null;
}
if (!strlen($branch)) {
return null;
}
return $branch;
}
public function getBranchName() {
list($err, $stdout, $stderr) = $this->execManualLocal(
'symbolic-ref --quiet HEAD');
if ($err === 0) {
// We expect the branch name to come qualified with a refs/heads/ prefix.
// Verify this, and strip it.
$ref = rtrim($stdout);
$branch = $this->getBranchNameFromRef($ref);
if ($branch === null) {
throw new Exception(
pht('Failed to parse %s output!', 'git symbolic-ref'));
}
return $branch;
} else if ($err === 1) {
// Exit status 1 with --quiet indicates that HEAD is detached.
return null;
} else {
throw new Exception(
pht('Command %s failed: %s', 'git symbolic-ref', $stderr));
}
}
public function getRemoteURI() {
// Determine which remote to examine; default to 'origin'
$remote = 'origin';
$branch = $this->getBranchName();
if ($branch) {
$path = $this->getPathToUpstream($branch);
if ($path->isConnectedToRemote()) {
$remote = $path->getRemoteRemoteName();
}
}
return $this->getGitRemoteFetchURI($remote);
}
public function getSourceControlPath() {
// TODO: Try to get something useful here.
return null;
}
public function getGitCommitLog() {
$relative = $this->getBaseCommit();
if ($this->repositoryHasNoCommits) {
// No commits yet.
return '';
} else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
// First commit.
list($stdout) = $this->execxLocal(
'log --format=medium HEAD');
} else {
// 2..N commits.
list($stdout) = $this->execxLocal(
'log --first-parent --format=medium %s..%s',
$this->getBaseCommit(),
$this->getHeadCommit());
}
return $stdout;
}
public function getGitHistoryLog() {
list($stdout) = $this->execxLocal(
'log --format=medium -n%d %s',
self::SEARCH_LENGTH_FOR_PARENT_REVISIONS,
$this->getBaseCommit());
return $stdout;
}
public function getSourceControlBaseRevision() {
list($stdout) = $this->execxLocal(
'rev-parse %s',
$this->getBaseCommit());
return rtrim($stdout, "\n");
}
public function getCanonicalRevisionName($string) {
$match = null;
if (preg_match('/@([0-9]+)$/', $string, $match)) {
$stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
} else {
list($stdout) = $this->execxLocal(
phutil_is_windows()
? 'show -s --format=%C %s --'
: 'show -s --format=%s %s --',
'%H',
$string);
}
return rtrim($stdout);
}
private function executeSVNFindRev($input, $vcs) {
$match = array();
list($stdout) = $this->execxLocal(
'svn find-rev %s',
$input);
if (!$stdout) {
throw new ArcanistUsageException(
pht(
'Cannot find the %s equivalent of %s.',
$vcs,
$input));
}
// When git performs a partial-rebuild during svn
// look-up, we need to parse the final line
$lines = explode("\n", $stdout);
$stdout = $lines[count($lines) - 2];
return rtrim($stdout);
}
// Convert svn revision number to git hash
public function getHashFromFromSVNRevisionNumber($revision_id) {
return $this->executeSVNFindRev('r'.$revision_id, 'Git');
}
// Convert a git hash to svn revision number
public function getSVNRevisionNumberFromHash($hash) {
return $this->executeSVNFindRev($hash, 'SVN');
}
private function buildUncommittedStatusViaStatus() {
$status = $this->buildLocalFuture(
array(
'status --porcelain=2 -z',
));
list($stdout) = $status->resolvex();
$result = new PhutilArrayWithDefaultValue();
$parts = explode("\0", $stdout);
while (count($parts) > 1) {
$entry = array_shift($parts);
$entry_parts = explode(' ', $entry, 2);
if ($entry_parts[0] == '1') {
$entry_parts = explode(' ', $entry, 9);
$path = $entry_parts[8];
} else if ($entry_parts[0] == '2') {
$entry_parts = explode(' ', $entry, 10);
$path = $entry_parts[9];
} else if ($entry_parts[0] == 'u') {
$entry_parts = explode(' ', $entry, 11);
$path = $entry_parts[10];
} else if ($entry_parts[0] == '?') {
$entry_parts = explode(' ', $entry, 2);
$result[$entry_parts[1]] = self::FLAG_UNTRACKED;
continue;
}
$result[$path] |= self::FLAG_UNCOMMITTED;
$index_state = substr($entry_parts[1], 0, 1);
$working_state = substr($entry_parts[1], 1, 1);
if ($index_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($index_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($index_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
if ($working_state != '.') {
$result[$path] |= self::FLAG_UNSTAGED;
if ($index_state == '.') {
if ($working_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($working_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($working_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
}
}
$submodule_tracked = substr($entry_parts[2], 2, 1);
$submodule_untracked = substr($entry_parts[2], 3, 1);
if ($submodule_tracked == 'M' || $submodule_untracked == 'U') {
$result[$path] |= self::FLAG_EXTERNALS;
}
if ($entry_parts[0] == '2') {
$result[array_shift($parts)] = $result[$path] | self::FLAG_DELETED;
$result[$path] |= self::FLAG_ADDED;
}
}
return $result->toArray();
}
protected function buildUncommittedStatus() {
if (version_compare($this->getGitVersion(), '2.11.0', '>=')) {
return $this->buildUncommittedStatusViaStatus();
}
$diff_options = $this->getDiffBaseOptions();
if ($this->repositoryHasNoCommits) {
$diff_base = self::GIT_MAGIC_ROOT_COMMIT;
} else {
$diff_base = 'HEAD';
}
// Find uncommitted changes.
$uncommitted_future = $this->buildLocalFuture(
array(
'diff %C --raw %s --',
$diff_options,
$diff_base,
));
$untracked_future = $this->buildLocalFuture(
array(
'ls-files --others --exclude-standard',
));
// Unstaged changes
$unstaged_future = $this->buildLocalFuture(
array(
'diff-files --name-only',
));
$futures = array(
$uncommitted_future,
$untracked_future,
// NOTE: `git diff-files` races with each of these other commands
// internally, and resolves with inconsistent results if executed
// in parallel. To work around this, DO NOT run it at the same time.
// After the other commands exit, we can start the `diff-files` command.
);
id(new FutureIterator($futures))->resolveAll();
// We're clear to start the `git diff-files` now.
$unstaged_future->start();
$result = new PhutilArrayWithDefaultValue();
list($stdout) = $uncommitted_future->resolvex();
$uncommitted_files = $this->parseGitRawDiff($stdout);
foreach ($uncommitted_files as $path => $mask) {
$result[$path] |= ($mask | self::FLAG_UNCOMMITTED);
}
list($stdout) = $untracked_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNTRACKED;
}
}
list($stdout, $stderr) = $unstaged_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNSTAGED;
}
}
return $result->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout, $stderr) = $this->execxLocal(
'diff %C --raw %s HEAD --',
$this->getDiffBaseOptions(),
$this->getBaseCommit());
return $this->parseGitRawDiff($stdout);
}
public function getGitConfig($key, $default = null) {
list($err, $stdout) = $this->execManualLocal('config %s', $key);
if ($err) {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
}
public function addToCommit(array $paths) {
$this->execxLocal(
'add -A -- %Ls',
$paths);
$this->reloadWorkingCopy();
return $this;
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
// so we do not provide it and thus require a message.
$this->execxLocal(
'commit -F %s',
$tmp_file);
$this->reloadWorkingCopy();
return $this;
}
public function amendCommit($message = null) {
if ($message === null) {
$this->execxLocal('commit --amend --allow-empty -C HEAD');
} else {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend --allow-empty -F %s',
$tmp_file);
}
$this->reloadWorkingCopy();
return $this;
}
private function parseGitRawDiff($status, $full = false) {
static $flags = array(
'A' => self::FLAG_ADDED,
'M' => self::FLAG_MODIFIED,
'D' => self::FLAG_DELETED,
);
$status = trim($status);
$lines = array();
foreach (explode("\n", $status) as $line) {
if ($line) {
$lines[] = preg_split("/[ \t]/", $line, 6);
}
}
$files = array();
foreach ($lines as $line) {
$mask = 0;
// "git diff --raw" lines begin with a ":" character.
$old_mode = ltrim($line[0], ':');
$new_mode = $line[1];
// The hashes may be padded with "." characters for alignment. Discard
// them.
$old_hash = rtrim($line[2], '.');
$new_hash = rtrim($line[3], '.');
$flag = $line[4];
$file = $line[5];
$new_value = intval($new_mode, 8);
$is_submodule = (($new_value & 0160000) === 0160000);
if (($is_submodule) &&
($flag == 'M') &&
($old_hash === $new_hash) &&
($old_mode === $new_mode)) {
// See T9455. We see this submodule as "modified", but the old and new
// hashes are the same and the old and new modes are the same, so we
// don't directly see a modification.
// We can end up here if we have a submodule which has uncommitted
// changes inside of it (for example, the user has added untracked
// files or made uncommitted changes to files in the submodule). In
// this case, we set a different flag because we can't meaningfully
// give users the same prompt.
// Note that if the submodule has real changes from the parent
// perspective (the base commit has changed) and also has uncommitted
// changes, we'll only see the real changes and miss the uncommitted
// changes. At the time of writing, there is no reasonable porcelain
// for finding those changes, and the impact of this error seems small.
$mask |= self::FLAG_EXTERNALS;
} else if (isset($flags[$flag])) {
$mask |= $flags[$flag];
} else if ($flag[0] == 'R') {
$both = explode("\t", $file);
if ($full) {
$files[$both[0]] = array(
'mask' => $mask | self::FLAG_DELETED,
'ref' => str_repeat('0', 40),
);
} else {
$files[$both[0]] = $mask | self::FLAG_DELETED;
}
$file = $both[1];
$mask |= self::FLAG_ADDED;
} else if ($flag[0] == 'C') {
$both = explode("\t", $file);
$file = $both[1];
$mask |= self::FLAG_ADDED;
}
if ($full) {
$files[$file] = array(
'mask' => $mask,
'ref' => $new_hash,
);
} else {
$files[$file] = $mask;
}
}
return $files;
}
public function getAllFiles() {
$future = $this->buildLocalFuture(array('ls-files -z'));
return id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'diff --raw %s',
$since_commit);
return $this->parseGitRawDiff($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'blame --porcelain -w -M %s -- %s',
$this->getBaseCommit(),
$path);
// the --porcelain format prints at least one header line per source line,
// then the source line prefixed by a tab character
$blame_info = preg_split('/^\t.*\n/m', rtrim($stdout));
// commit info is not repeated in these headers, so cache it
$revision_data = array();
$blame = array();
foreach ($blame_info as $line_info) {
$revision = substr($line_info, 0, 40);
$data = idx($revision_data, $revision, array());
if (empty($data)) {
$matches = array();
if (!preg_match('/^author (.*)$/m', $line_info, $matches)) {
throw new Exception(
pht(
'Unexpected output from %s: no author for commit %s',
'git blame',
$revision));
}
$data['author'] = $matches[1];
$data['from_first_commit'] = preg_match('/^boundary$/m', $line_info);
$revision_data[$revision] = $data;
}
// Ignore lines predating the git repository (on a boundary commit)
// rather than blaming them on the oldest diff's unfortunate author
if (!$data['from_first_commit']) {
$blame[] = array($data['author'], $revision);
}
}
return $blame;
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision($path, 'HEAD');
}
private function parseGitTree($stdout) {
$result = array();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return $result;
}
$lines = explode("\n", $stdout);
foreach ($lines as $line) {
$matches = array();
$ok = preg_match(
'/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/',
$line,
$matches);
if (!$ok) {
throw new Exception(pht('Failed to parse %s output!', 'git ls-tree'));
}
$result[$matches[4]] = array(
'mode' => $matches[1],
'type' => $matches[2],
'ref' => $matches[3],
);
}
return $result;
}
private function getFileDataAtRevision($path, $revision) {
// NOTE: We don't want to just "git show {$revision}:{$path}" since if the
// path was a directory at the given revision we'll get a list of its files
// and treat it as though it as a file containing a list of other files,
// which is silly.
if (!strlen($path)) {
// No filename, so there's no content (Probably new/deleted file).
return null;
}
list($stdout) = $this->execxLocal(
'ls-tree %s -- %s',
$revision,
$path);
$info = $this->parseGitTree($stdout);
if (empty($info[$path])) {
// No such path, or the path is a directory and we executed 'ls-tree dir/'
// and got a list of its contents back.
return null;
}
if ($info[$path]['type'] != 'blob') {
// Path is or was a directory, not a file.
return null;
}
list($stdout) = $this->execxLocal(
'cat-file blob %s',
$info[$path]['ref']);
return $stdout;
}
/**
* Returns names of all the branches in the current repository.
*
* @return list<dict<string, string>> Dictionary of branch information.
*/
public function getAllBranches() {
$field_list = array(
'%(refname)',
'%(objectname)',
'%(committerdate:raw)',
'%(tree)',
'%(subject)',
'%(subject)%0a%0a%(body)',
'%02',
);
list($stdout) = $this->execxLocal(
'for-each-ref --format=%s -- refs/heads',
implode('%01', $field_list));
$current = $this->getBranchName();
$result = array();
$lines = explode("\2", $stdout);
foreach ($lines as $line) {
$line = trim($line);
if (!strlen($line)) {
continue;
}
$fields = explode("\1", $line, 6);
list($ref, $hash, $epoch, $tree, $desc, $text) = $fields;
$branch = $this->getBranchNameFromRef($ref);
if ($branch !== null) {
$result[] = array(
'current' => ($branch === $current),
'name' => $branch,
'ref' => $ref,
'hash' => $hash,
'tree' => $tree,
'epoch' => (int)$epoch,
'desc' => $desc,
'text' => $text,
);
}
}
return $result;
}
public function getAllBranchRefs() {
$branches = $this->getAllBranches();
$refs = array();
foreach ($branches as $branch) {
$commit_ref = $this->newCommitRef()
->setCommitHash($branch['hash'])
->setTreeHash($branch['tree'])
->setCommitEpoch($branch['epoch'])
->attachMessage($branch['text']);
$refs[] = $this->newBranchRef()
->setBranchName($branch['name'])
->setRefName($branch['ref'])
->setIsCurrentBranch($branch['current'])
->attachCommitRef($commit_ref);
}
return $refs;
}
public function getBaseCommitRef() {
$base_commit = $this->getBaseCommit();
if ($base_commit === self::GIT_MAGIC_ROOT_COMMIT) {
return null;
}
$base_message = $this->getCommitMessage($base_commit);
// TODO: We should also pull the tree hash.
return $this->newCommitRef()
->setCommitHash($base_commit)
->attachMessage($base_message);
}
public function getWorkingCopyRevision() {
list($stdout) = $this->execxLocal('rev-parse HEAD');
return rtrim($stdout, "\n");
}
public function isHistoryDefaultImmutable() {
return false;
}
public function supportsAmend() {
return true;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function hasLocalCommit($commit) {
try {
if (!$this->getCanonicalRevisionName($commit)) {
return false;
}
} catch (CommandException $exception) {
return false;
}
return true;
}
public function getAllLocalChanges() {
$diff = $this->getFullGitDiff($this->getBaseCommit());
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s', or '%s', or by printing and faxing it).",
'git push',
'git svn dcommit');
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log -n1 --format=%C %s --',
'%s%n%n%b',
$commit);
return $message;
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getGitCommitLog();
if (!strlen($messages)) {
return array();
}
$parser = new ArcanistDiffParser();
$messages = $parser->parseDiff($messages);
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$message->getMetadata('message'));
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $message->getCommitHash();
}
}
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'] = pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
return $results;
}
// If we didn't succeed, try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('gtcm', $commit['commit']);
$hashes[] = array('gttr', $commit['tree']);
}
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $result) {
$results[$key]['why'] = pht(
'A git commit or tree hash in the commit range is already attached '.
'to the Differential revision.');
}
return $results;
}
public function updateWorkingCopy() {
$this->execxLocal('pull');
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
return pht('(The Empty Tree)');
}
list($summary) = $this->execxLocal(
'log -n 1 --format=%C %s',
'%s',
$commit);
return trim($summary);
}
public function isGitSubversionRepo() {
return Filesystem::pathExists($this->getPath('.git/svn'));
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
switch ($type) {
case 'git':
$matches = null;
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified by ".
"'%s' in your %s 'base' configuration.",
$matches[1],
$rule,
$source));
return trim($merge_base);
}
} else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base %s HEAD',
$matches[1]);
if ($err) {
return null;
}
$merge_base = trim($merge_base);
list($commits) = $this->execxLocal(
'log --format=%C %s..HEAD --',
'%H',
$merge_base);
$commits = array_filter(explode("\n", $commits));
if (!$commits) {
return null;
}
$commits[] = $merge_base;
$head_branch_count = null;
$all_branch_names = ipull($this->getAllBranches(), 'name');
foreach ($commits as $commit) {
// Ideally, we would use something like "for-each-ref --contains"
// to get a filtered list of branches ready for script consumption.
// Instead, try to get predictable output from "branch --contains".
$flags = array();
$flags[] = '--no-color';
// NOTE: The "--no-column" flag was introduced in Git 1.7.11, so
// don't pass it if we're running an older version. See T9953.
$version = $this->getGitVersion();
if (version_compare($version, '1.7.11', '>=')) {
$flags[] = '--no-column';
}
list($branches) = $this->execxLocal(
'branch %Ls --contains %s',
$flags,
$commit);
$branches = array_filter(explode("\n", $branches));
// Filter the list, removing the "current" marker (*) and ignoring
// anything other than known branch names (mainly, any possible
// "detached HEAD" or "no branch" line).
foreach ($branches as $key => $branch) {
$branch = trim($branch, ' *');
if (in_array($branch, $all_branch_names)) {
$branches[$key] = $branch;
} else {
unset($branches[$key]);
}
}
if ($head_branch_count === null) {
// If this is the first commit, it's HEAD. Count how many
// branches it is on; we want to include commits on the same
// number of branches. This covers a case where this branch
// has sub-branches and we're running "arc diff" here again
// for whatever reason.
$head_branch_count = count($branches);
} else if (count($branches) > $head_branch_count) {
$branches = implode(', ', $branches);
$this->setBaseCommitExplanation(
pht(
"it is the first commit between '%s' (the merge-base of ".
"'%s' and HEAD) which is also contained by another branch ".
"(%s).",
$merge_base,
$matches[1],
$branches));
return $commit;
}
}
} else {
list($err) = $this->execManualLocal(
'cat-file -t %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return self::GIT_MAGIC_ROOT_COMMIT;
case 'amended':
$text = $this->getCommitMessage('HEAD');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"HEAD has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
break;
case 'upstream':
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$upstream = rtrim($upstream);
list($upstream_merge_base) = $this->execxLocal(
'merge-base %s HEAD',
$upstream);
$upstream_merge_base = rtrim($upstream_merge_base);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the upstream of the current branch ".
"and HEAD, and matched the rule '%s' in your %s ".
"'base' configuration.",
$rule,
$source));
return $upstream_merge_base;
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
default:
return null;
}
return null;
}
public function canStashChanges() {
return true;
}
public function stashChanges() {
$this->execxLocal('stash');
$this->reloadWorkingCopy();
}
public function unstashChanges() {
$this->execxLocal('stash pop');
}
protected function didReloadCommitRange() {
// After an amend, the symbolic head may resolve to a different commit.
$this->resolvedHeadCommit = null;
}
/**
* Follow the chain of tracking branches upstream until we reach a remote
* or cycle locally.
*
* @param string Ref to start from.
* @return ArcanistGitUpstreamPath Path to an upstream.
*/
public function getPathToUpstream($start) {
$cursor = $start;
$path = new ArcanistGitUpstreamPath();
while (true) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --symbolic-full-name %s@{upstream}',
$cursor);
if ($err) {
// We ended up somewhere with no tracking branch, so we're done.
break;
}
$upstream = trim($upstream);
if (preg_match('(^refs/heads/)', $upstream)) {
$upstream = preg_replace('(^refs/heads/)', '', $upstream);
$is_cycle = $path->getUpstream($upstream);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_LOCAL,
'name' => $upstream,
'cycle' => $is_cycle,
));
if ($is_cycle) {
// We ran into a local cycle, so we're done.
break;
}
// We found another local branch, so follow that one upriver.
$cursor = $upstream;
continue;
}
if (preg_match('(^refs/remotes/)', $upstream)) {
$upstream = preg_replace('(^refs/remotes/)', '', $upstream);
list($remote, $branch) = explode('/', $upstream, 2);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_REMOTE,
'name' => $branch,
'remote' => $remote,
));
// We found a remote, so we're done.
break;
}
throw new Exception(
pht(
'Got unrecognized upstream format ("%s") from Git, expected '.
'"refs/heads/..." or "refs/remotes/...".',
$upstream));
}
return $path;
}
public function isPerforceRemote($remote_name) {
// See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs
// under "refs/remotes/", but does not define a real remote named "p4".
// We treat this remote as though it were a real remote during "arc land",
// but it does not respond to commands like "git remote show p4", so we
// need to handle it specially.
if ($remote_name !== 'p4') {
return false;
}
$remote_dir = $this->getMetadataPath().'/refs/remotes/p4';
if (!Filesystem::pathExists($remote_dir)) {
return false;
}
return true;
}
public function isPushableRemote($remote_name) {
$uri = $this->getGitRemotePushURI($remote_name);
return ($uri !== null);
}
public function isFetchableRemote($remote_name) {
$uri = $this->getGitRemoteFetchURI($remote_name);
return ($uri !== null);
}
private function getGitRemoteFetchURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = false);
}
private function getGitRemotePushURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = true);
}
private function getGitRemoteURI($remote_name, $for_push) {
$remote_uri = $this->loadGitRemoteURI($remote_name, $for_push);
if ($remote_uri !== null) {
$remote_uri = rtrim($remote_uri);
if (!strlen($remote_uri)) {
$remote_uri = null;
}
}
return $remote_uri;
}
private function loadGitRemoteURI($remote_name, $for_push) {
// Try to identify the best URI for a given remote. This is complicated
// because remotes may have different "push" and "fetch" URIs, may
// rewrite URIs with "insteadOf" configuration, and different versions
// of Git support different URI resolution commands.
// Remotes may also have more than one URI of a given type, but we ignore
// those cases here.
// Start with "git remote get-url [--push]". This is the simplest and
// most accurate command, but was introduced most recently in Git's
// history.
$argv = array();
if ($for_push) {
$argv[] = '--push';
}
list($err, $stdout) = $this->execManualLocal(
'remote get-url %Ls -- %s',
$argv,
$remote_name);
if (!$err) {
return $stdout;
}
// See T13481. If "git remote get-url [--push]" failed, it might be because
// the remote does not exist, but it might also be because the version of
// Git is too old to support "git remote get-url", which was introduced
// in Git 2.7 (circa late 2015).
$git_version = $this->getGitVersion();
if (version_compare($git_version, '2.7', '>=')) {
// This version of Git should support "git remote get-url --push", but
// the command failed, so conclude this is not a valid remote and thus
// there is no remote URI.
return null;
}
// If we arrive here, we're in a version of Git which is too old to
// support "git remote get-url [--push]". We're going to fall back to
// older and less accurate mechanisms for figuring out the remote URI.
// The first mechanism we try is "git ls-remote --get-url". This exists
// in Git 1.7.5 or newer. It only gives us the fetch URI, so this result
// will be incorrect if a remote has different fetch and push URIs.
// However, this is very rare, and this result is almost always correct.
// Note that some old versions of Git do not parse "--" in this command
// properly. We omit it since it doesn't seem like there's anything
// dangerous an attacker can do even if they can choose a remote name to
// intentionally cause an argument misparse.
// This will cause the command to behave incorrectly for remotes with
// names which are also valid flags, like "--quiet".
list($err, $stdout) = $this->execManualLocal(
'ls-remote --get-url %s',
$remote_name);
if (!$err) {
// The "git ls-remote --get-url" command just echoes the remote name
// (like "origin") if no remote URI is found. Treat this like a failure.
$output_is_input = (rtrim($stdout) === $remote_name);
if (!$output_is_input) {
return $stdout;
}
}
if (version_compare($git_version, '1.7.5', '>=')) {
// This version of Git should support "git ls-remote --get-url", but
// the command failed (or echoed the input), so conclude the remote
// really does not exist.
return null;
}
// Fall back to the very old "git config -- remote.origin.url" command.
// This does not give us push URLs and does not resolve "insteadOf"
// aliases, but still works in the simplest (and most common) cases.
list($err, $stdout) = $this->execManualLocal(
'config -- %s',
sprintf('remote.%s.url', $remote_name));
if (!$err) {
return $stdout;
}
return null;
}
protected function newCurrentCommitSymbol() {
return 'HEAD';
}
public function isGitLFSWorkingCopy() {
// We're going to run:
//
// $ git ls-files -z -- ':(attr:filter=lfs)'
//
// ...and exit as soon as it generates any field terminated with a "\0".
//
// If this command generates any such output, that means this working copy
// contains at least one LFS file, so it's an LFS working copy. If it
// exits with no error and no output, this is not an LFS working copy.
//
// If it exits with an error, we're in trouble.
$future = $this->buildLocalFuture(
array(
'ls-files -z -- %s',
':(attr:filter=lfs)',
));
$lfs_list = id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
try {
foreach ($lfs_list as $lfs_file) {
// We have our answer, so we can throw the subprocess away.
$future->resolveKill();
return true;
}
return false;
} catch (CommandException $ex) {
// This is probably an older version of Git. Continue below.
}
// In older versions of Git, the first command will fail with an error
// ("Invalid pathspec magic..."). See PHI1718.
//
// Some other tests we could use include:
//
// (1) Look for ".gitattributes" at the repository root. This approach is
// a rough approximation because ".gitattributes" may be global or in a
// subdirectory. See D21190.
//
// (2) Use "git check-attr" and pipe a bunch of files into it, roughly
// like this:
//
// $ git ls-files -z -- | git check-attr --stdin -z filter --
//
// However, the best version of this check I could come up with is fairly
// slow in even moderately large repositories (~200ms in a repository with
// 10K paths). See D21190.
//
// (3) Use "git lfs ls-files". This is even worse than piping "ls-files"
// to "check-attr" in PHP (~600ms in a repository with 10K paths).
//
// (4) Give up and just assume the repository isn't LFS. This is the
// current behavior.
return false;
}
protected function newLandEngine() {
return new ArcanistGitLandEngine();
}
public function newLocalState() {
return id(new ArcanistGitLocalState())
->setRepositoryAPI($this);
}
public function readRawCommit($hash) {
list($stdout) = $this->execxLocal(
'cat-file commit -- %s',
$hash);
return ArcanistGitRawCommit::newFromRawBlob($stdout);
}
public function writeRawCommit(ArcanistGitRawCommit $commit) {
$blob = $commit->getRawBlob();
$future = $this->execFutureLocal('hash-object -t commit --stdin -w');
$future->write($blob);
list($stdout) = $future->resolvex();
return trim($stdout);
}
}
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
index a457cbcf..182a52b2 100644
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1,1226 +1,1224 @@
<?php
/**
* Interfaces with the Mercurial working copies.
*/
final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
private $branch;
private $localCommitInfo;
private $rawDiffCache = array();
private $supportsRebase;
private $supportsPhases;
private $featureResults = array();
private $featureFutures = array();
protected function buildLocalFuture(array $argv) {
$env = $this->getMercurialEnvironmentVariables();
$argv[0] = 'hg '.$argv[0];
$future = newv('ExecFuture', $argv)
->setEnv($env)
->setCWD($this->getPath());
return $future;
}
- public function execPassthru($pattern /* , ... */) {
+ public function newPassthru($pattern /* , ... */) {
$args = func_get_args();
$env = $this->getMercurialEnvironmentVariables();
$args[0] = 'hg '.$args[0];
- $passthru = newv('PhutilExecPassthru', $args)
+ return newv('PhutilExecPassthru', $args)
->setEnv($env)
->setCWD($this->getPath());
-
- return $passthru->resolve();
}
public function getSourceControlSystemName() {
return 'hg';
}
public function getMetadataPath() {
return $this->getPath('.hg');
}
public function getSourceControlBaseRevision() {
return $this->getCanonicalRevisionName($this->getBaseCommit());
}
public function getCanonicalRevisionName($string) {
$match = null;
if ($this->isHgSubversionRepo() &&
preg_match('/@([0-9]+)$/', $string, $match)) {
$string = hgsprintf('svnrev(%s)', $match[1]);
}
list($stdout) = $this->execxLocal(
'log -l 1 --template %s -r %s --',
'{node}',
$string);
return $stdout;
}
public function getHashFromFromSVNRevisionNumber($revision_id) {
$matches = array();
$string = hgsprintf('svnrev(%s)', $revision_id);
list($stdout) = $this->execxLocal(
'log -l 1 --template %s -r %s --',
'{node}',
$string);
if (!$stdout) {
throw new ArcanistUsageException(
pht('Cannot find the HG equivalent of %s given.', $revision_id));
}
return $stdout;
}
public function getSVNRevisionNumberFromHash($hash) {
$matches = array();
list($stdout) = $this->execxLocal(
'log -r %s --template {svnrev}', $hash);
if (!$stdout) {
throw new ArcanistUsageException(
pht('Cannot find the SVN equivalent of %s given.', $hash));
}
return $stdout;
}
public function getSourceControlPath() {
return '/';
}
public function getBranchName() {
if (!$this->branch) {
list($stdout) = $this->execxLocal('branch');
$this->branch = trim($stdout);
}
return $this->branch;
}
protected function didReloadCommitRange() {
$this->localCommitInfo = null;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%s,.)', $symbolic_commit));
} catch (Exception $ex) {
// Try it as a revset instead of a commit id
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%R,.)', $symbolic_commit));
} catch (Exception $ex) {
throw new ArcanistUsageException(
pht(
"Commit '%s' is not a valid Mercurial commit identifier.",
$symbolic_commit));
}
}
$this->setBaseCommitExplanation(
pht(
'it is the greatest common ancestor of the working directory '.
'and the commit you specified explicitly.'));
return $commit;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
// Mercurial 2.1 and up have phases which indicate if something is
// published or not. To find which revs are outgoing, it's much
// faster to check the phase instead of actually checking the server.
if ($this->supportsPhases()) {
list($err, $stdout) = $this->execManualLocal(
'log --branch %s -r %s --style default',
$this->getBranchName(),
'draft()');
} else {
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(
pht(
'you have no outgoing commits, so arc assumes you intend to submit '.
'uncommitted changes in the working copy.'));
return $this->getWorkingCopyRevision();
}
$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(
pht('this is a new repository (all changes are outgoing).'));
} else {
$this->setBaseCommitExplanation(
pht(
'it is the first commit reachable from the working copy state '.
'which is not outgoing.'));
}
return $against;
}
public function getLocalCommitInformation() {
if ($this->localCommitInfo === null) {
$base_commit = $this->getBaseCommit();
list($info) = $this->execxLocal(
'log --template %s --rev %s --branch %s --',
"{node}\1{rev}\1{author}\1".
"{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$logs = array_filter(explode("\2", $info));
$last_node = null;
$futures = array();
$commits = array();
foreach ($logs as $log) {
list($node, $rev, $full_author, $date, $branch, $tag,
$parents, $desc) = explode("\1", $log, 9);
list($author, $author_email) = $this->parseFullAuthor($full_author);
// 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 %s --rev %s',
'{node}\n',
$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,
'authorEmail' => $author_email,
);
$last_node = $node;
}
$futures = id(new FutureIterator($futures))
->limit(4);
foreach ($futures 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->getBaseCommit(),
$path);
$lines = phutil_split_lines($stdout, $retain_line_endings = true);
$blame = array();
foreach ($lines as $line) {
if (!strlen($line)) {
continue;
}
$matches = null;
$ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches);
if (!$ok) {
throw new Exception(
pht(
'Unable to parse Mercurial blame line: %s',
$line));
}
$revision = $matches[2];
$author = trim($matches[1]);
$blame[] = array($author, $revision);
}
return $blame;
}
protected function buildUncommittedStatus() {
list($stdout) = $this->execxLocal('status');
$results = new PhutilArrayWithDefaultValue();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $mask) {
if (!($mask & parent::FLAG_UNTRACKED)) {
// Mark tracked files as uncommitted.
$mask |= self::FLAG_UNCOMMITTED;
}
$results[$path] |= $mask;
}
return $results->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout) = $this->execxLocal(
'status --rev %s --rev tip',
$this->getBaseCommit());
$results = new PhutilArrayWithDefaultValue();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $mask) {
$results[$path] |= $mask;
}
return $results->toArray();
}
protected function didReloadWorkingCopy() {
// Diffs are against ".", so we need to drop the cache if we change the
// working copy.
$this->rawDiffCache = array();
$this->branch = null;
}
private function getDiffOptions() {
$options = array(
'--git',
'-U'.$this->getDiffLinesOfContext(),
);
return implode(' ', $options);
}
public function getRawDiffText($path) {
$options = $this->getDiffOptions();
$range = $this->getBaseCommit();
$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->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision(
$path,
$this->getWorkingCopyRevision());
}
public function getBulkOriginalFileData($paths) {
return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit());
}
public function getBulkCurrentFileData($paths) {
return $this->getBulkFileDataAtRevision(
$paths,
$this->getWorkingCopyRevision());
}
private function getBulkFileDataAtRevision($paths, $revision) {
// Calling 'hg cat' on each file individually is slow (1 second per file
// on a large repo) because mercurial has to decompress and parse the
// entire manifest every time. Do it in one large batch instead.
// hg cat will write the file data to files in a temp directory
$tmpdir = Filesystem::createTemporaryDirectory();
// Mercurial doesn't create the directories for us :(
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
Filesystem::createDirectory(dirname($tmppath), 0755, true);
}
// NOTE: The "%s%%p" construction passes a literal "%p" to Mercurial,
// which is a formatting directive for a repo-relative filepath. The
// particulars of the construction avoid Windows escaping issues. See
// PHI904.
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s --output %s%%p -- %Ls',
$revision,
$tmpdir.DIRECTORY_SEPARATOR,
$paths);
$filedata = array();
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
if (Filesystem::pathExists($tmppath)) {
$filedata[$path] = Filesystem::readFile($tmppath);
}
}
Filesystem::remove($tmpdir);
return $filedata;
}
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 supportsRebase() {
if ($this->supportsRebase === null) {
list($err) = $this->execManualLocal('help rebase');
$this->supportsRebase = $err === 0;
}
return $this->supportsRebase;
}
public function supportsPhases() {
if ($this->supportsPhases === null) {
list($err) = $this->execManualLocal('help phase');
$this->supportsPhases = $err === 0;
}
return $this->supportsPhases;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function getAllBranches() {
// TODO: This is wrong, and returns bookmarks.
list($branch_info) = $this->execxLocal('bookmarks');
if (trim($branch_info) == 'no bookmarks set') {
return array();
}
$matches = null;
preg_match_all(
'/^\s*(\*?)\s*(.+)\s(\S+)$/m',
$branch_info,
$matches,
PREG_SET_ORDER);
$return = array();
foreach ($matches as $match) {
list(, $current, $name, $hash) = $match;
list($id, $hash) = explode(':', $hash);
$return[] = array(
'current' => (bool)$current,
'name' => rtrim($name),
'hash' => $hash,
);
}
return $return;
}
public function getAllBranchRefs() {
$branches = $this->getAllBranches();
$refs = array();
foreach ($branches as $branch) {
$commit_ref = $this->newCommitRef()
->setCommitHash($branch['hash']);
$refs[] = $this->newBranchRef()
->setBranchName($branch['name'])
->setIsCurrentBranch($branch['current'])
->attachCommitRef($commit_ref);
}
return $refs;
}
public function getBaseCommitRef() {
$base_commit = $this->getBaseCommit();
if ($base_commit === 'null') {
return null;
}
$base_message = $this->getCommitMessage($base_commit);
return $this->newCommitRef()
->setCommitHash($base_commit)
->attachMessage($base_message);
}
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 getAllLocalChanges() {
$diff = $this->getFullMercurialDiff();
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s' or by printing and faxing it).",
'hg push');
}
public function getCommitMessageLog() {
$base_commit = $this->getBaseCommit();
list($stdout) = $this->execxLocal(
'log --template %s --rev %s --branch %s --',
"{node}\1{desc}\2",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$map = array();
$logs = explode("\2", trim($stdout));
foreach (array_filter($logs) as $log) {
list($node, $desc) = explode("\1", $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'] =
pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
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'] = pht(
'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');
$this->reloadWorkingCopy();
}
private function getMercurialConfig($key, $default = null) {
list($stdout) = $this->execxLocal('showconfig %s', $key);
if ($stdout == '') {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
$full_author = $this->getMercurialConfig('ui.username');
list($author, $author_email) = $this->parseFullAuthor($full_author);
return $author;
}
/**
* Parse the Mercurial author field.
*
* Not everyone enters their email address as a part of the username
* field. Try to make it work when it's obvious.
*
* @param string $full_author
* @return array
*/
protected function parseFullAuthor($full_author) {
if (strpos($full_author, '@') === false) {
$author = $full_author;
$author_email = null;
} else {
$email = new PhutilEmailAddress($full_author);
$author = $email->getDisplayName();
$author_email = $email->getAddress();
}
return array($author, $author_email);
}
public function addToCommit(array $paths) {
$this->execxLocal(
'addremove -- %Ls',
$paths);
$this->reloadWorkingCopy();
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal('commit -l %s', $tmp_file);
$this->reloadWorkingCopy();
}
public function amendCommit($message = null) {
if ($message === null) {
$message = $this->getCommitMessage('.');
}
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
try {
$this->execxLocal(
'commit --amend -l %s',
$tmp_file);
} catch (CommandException $ex) {
if (preg_match('/nothing changed/', $ex->getStdout())) {
// NOTE: Mercurial considers it an error to make a no-op amend. Although
// we generally defer to the underlying VCS to dictate behavior, this
// one seems a little goofy, and we use amend as part of various
// workflows under the assumption that no-op amends are fine. If this
// amend failed because it's a no-op, just continue.
} else {
throw $ex;
}
}
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == 'null') {
return pht('(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);
// NOTE: This function MUST return node hashes or symbolic commits (like
// branch names or the word "tip"), not revsets. This includes ".^" and
// similar, which a revset, not a symbolic commit identifier. If you return
// a revset it will be escaped later and looked up literally.
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(
pht(
"it is the greatest common ancestor of '%s' and %s, as ".
"specified by '%s' in your %s 'base' configuration.",
$matches[1],
'.',
$rule,
$source));
return trim($merge_base);
}
} else {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
hgsprintf('%s', $name));
if ($err) {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
$name);
}
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return trim($commit);
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'null';
case 'outgoing':
list($err, $outgoing_base) = $this->execManualLocal(
'log --template={node} --rev %s',
'limit(reverse(ancestors(.) - outgoing()), 1)');
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the first ancestor of the working copy that is not ".
"outgoing, and it matched the rule %s in your %s ".
"'base' configuration.",
$rule,
$source));
return trim($outgoing_base);
}
case 'amended':
$text = $this->getCommitMessage('.');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"'%s' has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
'.'.
$rule,
$source));
// NOTE: This should be safe because Mercurial doesn't support
// amend until 2.2.
return $this->getCanonicalRevisionName('.^');
}
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(
pht(
"it is the first ancestor of %s that either has a bookmark, ".
"or is already in the remote and it matched the rule %s in ".
"your %s 'base' configuration",
'.',
$rule,
$source));
return trim($bookmark_base);
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return $this->getCanonicalRevisionName('.^');
default:
if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) {
list($results) = $this->execxLocal(
'log --template %s --rev %s',
"{node}\1{desc}\2",
sprintf('ancestor(.,%s)::.^', $matches[1]));
$results = array_reverse(explode("\2", trim($results)));
foreach ($results as $result) {
if (empty($result)) {
continue;
}
list($node, $desc) = explode("\1", $result, 2);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$desc);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"it is the first ancestor of %s that has a diff and is ".
"the gca or a descendant of the gca with '%s', ".
"specified by '%s' in your %s 'base' configuration.",
'.',
$matches[1],
$rule,
$source));
return $node;
}
}
}
break;
}
break;
default:
return null;
}
return null;
}
public function isHgSubversionRepo() {
return file_exists($this->getPath('.hg/svn/rev_map'));
}
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() {
list($stdout) = $this->execxLocal('--debug branches');
$lines = ArcanistMercurialParser::parseMercurialBranches($stdout);
$branches = array();
foreach ($lines as $name => $spec) {
$branches[] = array(
'name' => $name,
'revision' => $spec['rev'],
);
}
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) {
// example line: * mybook 2:6b274d49be97
list($name, $revision) = $this->splitBranchOrBookmarkLine($line);
$is_active = false;
if ('*' === $name[0]) {
$is_active = true;
$name = substr($name, 2);
}
$bookmarks[] = array(
'is_active' => $is_active,
'name' => $name,
'revision' => $revision,
);
}
}
return $bookmarks;
}
public function getBookmarkCommitHash($name) {
// TODO: Cache this.
$bookmarks = $this->getBookmarks($name);
$bookmarks = ipull($bookmarks, null, 'name');
foreach ($bookmarks as $bookmark) {
if ($bookmark['name'] === $name) {
return $bookmark['revision'];
}
}
throw new Exception(pht('No bookmark "%s".', $name));
}
public function getBranchCommitHash($name) {
// TODO: Cache this.
// TODO: This won't work when there are multiple branch heads with the
// same name.
$branches = $this->getBranches($name);
$heads = array();
foreach ($branches as $branch) {
if ($branch['name'] === $name) {
$heads[] = $branch;
}
}
if (count($heads) === 1) {
return idx(head($heads), 'revision');
}
if (!$heads) {
throw new Exception(pht('No branch "%s".', $name));
}
throw new Exception(pht('Too many branch heads for "%s".', $name));
}
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));
}
public function getRemoteURI() {
list($stdout) = $this->execxLocal('paths default');
$stdout = trim($stdout);
if (strlen($stdout)) {
return $stdout;
}
return null;
}
private function getMercurialEnvironmentVariables() {
$env = array();
// 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.
$env['HGPLAIN'] = 1;
return $env;
}
protected function newLandEngine() {
return new ArcanistMercurialLandEngine();
}
public function newLocalState() {
return id(new ArcanistMercurialLocalState())
->setRepositoryAPI($this);
}
public function willTestMercurialFeature($feature) {
$this->executeMercurialFeatureTest($feature, false);
return $this;
}
public function getMercurialFeature($feature) {
return $this->executeMercurialFeatureTest($feature, true);
}
private function executeMercurialFeatureTest($feature, $resolve) {
if (array_key_exists($feature, $this->featureResults)) {
return $this->featureResults[$feature];
}
if (!array_key_exists($feature, $this->featureFutures)) {
$future = $this->newMercurialFeatureFuture($feature);
$future->start();
$this->featureFutures[$feature] = $future;
}
if (!$resolve) {
return;
}
$future = $this->featureFutures[$feature];
$result = $this->resolveMercurialFeatureFuture($feature, $future);
$this->featureResults[$feature] = $result;
return $result;
}
private function newMercurialFeatureFuture($feature) {
switch ($feature) {
case 'shelve':
return $this->execFutureLocal(
'--config extensions.shelve= shelve --help');
default:
throw new Exception(
pht(
'Unknown Mercurial feature "%s".',
$feature));
}
}
private function resolveMercurialFeatureFuture($feature, $future) {
// By default, assume the feature is a simple capability test and the
// capability is present if the feature resolves without an error.
list($err) = $future->resolve();
return !$err;
}
}
diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php
index 43026c60..d5e18ada 100644
--- a/src/repository/api/ArcanistRepositoryAPI.php
+++ b/src/repository/api/ArcanistRepositoryAPI.php
@@ -1,752 +1,766 @@
<?php
/**
* Interfaces with the VCS in the working copy.
*
* @task status Path Status
*/
abstract class ArcanistRepositoryAPI extends Phobject {
const FLAG_MODIFIED = 1;
const FLAG_ADDED = 2;
const FLAG_DELETED = 4;
const FLAG_UNTRACKED = 8;
const FLAG_CONFLICT = 16;
const FLAG_MISSING = 32;
const FLAG_UNSTAGED = 64;
const FLAG_UNCOMMITTED = 128;
// Occurs in SVN when you have uncommitted changes to a modified external,
// or in Git when you have uncommitted or untracked changes in a submodule.
const FLAG_EXTERNALS = 256;
// Occurs in SVN when you replace a file with a directory without telling
// SVN about it.
const FLAG_OBSTRUCTED = 512;
// Occurs in SVN when an update was interrupted or failed, e.g. you ^C'd it.
const FLAG_INCOMPLETE = 1024;
protected $path;
protected $diffLinesOfContext = 0x7FFF;
private $baseCommitExplanation = '???';
private $configurationManager;
private $baseCommitArgumentRules;
private $uncommittedStatusCache;
private $commitRangeStatusCache;
private $symbolicBaseCommit;
private $resolvedBaseCommit;
private $runtime;
private $currentWorkingCopyStateRef = false;
private $currentCommitRef = false;
abstract public function getSourceControlSystemName();
public function getDiffLinesOfContext() {
return $this->diffLinesOfContext;
}
public function setDiffLinesOfContext($lines) {
$this->diffLinesOfContext = $lines;
return $this;
}
public function getWorkingCopyIdentity() {
return $this->configurationManager->getWorkingCopyIdentity();
}
public function getConfigurationManager() {
return $this->configurationManager;
}
public static function newAPIFromConfigurationManager(
ArcanistConfigurationManager $configuration_manager) {
$working_copy = $configuration_manager->getWorkingCopyIdentity();
if (!$working_copy) {
throw new Exception(
pht(
'Trying to create a %s without a working copy!',
__CLASS__));
}
$root = $working_copy->getProjectRoot();
switch ($working_copy->getVCSType()) {
case 'svn':
$api = new ArcanistSubversionAPI($root);
break;
case 'hg':
$api = new ArcanistMercurialAPI($root);
break;
case 'git':
$api = new ArcanistGitAPI($root);
break;
default:
throw new Exception(
pht(
'The current working directory is not part of a working copy for '.
'a supported version control system (Git, Subversion or '.
'Mercurial).'));
}
$api->configurationManager = $configuration_manager;
return $api;
}
public function __construct($path) {
$this->path = $path;
}
public function getPath($to_file = null) {
if ($to_file !== null) {
return $this->path.DIRECTORY_SEPARATOR.
ltrim($to_file, DIRECTORY_SEPARATOR);
} else {
return $this->path.DIRECTORY_SEPARATOR;
}
}
/* -( Path Status )-------------------------------------------------------- */
abstract protected function buildUncommittedStatus();
abstract protected function buildCommitRangeStatus();
/**
* Get a list of uncommitted paths in the working copy that have been changed
* or are affected by other status effects, like conflicts or untracked
* files.
*
* Convenience methods @{method:getUntrackedChanges},
* @{method:getUnstagedChanges}, @{method:getUncommittedChanges},
* @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow
* simpler selection of paths in a specific state.
*
* This method returns a map of paths to bitmasks with status, using
* `FLAG_` constants. For example:
*
* array(
* 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED,
* );
*
* A file may be in several states. Not all states are possible with all
* version control systems.
*
* @return map<string, bitmask> Map of paths, see above.
* @task status
*/
final public function getUncommittedStatus() {
if ($this->uncommittedStatusCache === null) {
$status = $this->buildUncommittedStatus();
ksort($status);
$this->uncommittedStatusCache = $status;
}
return $this->uncommittedStatusCache;
}
/**
* @task status
*/
final public function getUntrackedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED);
}
/**
* @task status
*/
final public function getUnstagedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED);
}
/**
* @task status
*/
final public function getUncommittedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED);
}
/**
* @task status
*/
final public function getMergeConflicts() {
return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT);
}
/**
* @task status
*/
final public function getIncompleteChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE);
}
/**
* @task status
*/
final public function getMissingChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_MISSING);
}
/**
* @task status
*/
final public function getDirtyExternalChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_EXTERNALS);
}
/**
* @task status
*/
private function getUncommittedPathsWithMask($mask) {
$match = array();
foreach ($this->getUncommittedStatus() as $path => $flags) {
if ($flags & $mask) {
$match[] = $path;
}
}
return $match;
}
/**
* Get a list of paths affected by the commits in the current commit range.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getCommitRangeStatus() {
if ($this->commitRangeStatusCache === null) {
$status = $this->buildCommitRangeStatus();
ksort($status);
$this->commitRangeStatusCache = $status;
}
return $this->commitRangeStatusCache;
}
/**
* Get a list of paths affected by commits in the current commit range, or
* uncommitted changes in the working copy. See @{method:getUncommittedStatus}
* or @{method:getCommitRangeStatus} to retrieve smaller parts of the status.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getWorkingCopyStatus() {
$range_status = $this->getCommitRangeStatus();
$uncommitted_status = $this->getUncommittedStatus();
$result = new PhutilArrayWithDefaultValue($range_status);
foreach ($uncommitted_status as $path => $mask) {
$result[$path] |= $mask;
}
$result = $result->toArray();
ksort($result);
return $result;
}
/**
* Drops caches after changes to the working copy. By default, some queries
* against the working copy are cached. They
*
* @return this
* @task status
*/
final public function reloadWorkingCopy() {
$this->uncommittedStatusCache = null;
$this->commitRangeStatusCache = null;
$this->didReloadWorkingCopy();
$this->reloadCommitRange();
return $this;
}
/**
* Hook for implementations to dirty working copy caches after the working
* copy has been updated.
*
* @return void
* @task status
*/
protected function didReloadWorkingCopy() {
return;
}
/**
* Fetches the original file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkOriginalFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getOriginalFileData($path);
}
return $filedata;
}
/**
* Fetches the current file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkCurrentFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getCurrentFileData($path);
}
return $filedata;
}
/**
* @return Traversable
*/
abstract public function getAllFiles();
abstract public function getBlame($path);
abstract public function getRawDiffText($path);
abstract public function getOriginalFileData($path);
abstract public function getCurrentFileData($path);
abstract public function getLocalCommitInformation();
abstract public function getSourceControlBaseRevision();
abstract public function getCanonicalRevisionName($string);
abstract public function getBranchName();
abstract public function getSourceControlPath();
abstract public function isHistoryDefaultImmutable();
abstract public function supportsAmend();
abstract public function getWorkingCopyRevision();
abstract public function updateWorkingCopy();
abstract public function getMetadataPath();
abstract public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query);
abstract public function getRemoteURI();
public function getChangedFiles($since_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAuthor() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function addToCommit(array $paths) {
throw new ArcanistCapabilityNotSupportedException($this);
}
abstract public function supportsLocalCommits();
public function doCommit($message) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function amendCommit($message = null) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAllBranches() {
// TODO: Implement for Mercurial/SVN and make abstract.
return array();
}
public function getAllBranchRefs() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getBaseCommitRef() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function hasLocalCommit($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitMessage($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitSummary($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAllLocalChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getFinalizedRevisionMessage() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function execxLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolvex();
}
public function execManualLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolve();
}
public function execFutureLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args);
}
abstract protected function buildLocalFuture(array $argv);
public function canStashChanges() {
return false;
}
public function stashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function unstashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
/* -( Scratch Files )------------------------------------------------------ */
/**
* Try to read a scratch file, if it exists and is readable.
*
* @param string Scratch file name.
* @return mixed String for file contents, or false for failure.
* @task scratch
*/
public function readScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
if (!Filesystem::pathExists($full_path)) {
return false;
}
try {
$result = Filesystem::readFile($full_path);
} catch (FilesystemException $ex) {
return false;
}
return $result;
}
/**
* Try to write a scratch file, if there's somewhere to put it and we can
* write there.
*
* @param string Scratch file name to write.
* @param string Data to write.
* @return bool True on success, false on failure.
* @task scratch
*/
public function writeScratchFile($path, $data) {
$dir = $this->getScratchFilePath('');
if (!$dir) {
return false;
}
if (!Filesystem::pathExists($dir)) {
try {
Filesystem::createDirectory($dir);
} catch (Exception $ex) {
return false;
}
}
try {
Filesystem::writeFile($this->getScratchFilePath($path), $data);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Try to remove a scratch file.
*
* @param string Scratch file name to remove.
* @return bool True if the file was removed successfully.
* @task scratch
*/
public function removeScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
try {
Filesystem::remove($full_path);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Get a human-readable description of the scratch file location.
*
* @param string Scratch file name.
* @return mixed String, or false on failure.
* @task scratch
*/
public function getReadableScratchFilePath($path) {
$full_path = $this->getScratchFilePath($path);
if ($full_path) {
return Filesystem::readablePath(
$full_path,
$this->getPath());
} else {
return false;
}
}
/**
* Get the path to a scratch file, if possible.
*
* @param string Scratch file name.
* @return mixed File path, or false on failure.
* @task scratch
*/
public function getScratchFilePath($path) {
$new_scratch_path = Filesystem::resolvePath(
'arc',
$this->getMetadataPath());
static $checked = false;
if (!$checked) {
$checked = true;
$old_scratch_path = $this->getPath('.arc');
// we only want to do the migration once
// unfortunately, people have checked in .arc directories which
// means that the old one may get recreated after we delete it
if (Filesystem::pathExists($old_scratch_path) &&
!Filesystem::pathExists($new_scratch_path)) {
Filesystem::createDirectory($new_scratch_path);
$existing_files = Filesystem::listDirectory($old_scratch_path, true);
foreach ($existing_files as $file) {
$new_path = Filesystem::resolvePath($file, $new_scratch_path);
$old_path = Filesystem::resolvePath($file, $old_scratch_path);
Filesystem::writeFile(
$new_path,
Filesystem::readFile($old_path));
}
Filesystem::remove($old_scratch_path);
}
}
return Filesystem::resolvePath($path, $new_scratch_path);
}
/* -( Base Commits )------------------------------------------------------- */
abstract public function supportsCommitRanges();
final public function setBaseCommit($symbolic_commit) {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
$this->symbolicBaseCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
public function setHeadCommit($symbolic_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function getBaseCommit() {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
if ($this->resolvedBaseCommit === null) {
$commit = $this->buildBaseCommit($this->symbolicBaseCommit);
$this->resolvedBaseCommit = $commit;
}
return $this->resolvedBaseCommit;
}
public function getHeadCommit() {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function reloadCommitRange() {
$this->resolvedBaseCommit = null;
$this->baseCommitExplanation = null;
$this->didReloadCommitRange();
return $this;
}
protected function didReloadCommitRange() {
return;
}
protected function buildBaseCommit($symbolic_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getBaseCommitExplanation() {
return $this->baseCommitExplanation;
}
public function setBaseCommitExplanation($explanation) {
$this->baseCommitExplanation = $explanation;
return $this;
}
public function resolveBaseCommitRule($rule, $source) {
return null;
}
public function setBaseCommitArgumentRules($base_commit_argument_rules) {
$this->baseCommitArgumentRules = $base_commit_argument_rules;
return $this;
}
public function getBaseCommitArgumentRules() {
return $this->baseCommitArgumentRules;
}
public function resolveBaseCommit() {
$base_commit_rules = array(
'runtime' => $this->getBaseCommitArgumentRules(),
'local' => '',
'project' => '',
'user' => '',
'system' => '',
);
$all_sources = $this->configurationManager->getConfigFromAllSources('base');
$base_commit_rules = $all_sources + $base_commit_rules;
$parser = new ArcanistBaseCommitParser($this);
$commit = $parser->resolveBaseCommit($base_commit_rules);
return $commit;
}
public function getRepositoryUUID() {
return null;
}
final public function newFuture($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)
->setResolveOnError(false);
}
+ public function newPassthru($pattern /* , ... */) {
+ throw new PhutilMethodNotImplementedException();
+ }
+
+ final public function execPassthru($pattern /* , ... */) {
+ $args = func_get_args();
+
+ $future = call_user_func_array(
+ array($this, 'newPassthru'),
+ $args);
+
+ return $future->resolve();
+ }
+
final public function setRuntime(ArcanistRuntime $runtime) {
$this->runtime = $runtime;
return $this;
}
final public function getRuntime() {
return $this->runtime;
}
final protected function getSymbolEngine() {
return $this->getRuntime()->getSymbolEngine();
}
final public function getCurrentWorkingCopyStateRef() {
if ($this->currentWorkingCopyStateRef === false) {
$ref = $this->newCurrentWorkingCopyStateRef();
$this->currentWorkingCopyStateRef = $ref;
}
return $this->currentWorkingCopyStateRef;
}
protected function newCurrentWorkingCopyStateRef() {
$commit_ref = $this->getCurrentCommitRef();
if (!$commit_ref) {
return null;
}
return id(new ArcanistWorkingCopyStateRef())
->setCommitRef($commit_ref);
}
final public function getCurrentCommitRef() {
if ($this->currentCommitRef === false) {
$this->currentCommitRef = $this->newCurrentCommitRef();
}
return $this->currentCommitRef;
}
protected function newCurrentCommitRef() {
$symbols = $this->getSymbolEngine();
$commit_symbol = $this->newCurrentCommitSymbol();
return $symbols->loadCommitForSymbol($commit_symbol);
}
protected function newCurrentCommitSymbol() {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function newCommitRef() {
return new ArcanistCommitRef();
}
final public function newBranchRef() {
return new ArcanistBranchRef();
}
final public function getLandEngine() {
$engine = $this->newLandEngine();
if ($engine) {
$engine->setRepositoryAPI($this);
}
return $engine;
}
protected function newLandEngine() {
return null;
}
}
diff --git a/src/toolset/command/ArcanistCommand.php b/src/toolset/command/ArcanistCommand.php
index 0eff69f9..1c5390ef 100644
--- a/src/toolset/command/ArcanistCommand.php
+++ b/src/toolset/command/ArcanistCommand.php
@@ -1,59 +1,71 @@
<?php
final class ArcanistCommand
extends Phobject {
private $logEngine;
private $executableFuture;
+ private $resolveOnError = false;
public function setExecutableFuture(PhutilExecutableFuture $future) {
$this->executableFuture = $future;
return $this;
}
public function getExecutableFuture() {
return $this->executableFuture;
}
public function setLogEngine(ArcanistLogEngine $log_engine) {
$this->logEngine = $log_engine;
return $this;
}
public function getLogEngine() {
return $this->logEngine;
}
+ public function setResolveOnError($resolve_on_error) {
+ $this->resolveOnError = $resolve_on_error;
+ return $this;
+ }
+
+ public function getResolveOnError() {
+ return $this->resolveOnError;
+ }
+
public function execute() {
$log = $this->getLogEngine();
$future = $this->getExecutableFuture();
$command = $future->getCommand();
$log->writeNewline();
$log->writeStatus(
' $ ',
tsprintf('**%s**', phutil_string_cast($command)));
$log->writeNewline();
$err = $future->resolve();
$log->writeNewline();
- if ($err) {
+ if ($err && !$this->getResolveOnError()) {
$log->writeError(
pht('ERROR'),
pht(
'Command exited with error code %d.',
$err));
throw new CommandException(
pht('Command exited with nonzero error code.'),
$command,
$err,
'',
'');
}
+
+ return $err;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Nov 28, 14:55 (17 h, 12 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1086267
Default Alt Text
(216 KB)

Event Timeline