diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php index d8d8cd9a..c8eda270 100644 --- a/src/land/engine/ArcanistGitLandEngine.php +++ b/src/land/engine/ArcanistGitLandEngine.php @@ -1,1436 +1,1436 @@ 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( '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( '%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( '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( '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 = 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/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php index f5b13afd..b0ae9dc0 100644 --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -1,651 +1,655 @@ 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( 'pull -b %s -- %s', $target->getRef(), $target->getRemote()); // 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]; - $commit->addSymbol($symbol); + 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( '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.'); - $push_command = csprintf( - '$ hg push -- %s %Ls', - - // TODO: When a parameter contains only "safe" characters, we could - // relax the behavior of hgsprintf(). + // TODO: This is only vaguely correct. + $push_command = csprintf( + '$ hg push --rev %s -- %s', hgsprintf('%s', $this->getDisplayHash($into_commit)), - $this->newOntoRefArguments($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". + "%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/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index 410da8ea..a457cbcf 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,1171 +1,1226 @@ getMercurialEnvironmentVariables(); $argv[0] = 'hg '.$argv[0]; $future = newv('ExecFuture', $argv) ->setEnv($env) ->setCWD($this->getPath()); return $future; } public function execPassthru($pattern /* , ... */) { $args = func_get_args(); $env = $this->getMercurialEnvironmentVariables(); $args[0] = 'hg '.$args[0]; $passthru = 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/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php index 95aa0624..338971c8 100644 --- a/src/repository/state/ArcanistMercurialLocalState.php +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -1,65 +1,96 @@ localRef; } public function getLocalPath() { return $this->localPath; } protected function executeSaveLocalState() { $api = $this->getRepositoryAPI(); + // TODO: Fix this. } protected function executeRestoreLocalState() { $api = $this->getRepositoryAPI(); // TODO: Fix this. // TODO: In Mercurial, we may want to discard commits we've created. // $repository_api->execxLocal( // '--config extensions.mq= strip %s', // $this->onto); } protected function executeDiscardLocalState() { // TODO: Fix this. } protected function canStashChanges() { - // Depends on stash extension. - return false; + $api = $this->getRepositoryAPI(); + return $api->getMercurialFeature('shelve'); } protected function getIgnoreHints() { - // TODO: Provide this. - return array(); + return array( + pht( + 'To configure Mercurial to ignore certain files in the working '. + 'copy, add them to ".hgignore".'), + ); } protected function newRestoreCommandsForDisplay() { // TODO: Provide this. return array(); } protected function saveStash() { - return null; + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $stash_ref = sprintf( + 'arc-%s', + Filesystem::readRandomCharacters(12)); + + $api->execxLocal( + '--config extensions.shelve= shelve --unknown --name %s --', + $stash_ref); + + $log->writeStatus( + pht('SHELVE'), + pht('Shelving uncommitted changes from working copy.')); + + return $stash_ref; } protected function restoreStash($stash_ref) { - return null; + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $log->writeStatus( + pht('UNSHELVE'), + pht('Restoring uncommitted changes to working copy.')); + + $api->execxLocal( + '--config extensions.shelve= unshelve --keep --name %s --', + $stash_ref); } protected function discardStash($stash_ref) { - return null; + $api = $this->getRepositoryAPI(); + + $api->execxLocal( + '--config extensions.shelve= shelve --delete %s --', + $stash_ref); } } diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php index 675e8927..1526d50d 100644 --- a/src/repository/state/ArcanistRepositoryLocalState.php +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -1,253 +1,259 @@ workflow = $workflow; return $this; } final public function getWorkflow() { return $this->workflow; } final public function setRepositoryAPI(ArcanistRepositoryAPI $api) { $this->repositoryAPI = $api; return $this; } final public function getRepositoryAPI() { return $this->repositoryAPI; } final public function saveLocalState() { $api = $this->getRepositoryAPI(); $working_copy_display = tsprintf( " %s: %s\n", pht('Working Copy'), $api->getPath()); $conflicts = $api->getMergeConflicts(); if ($conflicts) { echo tsprintf( "\n%!\n%W\n\n%s\n", pht('MERGE CONFLICTS'), pht('You have merge conflicts in this working copy.'), $working_copy_display); $lists = array(); $lists[] = $this->newDisplayFileList( pht('Merge conflicts in working copy:'), $conflicts); $this->printFileLists($lists); throw new PhutilArgumentUsageException( pht( 'Resolve merge conflicts before proceeding.')); } $externals = $api->getDirtyExternalChanges(); if ($externals) { $message = pht( '%s submodule(s) have uncommitted or untracked changes:', new PhutilNumber(count($externals))); $prompt = pht( 'Ignore the changes to these %s submodule(s) and continue?', new PhutilNumber(count($externals))); $list = id(new PhutilConsoleList()) ->setWrap(false) ->addItems($externals); id(new PhutilConsoleBlock()) ->addParagraph($message) ->addList($list) ->draw(); $ok = phutil_console_confirm($prompt, $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } $uncommitted = $api->getUncommittedChanges(); $unstaged = $api->getUnstagedChanges(); $untracked = $api->getUntrackedChanges(); // We already dealt with externals. $unstaged = array_diff($unstaged, $externals); // We only want files which are purely uncommitted. $uncommitted = array_diff($uncommitted, $unstaged); $uncommitted = array_diff($uncommitted, $externals); if ($untracked || $unstaged || $uncommitted) { echo tsprintf( "\n%!\n%W\n\n%s\n", pht('UNCOMMITTED CHANGES'), pht('You have uncommitted changes in this working copy.'), $working_copy_display); $lists = array(); $lists[] = $this->newDisplayFileList( pht('Untracked changes in working copy:'), $untracked); $lists[] = $this->newDisplayFileList( pht('Unstaged changes in working copy:'), $unstaged); $lists[] = $this->newDisplayFileList( pht('Uncommitted changes in working copy:'), $uncommitted); $this->printFileLists($lists); if ($untracked) { $hints = $this->getIgnoreHints(); foreach ($hints as $hint) { echo tsprintf("%?\n", $hint); } } if ($this->canStashChanges()) { $query = pht('Stash these changes and continue?'); $this->getWorkflow() ->getPrompt('arc.state.stash') ->setQuery($query) ->execute(); $stash_ref = $this->saveStash(); if ($stash_ref === null) { throw new Exception( pht( 'Expected a non-null return from call to "%s->saveStash()".', get_class($this))); } $this->stashRef = $stash_ref; } else { throw new PhutilArgumentUsageException( pht( 'You can not continue with uncommitted changes. Commit or '. 'discard them before proceeding.')); } } $this->executeSaveLocalState(); $this->shouldRestore = true; // TODO: Detect when we're in the middle of a rebase. // TODO: Detect when we're in the middle of a cherry-pick. return $this; } final public function restoreLocalState() { $this->shouldRestore = false; $this->executeRestoreLocalState(); - if ($this->stashRef !== null) { - $this->restoreStash($this->stashRef); - } + $this->applyStash(); + $this->executeDiscardLocalState(); return $this; } final public function discardLocalState() { $this->shouldRestore = false; + $this->applyStash(); $this->executeDiscardLocalState(); - if ($this->stashRef !== null) { - $this->restoreStash($this->stashRef); - $this->discardStash($this->stashRef); - $this->stashRef = null; - } return $this; } final public function __destruct() { if ($this->shouldRestore) { $this->restoreLocalState(); + } else { + $this->discardLocalState(); } - - $this->discardLocalState(); } final public function getRestoreCommandsForDisplay() { return $this->newRestoreCommandsForDisplay(); } protected function canStashChanges() { return false; } protected function saveStash() { throw new PhutilMethodNotImplementedException(); } protected function restoreStash($ref) { throw new PhutilMethodNotImplementedException(); } protected function discardStash($ref) { throw new PhutilMethodNotImplementedException(); } + private function applyStash() { + if ($this->stashRef === null) { + return; + } + $stash_ref = $this->stashRef; + $this->stashRef = null; + + $this->restoreStash($stash_ref); + $this->discardStash($stash_ref); + } + abstract protected function executeSaveLocalState(); abstract protected function executeRestoreLocalState(); abstract protected function executeDiscardLocalState(); abstract protected function newRestoreCommandsForDisplay(); protected function getIgnoreHints() { return array(); } final protected function newDisplayFileList($title, array $files) { if (!$files) { return null; } $items = array(); $items[] = tsprintf("%s\n\n", $title); foreach ($files as $file) { $items[] = tsprintf( " %s\n", $file); } return $items; } final protected function printFileLists(array $lists) { $lists = array_filter($lists); $last_key = last_key($lists); foreach ($lists as $key => $list) { foreach ($list as $item) { echo tsprintf('%B', $item); } if ($key !== $last_key) { echo tsprintf("\n\n"); } } echo tsprintf("\n"); } } diff --git a/src/xsprintf/hgsprintf.php b/src/xsprintf/hgsprintf.php index 326d0e14..c1006593 100644 --- a/src/xsprintf/hgsprintf.php +++ b/src/xsprintf/hgsprintf.php @@ -1,33 +1,40 @@