Page MenuHomeSealhub

No OneTemporary

diff --git a/src/land/ArcanistLandCommitSet.php b/src/land/ArcanistLandCommitSet.php
index bdf34ce0..b58e2687 100644
--- a/src/land/ArcanistLandCommitSet.php
+++ b/src/land/ArcanistLandCommitSet.php
@@ -1,52 +1,72 @@
<?php
final class ArcanistLandCommitSet
extends Phobject {
private $revisionRef;
private $commits;
+ private $isPick;
public function setRevisionRef(ArcanistRevisionRef $revision_ref) {
$this->revisionRef = $revision_ref;
return $this;
}
public function getRevisionRef() {
return $this->revisionRef;
}
public function setCommits(array $commits) {
assert_instances_of($commits, 'ArcanistLandCommit');
$this->commits = $commits;
$revision_phid = $this->getRevisionRef()->getPHID();
foreach ($commits as $commit) {
$revision_ref = $commit->getExplicitRevisionRef();
if ($revision_ref) {
if ($revision_ref->getPHID() === $revision_phid) {
continue;
}
}
$commit->setIsImplicitCommit(true);
}
return $this;
}
public function getCommits() {
return $this->commits;
}
public function hasImplicitCommits() {
foreach ($this->commits as $commit) {
if ($commit->getIsImplicitCommit()) {
return true;
}
}
return false;
}
+ public function hasDirectSymbols() {
+ foreach ($this->commits as $commit) {
+ if ($commit->getDirectSymbols()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function setIsPick($is_pick) {
+ $this->isPick = $is_pick;
+ return $this;
+ }
+
+ public function getIsPick() {
+ return $this->isPick;
+ }
+
}
diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php
index db10bd8d..51655dcf 100644
--- a/src/land/engine/ArcanistGitLandEngine.php
+++ b/src/land/engine/ArcanistGitLandEngine.php
@@ -1,1526 +1,1548 @@
<?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('Cleaning up 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;
}
+ $min_commit = head($set->getCommits())->getHash();
$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));
+ // If we used "--pick" to select this commit, we want to rebase branches
+ // that descend from it onto its ancestor, not onto the landed change.
+
+ // For example, if the change sequence was "W", "X", "Y", "Z" and we
+ // landed "Y" onto "master" using "--pick", we want to rebase "Z" onto
+ // "X" (so "W" and "X", which it will often depend on, are still
+ // its ancestors), not onto the new "master".
+
+ if ($set->getIsPick()) {
+ $rebase_target = $min_commit.'^';
+ } else {
+ $rebase_target = $new_commit;
+ }
+
try {
$api->execxLocal(
'rebase --onto %s -- %s %s',
- $new_commit,
+ $rebase_target,
$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;
+ $api->execManualLocal('rebase --abort');
+ $api->execManualLocal('reset --hard HEAD --');
+
+ $log->writeWarning(
+ pht('REBASE CONFLICT'),
+ pht(
+ 'Branch "%s" does not rebase cleanly from "%s" onto '.
+ '"%s", skipping.',
+ $branch_name,
+ $this->getDisplayHash($old_commit),
+ $this->getDisplayHash($rebase_target)));
}
}
}
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 = $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();
$this->confirmLegacyStrategyConfiguration();
$is_empty = ($into_commit === null);
if ($is_empty) {
$empty_commit = ArcanistGitRawCommit::newEmptyCommit();
$into_commit = $api->writeRawCommit($empty_commit);
}
$commits = $set->getCommits();
$min_commit = head($commits);
$min_hash = $min_commit->getHash();
$max_commit = last($commits);
$max_hash = $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,
$max_hash);
$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($max_hash),
$this->getDisplayHash($into_commit)));
}
$log->writeStatus(
pht('MERGING'),
pht(
'%s %s',
$this->getDisplayHash($max_hash),
$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[] = '--';
$is_rebasing = false;
$is_merging = false;
try {
if ($this->isSquashStrategy() && !$is_empty) {
// If we're performing a squash merge, we're going to rebase the
// commit range first. We only want to merge the specific commits
// in the range, and merging too much can create conflicts.
$api->execxLocal('checkout %s --', $max_hash);
$is_rebasing = true;
$api->execxLocal(
'rebase --onto %s -- %s',
$into_commit,
$min_hash.'^');
$is_rebasing = false;
$merge_hash = $api->getCanonicalRevisionName('HEAD');
} else {
$merge_hash = $max_hash;
}
$api->execxLocal('checkout %s --', $into_commit);
$argv[] = $merge_hash;
$is_merging = true;
$api->execxLocal('merge %Ls', $argv);
$is_merging = false;
} catch (CommandException $ex) {
$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($max_hash),
$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($max_hash),
$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($max_hash),
$this->getDisplayHash($into_commit));
}
echo tsprintf(
"\n%!\n%W\n\n",
pht('MERGE CONFLICT'),
$message);
if ($this->getHasUnpushedChanges()) {
echo tsprintf(
"%?\n\n",
pht(
'Use "--incremental" to merge and push changes one by one.'));
}
if ($is_rebasing) {
$api->execManualLocal('rebase --abort');
}
if ($is_merging) {
$api->execManualLocal('merge --abort');
}
if ($is_merging || $is_rebasing) {
$api->execManualLocal('reset --hard HEAD --');
}
throw new PhutilArgumentUsageException(
pht('Encountered a merge conflict.'));
}
list($original_author, $original_date) = $this->getAuthorAndDate(
$max_hash);
$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($merge_hash));
}
$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 = $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 = $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();
$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;
}
private function confirmLegacyStrategyConfiguration() {
// TODO: See T13547. Remove this check in the future. This prevents users
// from accidentally executing a "squash" workflow under a configuration
// which would previously have executed a "merge" workflow.
// We're fine if we have an explicit "--strategy".
if ($this->getStrategyArgument() !== null) {
return;
}
// We're fine if we have an explicit "arc.land.strategy".
if ($this->getStrategyFromConfiguration() !== null) {
return;
}
// We're fine if "history.immutable" is not set to "true".
$source_list = $this->getWorkflow()->getConfigurationSourceList();
$config_list = $source_list->getStorageValueList('history.immutable');
if (!$config_list) {
return;
}
$config_value = (bool)last($config_list)->getValue();
if (!$config_value) {
return;
}
// We're in trouble: we would previously have selected "merge" and will
// now select "squash". Make sure the user knows what they're in for.
echo tsprintf(
"\n%!\n%W\n\n",
pht('MERGE STRATEGY IS AMBIGUOUS'),
pht(
'See <%s>. The default merge strategy under Git with '.
'"history.immutable" has changed from "merge" to "squash". Your '.
'configuration is ambiguous under this behavioral change. '.
'(Use "--strategy" or configure "arc.land.strategy" to bypass '.
'this check.)',
'https://secure.phabricator.com/T13547'));
throw new PhutilArgumentUsageException(
pht(
'Desired merge strategy is ambiguous, choose an explicit strategy.'));
}
}
diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php
index 3f03e499..14657d6d 100644
--- a/src/land/engine/ArcanistLandEngine.php
+++ b/src/land/engine/ArcanistLandEngine.php
@@ -1,1546 +1,1566 @@
<?php
abstract class ArcanistLandEngine
extends ArcanistWorkflowEngine {
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;
private $hasUnpushedChanges;
+ private $pickArgument;
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 setPickArgument($pick_argument) {
+ $this->pickArgument = $pick_argument;
+ return $this;
+ }
+
+ final public function getPickArgument() {
+ return $this->pickArgument;
+ }
+
final public function setIntoLocal($into_local) {
$this->intoLocal = $into_local;
return $this;
}
final public function getIntoLocal() {
return $this->intoLocal;
}
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;
}
private function setHasUnpushedChanges($unpushed) {
$this->hasUnpushedChanges = $unpushed;
return $this;
}
final protected function getHasUnpushedChanges() {
return $this->hasUnpushedChanges;
}
final protected function getOntoConfigurationKey() {
return 'arc.land.onto';
}
final protected function getOntoFromConfiguration() {
$config_key = $this->getOntoConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getOntoRemoteConfigurationKey() {
return 'arc.land.onto-remote';
}
final protected function getOntoRemoteFromConfiguration() {
$config_key = $this->getOntoRemoteConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getStrategyConfigurationKey() {
return 'arc.land.strategy';
}
final protected function getStrategyFromConfiguration() {
$config_key = $this->getStrategyConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
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);
$this->setHasUnpushedChanges(true);
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);
$this->setHasUnpushedChanges(false);
} 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);
+ $is_pick = $this->getPickArgument();
+ if ($is_pick && !$this->isSquashStrategy()) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'You can not "--pick" changes under the "merge" 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 = $this->getStrategyFromConfiguration();
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,
$this->getStrategyConfigurationKey()));
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) {
+ if ($set->hasDirectSymbols()) {
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.
+ $is_pick = $this->getPickArgument();
+ if ($is_pick) {
+ foreach ($sets as $key => $set) {
+ if ($set->hasDirectSymbols()) {
+ $set->setIsPick(true);
+ continue;
+ }
+
+ unset($sets[$key]);
+ }
+ }
+
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/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php
index 04e5ed0c..d34dc15e 100644
--- a/src/workflow/ArcanistLandWorkflow.php
+++ b/src/workflow/ArcanistLandWorkflow.php
@@ -1,333 +1,342 @@
<?php
/**
* Lands a branch by rebasing, merging and amending it.
*/
final class ArcanistLandWorkflow
extends ArcanistArcWorkflow {
public function getWorkflowName() {
return 'land';
}
public function getWorkflowInformation() {
$help = pht(<<<EOTEXT
Supports: git, git/p4, git/svn, hg
Publish accepted revisions after review. This command is the last step in the
standard Differential code review workflow.
To publish changes in local branch or bookmark "feature1", you will usually
run this command:
**$ arc land feature1**
This workflow merges and pushes changes associated with revisions that are
ancestors of __ref__. Without __ref__, the current working copy state will be
used. You can specify multiple __ref__ arguments to publish multiple changes at
once.
A __ref__ can be any symbol which identifies a commit: a branch name, a tag
name, a bookmark name, a topic name, a raw commit hash, a symbolic reference,
etc.
When you provide a __ref__, all unpublished changes which are present in
-ancestors of that __ref__ will be selected for publishing.
+ancestors of that __ref__ will be selected for publishing. (With the
+**--pick** flag, only the unpublished changes you directly reference will be
+selected.)
For example, if you provide local branch "feature3" as a __ref__ argument, that
may also select the changes in "feature1" and "feature2" (if they are ancestors
of "feature3"). If you stack changes in a single local branch, all commits in
the stack may be selected.
The workflow merges unpublished changes reachable from __ref__ "into" some
intermediate branch, then pushes the combined state "onto" some destination
branch (or list of branches).
(In Mercurial, the "into" and "onto" branches may be bookmarks instead.)
In the most common case, there is only one "onto" branch (often "master" or
"default" or some similar branch) and the "into" branch is the same branch. For
example, it is common to merge local feature branch "feature1" into
"origin/master", then push it onto "origin/master".
The list of "onto" branches is selected by examining these sources in order:
- the **--onto** flags;
- the __arc.land.onto__ configuration setting;
- (in Git) the upstream of the branch targeted by the land operation,
recursively;
- or by falling back to a standard default:
- (in Git) "master";
- (in Mercurial) "default".
The remote to push "onto" is selected by examining these sources in order:
- the **--onto-remote** flag;
- the __arc.land.onto-remote__ configuration setting;
- (in Git) the upstream of the current branch, recursively;
- (in Git) the special "p4" remote which indicates a repository has
been synchronized with Perforce;
- or by falling back to a standard default:
- (in Git) "origin";
- (in Mercurial) "default".
The branch to merge "into" is selected by examining these sources in order:
- the **--into** flag;
- the **--into-empty** flag;
- or by falling back to the first "onto" branch.
The remote to merge "into" is selected by examining these sources in order:
- the **--into-remote** flag;
- the **--into-local** flag (which disables fetching before merging);
- or by falling back to the "onto" remote.
After selecting remotes and branches, the commits which will land are printed.
With **--preview**, execution stops here, before the change is merged.
The "into" branch is fetched from the "into" remote (unless **--into-local** or
**--into-empty** are specified) and the changes are merged into the state in
the "into" branch according to the selected merge strategy.
The default merge strategy is "squash", which produces a single commit from
all local commits for each change. A different strategy can be selected with
the **--strategy** flag.
The resulting merged change will be given an up-to-date commit message
describing the final state of the revision in Differential.
With **--hold**, execution stops here, before the change is pushed.
The change is pushed onto all of the "onto" branches in the "onto" remote.
If you are landing multiple changes, they are normally all merged locally and
then all pushed in a single operation. Instead, you can merge and push them one
at a time with **--incremental**.
Under merge strategies which mutate history (including the default "squash"
strategy), local refs which descend from commits that were published are
now updated. For example, if you land "feature4", local branches "feature5" and
"feature6" may now be rebased on the published version of the change.
Once everything has been pushed, cleanup occurs. Consulting mystical sources of
power, the workflow makes a guess about what state you wanted to end up in
after the process finishes. The working copy is put into that state.
Any obsolete refs that point at commits which were published are deleted,
unless the **--keep-branch** flag is passed.
EOTEXT
);
return $this->newWorkflowInformation()
->setSynopsis(pht('Publish reviewed changes.'))
->addExample(pht('**land** [__options__] -- [__ref__ ...]'))
->setHelp($help);
}
public function getWorkflowArguments() {
return array(
$this->newWorkflowArgument('hold')
->setHelp(
pht(
'Prepare the changes to be pushed, but do not actually push '.
'them.')),
$this->newWorkflowArgument('keep-branches')
->setHelp(
pht(
'Keep local branches around after changes are pushed. By '.
'default, local branches are deleted after the changes they '.
'contain are published.')),
$this->newWorkflowArgument('onto-remote')
->setParameter('remote-name')
->setHelp(pht('Push to a remote other than the default.'))
->addRelatedConfig('arc.land.onto-remote'),
$this->newWorkflowArgument('onto')
->setParameter('branch-name')
->setRepeatable(true)
->addRelatedConfig('arc.land.onto')
->setHelp(
array(
pht(
'After merging, push changes onto a specified branch.'),
pht(
'Specifying this flag multiple times will push to multiple '.
'branches.'),
)),
$this->newWorkflowArgument('strategy')
->setParameter('strategy-name')
->addRelatedConfig('arc.land.strategy')
->setHelp(
array(
pht(
'Merge using a particular strategy. Supported strategies are '.
'"squash" and "merge".'),
pht(
'The "squash" strategy collapses multiple local commits into '.
'a single commit when publishing. It produces a linear '.
'published history (but discards local checkpoint commits). '.
'This is the default strategy.'),
pht(
'The "merge" strategy generates a merge commit when publishing '.
'that retains local checkpoint commits (but produces a '.
'nonlinear published history). Select this strategy if you do '.
'not want "arc land" to discard checkpoint commits.'),
)),
$this->newWorkflowArgument('revision')
->setParameter('revision-identifier')
->setHelp(
pht(
'Land a specific revision, rather than determining revisions '.
'automatically from the commits that are landing.')),
$this->newWorkflowArgument('preview')
->setHelp(
pht(
'Show the changes that will land. Does not modify the working '.
'copy or the remote.')),
$this->newWorkflowArgument('into')
->setParameter('commit-ref')
->setHelp(
pht(
'Specify the state to merge into. By default, this is the same '.
'as the "onto" ref.')),
$this->newWorkflowArgument('into-remote')
->setParameter('remote-name')
->setHelp(
pht(
'Specifies the remote to fetch the "into" ref from. By '.
'default, this is the same as the "onto" remote.')),
$this->newWorkflowArgument('into-local')
->setHelp(
pht(
'Use the local "into" ref state instead of fetching it from '.
'a remote.')),
$this->newWorkflowArgument('into-empty')
->setHelp(
pht(
'Merge into the empty state instead of an existing state. This '.
'mode is primarily useful when creating a new repository, and '.
'selected automatically if the "onto" ref does not exist and the '.
'"into" state is not specified.')),
$this->newWorkflowArgument('incremental')
->setHelp(
array(
pht(
'When landing multiple revisions at once, push and rebase '.
'after each merge completes instead of waiting until all '.
'merges are completed to push.'),
pht(
'This is slower than the default behavior and not atomic, '.
'but may make it easier to resolve conflicts and land '.
'complicated changes by allowing you to make progress one '.
'step at a time.'),
)),
+ $this->newWorkflowArgument('pick')
+ ->setHelp(
+ pht(
+ 'Land only the changes directly named by arguments, instead '.
+ 'of all reachable ancestors.')),
$this->newWorkflowArgument('ref')
->setWildcard(true),
);
}
protected function newPrompts() {
return array(
$this->newPrompt('arc.land.large-working-set')
->setDescription(
pht(
'Confirms landing more than %s commit(s) in a single operation.',
new PhutilNumber($this->getLargeWorkingSetLimit()))),
$this->newPrompt('arc.land.confirm')
->setDescription(
pht(
'Confirms that the correct changes have been selected to '.
'land.')),
$this->newPrompt('arc.land.implicit')
->setDescription(
pht(
'Confirms that local commits which are not associated with '.
'a revision have been associated correctly and should land.')),
$this->newPrompt('arc.land.unauthored')
->setDescription(
pht(
'Confirms that revisions you did not author should land.')),
$this->newPrompt('arc.land.changes-planned')
->setDescription(
pht(
'Confirms that revisions with changes planned should land.')),
$this->newPrompt('arc.land.closed')
->setDescription(
pht(
'Confirms that revisions that are already closed should land.')),
$this->newPrompt('arc.land.not-accepted')
->setDescription(
pht(
'Confirms that revisions that are not accepted should land.')),
$this->newPrompt('arc.land.open-parents')
->setDescription(
pht(
'Confirms that revisions with open parent revisions should '.
'land.')),
$this->newPrompt('arc.land.failed-builds')
->setDescription(
pht(
'Confirms that revisions with failed builds should land.')),
$this->newPrompt('arc.land.ongoing-builds')
->setDescription(
pht(
'Confirms that revisions with ongoing builds should land.')),
);
}
public function getLargeWorkingSetLimit() {
return 50;
}
public function runWorkflow() {
$working_copy = $this->getWorkingCopy();
$repository_api = $working_copy->getRepositoryAPI();
$land_engine = $repository_api->getLandEngine();
if (!$land_engine) {
throw new PhutilArgumentUsageException(
pht(
'"arc land" must be run in a Git or Mercurial working copy.'));
}
$is_incremental = $this->getArgument('incremental');
$source_refs = $this->getArgument('ref');
$onto_remote_arg = $this->getArgument('onto-remote');
$onto_args = $this->getArgument('onto');
$into_remote = $this->getArgument('into-remote');
$into_empty = $this->getArgument('into-empty');
$into_local = $this->getArgument('into-local');
$into = $this->getArgument('into');
$is_preview = $this->getArgument('preview');
$should_hold = $this->getArgument('hold');
$should_keep = $this->getArgument('keep-branches');
$revision = $this->getArgument('revision');
$strategy = $this->getArgument('strategy');
+ $pick = $this->getArgument('pick');
$land_engine
->setViewer($this->getViewer())
->setWorkflow($this)
->setLogEngine($this->getLogEngine())
->setSourceRefs($source_refs)
->setShouldHold($should_hold)
->setShouldKeep($should_keep)
->setStrategyArgument($strategy)
->setShouldPreview($is_preview)
->setOntoRemoteArgument($onto_remote_arg)
->setOntoArguments($onto_args)
->setIntoRemoteArgument($into_remote)
->setIntoEmptyArgument($into_empty)
->setIntoLocalArgument($into_local)
->setIntoArgument($into)
+ ->setPickArgument($pick)
->setIsIncremental($is_incremental)
->setRevisionSymbol($revision);
$land_engine->execute();
}
}
diff --git a/src/workflow/ArcanistWorkWorkflow.php b/src/workflow/ArcanistWorkWorkflow.php
index 5be3c94c..3696bb17 100644
--- a/src/workflow/ArcanistWorkWorkflow.php
+++ b/src/workflow/ArcanistWorkWorkflow.php
@@ -1,95 +1,95 @@
<?php
final class ArcanistWorkWorkflow
extends ArcanistArcWorkflow {
public function getWorkflowName() {
return 'work';
}
public function getWorkflowArguments() {
return array(
$this->newWorkflowArgument('start')
->setParameter('symbol')
->setHelp(
pht(
'When creating a new branch or bookmark, use this as the '.
'branch point.')),
$this->newWorkflowArgument('symbol')
->setWildcard(true),
);
}
public function getWorkflowInformation() {
$help = pht(<<<EOHELP
Begin or resume work on a branch, bookmark, task, or revision.
The __symbol__ may be a branch or bookmark name, a revision name (like "D123"),
a task name (like "T123"), or a new symbol.
If you provide a symbol which currently does not identify any ongoing work,
Arcanist will create a new branch or bookmark with the name you provide.
If you provide the name of an existing branch or bookmark, Arcanist will switch
to that branch or bookmark.
If you provide the name of a revision or task, Arcanist will look for a related
branch or bookmark that exists in the working copy. If it finds one, it will
switch to it. If it does not find one, it will attempt to create a new branch
or bookmark.
-When "arc work" creates a branch or bookmark, it will use "--start" as the
+When "arc work" creates a branch or bookmark, it will use **--start** as the
branchpoint if it is provided. Otherwise, the current working copy state will
serve as the starting point.
EOHELP
);
return $this->newWorkflowInformation()
->setSynopsis(pht('Begin or resume work.'))
->addExample(pht('**work** [--start __start__] __symbol__'))
->setHelp($help);
}
public function runWorkflow() {
$api = $this->getRepositoryAPI();
$work_engine = $api->getWorkEngine();
if (!$work_engine) {
throw new PhutilArgumentUsageException(
pht(
'"arc work" must be run in a Git or Mercurial working copy.'));
}
$argv = $this->getArgument('symbol');
if (count($argv) === 0) {
throw new PhutilArgumentUsageException(
pht(
'Provide a branch, bookmark, task, or revision name to begin '.
'or resume work on.'));
} else if (count($argv) === 1) {
$symbol_argument = $argv[0];
if (!strlen($symbol_argument)) {
throw new PhutilArgumentUsageException(
pht(
'Provide a nonempty symbol to begin or resume work on.'));
}
} else {
throw new PhutilArgumentUsageException(
pht(
'Too many arguments: provide exactly one argument.'));
}
$start_argument = $this->getArgument('start');
$work_engine
->setViewer($this->getViewer())
->setWorkflow($this)
->setLogEngine($this->getLogEngine())
->setSymbolArgument($symbol_argument)
->setStartArgument($start_argument)
->execute();
return 0;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Jan 24, 05:17 (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
601541
Default Alt Text
(107 KB)

Event Timeline