1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-11-21 22:32:41 +01:00

Substantially modernize the "arc land" workflow

Summary: Ref T13546. This has a lot of dangerously rough edges, but has managed to land at least one commit in each Git and Mercurial.

Test Plan:
  - Landed one commit under ideal conditions in Git and Mercurial.
  - See followups.

Maniphest Tasks: T13546

Differential Revision: https://secure.phabricator.com/D21315
This commit is contained in:
epriestley 2020-06-04 17:29:16 -07:00
parent 7d615a97e2
commit 68f28a1718
22 changed files with 4226 additions and 2686 deletions

View file

@ -218,7 +218,7 @@ phutil_register_library_map(array(
'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php',
'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php', 'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php',
'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php',
'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php', 'ArcanistGitLandEngine' => 'land/engine/ArcanistGitLandEngine.php',
'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php', 'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php',
'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php', 'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php',
'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php', 'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php',
@ -288,7 +288,11 @@ phutil_register_library_map(array(
'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistKeywordCasingXHPASTLinterRuleTestCase.php', 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistKeywordCasingXHPASTLinterRuleTestCase.php',
'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php',
'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase.php',
'ArcanistLandEngine' => 'land/ArcanistLandEngine.php', 'ArcanistLandCommit' => 'land/ArcanistLandCommit.php',
'ArcanistLandCommitSet' => 'land/ArcanistLandCommitSet.php',
'ArcanistLandEngine' => 'land/engine/ArcanistLandEngine.php',
'ArcanistLandSymbol' => 'land/ArcanistLandSymbol.php',
'ArcanistLandTarget' => 'land/ArcanistLandTarget.php',
'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php',
'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php',
'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase.php',
@ -320,9 +324,13 @@ phutil_register_library_map(array(
'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php',
'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase.php', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase.php',
'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php',
'ArcanistMercurialCommitMessageHardpointQuery' => 'query/ArcanistMercurialCommitMessageHardpointQuery.php',
'ArcanistMercurialLandEngine' => 'land/engine/ArcanistMercurialLandEngine.php',
'ArcanistMercurialLocalState' => 'repository/state/ArcanistMercurialLocalState.php',
'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php',
'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php',
'ArcanistMercurialWorkingCopy' => 'workingcopy/ArcanistMercurialWorkingCopy.php', 'ArcanistMercurialWorkingCopy' => 'workingcopy/ArcanistMercurialWorkingCopy.php',
'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php',
'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php',
'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php', 'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php',
'ArcanistMessageRevisionHardpointQuery' => 'query/ArcanistMessageRevisionHardpointQuery.php', 'ArcanistMessageRevisionHardpointQuery' => 'query/ArcanistMessageRevisionHardpointQuery.php',
@ -524,6 +532,7 @@ phutil_register_library_map(array(
'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php', 'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php',
'ArcanistWorkflowGitHardpointQuery' => 'query/ArcanistWorkflowGitHardpointQuery.php', 'ArcanistWorkflowGitHardpointQuery' => 'query/ArcanistWorkflowGitHardpointQuery.php',
'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php', 'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php',
'ArcanistWorkflowMercurialHardpointQuery' => 'query/ArcanistWorkflowMercurialHardpointQuery.php',
'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php', 'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php',
'ArcanistWorkingCopyCommitHardpointQuery' => 'query/ArcanistWorkingCopyCommitHardpointQuery.php', 'ArcanistWorkingCopyCommitHardpointQuery' => 'query/ArcanistWorkingCopyCommitHardpointQuery.php',
'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php', 'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php',
@ -1298,8 +1307,12 @@ phutil_register_library_map(array(
'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistLandCommit' => 'Phobject',
'ArcanistLandCommitSet' => 'Phobject',
'ArcanistLandEngine' => 'Phobject', 'ArcanistLandEngine' => 'Phobject',
'ArcanistLandWorkflow' => 'ArcanistWorkflow', 'ArcanistLandSymbol' => 'Phobject',
'ArcanistLandTarget' => 'Phobject',
'ArcanistLandWorkflow' => 'ArcanistArcWorkflow',
'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistLesscLinter' => 'ArcanistExternalLinter', 'ArcanistLesscLinter' => 'ArcanistExternalLinter',
@ -1330,9 +1343,13 @@ phutil_register_library_map(array(
'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI',
'ArcanistMercurialCommitMessageHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery',
'ArcanistMercurialLandEngine' => 'ArcanistLandEngine',
'ArcanistMercurialLocalState' => 'ArcanistRepositoryLocalState',
'ArcanistMercurialParser' => 'Phobject', 'ArcanistMercurialParser' => 'Phobject',
'ArcanistMercurialParserTestCase' => 'PhutilTestCase', 'ArcanistMercurialParserTestCase' => 'PhutilTestCase',
'ArcanistMercurialWorkingCopy' => 'ArcanistWorkingCopy', 'ArcanistMercurialWorkingCopy' => 'ArcanistWorkingCopy',
'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery',
'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistMergeConflictLinter' => 'ArcanistLinter',
'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase',
'ArcanistMessageRevisionHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistMessageRevisionHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
@ -1544,6 +1561,7 @@ phutil_register_library_map(array(
'ArcanistWorkflowArgument' => 'Phobject', 'ArcanistWorkflowArgument' => 'Phobject',
'ArcanistWorkflowGitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkflowGitHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkflowInformation' => 'Phobject', 'ArcanistWorkflowInformation' => 'Phobject',
'ArcanistWorkflowMercurialHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkingCopy' => 'Phobject', 'ArcanistWorkingCopy' => 'Phobject',
'ArcanistWorkingCopyCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkingCopyCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery',
'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource', 'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource',

View file

@ -136,19 +136,15 @@ final class ArcanistArcConfigurationEngineExtension
array( array(
'origin', 'origin',
)), )),
id(new ArcanistBoolConfigOption()) id(new ArcanistStringConfigOption())
->setKey('history.immutable') ->setKey('arc.land.strategy')
->setSummary( ->setSummary(
pht( pht(
'Configure use of history mutation operations like amends '. 'Configure a default merge strategy for "arc land".'))
'and rebases.'))
->setHelp( ->setHelp(
pht( pht(
'If this option is set to "true", Arcanist will treat the '. 'Specifies the default behavior when "arc land" is run with '.
'repository history as immutable and will never issue '. 'no "--strategy" flag.')),
'commands which rewrite repository history (like amends or '.
'rebases). This option defaults to "true" in Mercurial, '.
'"false" in Git, and has no effect in Subversion.')),
); );
} }

View file

@ -1,913 +0,0 @@
<?php
final class ArcanistGitLandEngine
extends ArcanistLandEngine {
private $localRef;
private $localCommit;
private $sourceCommit;
private $mergedRef;
private $restoreWhenDestroyed;
private $isGitPerforce;
private function setIsGitPerforce($is_git_perforce) {
$this->isGitPerforce = $is_git_perforce;
return $this;
}
private function getIsGitPerforce() {
return $this->isGitPerforce;
}
public function parseArguments() {
$api = $this->getRepositoryAPI();
$onto = $this->getEngineOnto();
$this->setTargetOnto($onto);
$remote = $this->getEngineRemote();
$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 "--remote" flag to choose '.
'a valid, pushable remote to land changes onto.',
$remote));
}
if ($is_perforce) {
$this->setIsGitPerforce(true);
$this->writeWarn(
pht('P4 MODE'),
pht(
'Operating in Git/Perforce mode after selecting a Perforce '.
'remote.'));
if (!$this->getShouldSquash()) {
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).'));
}
}
$this->setTargetRemote($remote);
}
public function execute() {
$this->verifySourceAndTargetExist();
$this->fetchTarget();
$this->printLandingCommits();
if ($this->getShouldPreview()) {
$this->writeInfo(
pht('PREVIEW'),
pht('Completed preview of operation.'));
return;
}
$this->saveLocalState();
try {
$this->identifyRevision();
$this->updateWorkingCopy();
if ($this->getShouldHold()) {
$this->didHoldChanges();
} else {
$this->pushChange();
$this->reconcileLocalState();
$api = $this->getRepositoryAPI();
$api->execxLocal('submodule update --init --recursive');
if ($this->getShouldKeep()) {
echo tsprintf(
"%s\n",
pht('Keeping local branch.'));
} else {
$this->destroyLocalBranch();
}
$this->writeOkay(
pht('DONE'),
pht('Landed changes.'));
}
$this->restoreWhenDestroyed = false;
} catch (Exception $ex) {
$this->restoreLocalState();
throw $ex;
}
}
public function __destruct() {
if ($this->restoreWhenDestroyed) {
$this->writeWarn(
pht('INTERRUPTED!'),
pht('Restoring working copy to its original state.'));
$this->restoreLocalState();
}
}
protected function getLandingCommits() {
$api = $this->getRepositoryAPI();
list($out) = $api->execxLocal(
'log --oneline %s..%s --',
$this->getTargetFullRef(),
$this->sourceCommit);
$out = trim($out);
if (!strlen($out)) {
return array();
} else {
return phutil_split_lines($out, false);
}
}
private function identifyRevision() {
$api = $this->getRepositoryAPI();
$api->execxLocal('checkout %s --', $this->getSourceRef());
call_user_func($this->getBuildMessageCallback(), $this);
}
private function verifySourceAndTargetExist() {
$api = $this->getRepositoryAPI();
list($err) = $api->execManualLocal(
'rev-parse --verify %s',
$this->getTargetFullRef());
if ($err) {
$this->writeWarn(
pht('TARGET'),
pht(
'No local ref exists for branch "%s" in remote "%s", attempting '.
'fetch...',
$this->getTargetOnto(),
$this->getTargetRemote()));
$api->execManualLocal(
'fetch %s %s --',
$this->getTargetRemote(),
$this->getTargetOnto());
list($err) = $api->execManualLocal(
'rev-parse --verify %s',
$this->getTargetFullRef());
if ($err) {
throw new Exception(
pht(
'Branch "%s" does not exist in remote "%s".',
$this->getTargetOnto(),
$this->getTargetRemote()));
}
$this->writeInfo(
pht('FETCHED'),
pht(
'Fetched branch "%s" from remote "%s".',
$this->getTargetOnto(),
$this->getTargetRemote()));
}
list($err, $stdout) = $api->execManualLocal(
'rev-parse --verify %s',
$this->getSourceRef());
if ($err) {
throw new Exception(
pht(
'Branch "%s" does not exist in the local working copy.',
$this->getSourceRef()));
}
$this->sourceCommit = trim($stdout);
}
private function fetchTarget() {
$api = $this->getRepositoryAPI();
$ref = $this->getTargetFullRef();
// 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()) {
$this->writeInfo(
pht('P4 SYNC'),
pht('Synchronizing "%s" from Perforce...', $ref));
$sync_ref = sprintf(
'refs/remotes/%s/%s',
$this->getTargetRemote(),
$this->getTargetOnto());
$err = $api->execPassthru(
'p4 sync --silent --branch %R --',
$sync_ref);
if ($err) {
throw new ArcanistUsageException(
pht(
'Perforce sync failed! Fix the error and run "arc land" again.'));
}
} else {
$this->writeInfo(
pht('FETCH'),
pht('Fetching "%s"...', $ref));
$err = $api->execPassthru(
'fetch --quiet -- %s %s',
$this->getTargetRemote(),
$this->getTargetOnto());
if ($err) {
throw new ArcanistUsageException(
pht(
'Fetch failed! Fix the error and run "arc land" again.'));
}
}
}
private function updateWorkingCopy() {
$api = $this->getRepositoryAPI();
$source = $this->sourceCommit;
$api->execxLocal(
'checkout %s --',
$this->getTargetFullRef());
list($original_author, $original_date) = $this->getAuthorAndDate($source);
try {
if ($this->getShouldSquash()) {
// NOTE: We're explicitly specifying "--ff" to override the presence
// of "merge.ff" options in user configuration.
$api->execxLocal(
'merge --no-stat --no-commit --ff --squash -- %s',
$source);
} else {
$api->execxLocal(
'merge --no-stat --no-commit --no-ff -- %s',
$source);
}
} catch (Exception $ex) {
$api->execManualLocal('merge --abort');
$api->execManualLocal('reset --hard HEAD --');
throw new Exception(
pht(
'Local "%s" does not merge cleanly into "%s". Merge or rebase '.
'local changes so they can merge cleanly.',
$this->getSourceRef(),
$this->getTargetFullRef()));
}
// TODO: This could probably be cleaner by asking the API a question
// about working copy status instead of running a raw diff command. See
// discussion in T11435.
list($changes) = $api->execxLocal('diff --no-ext-diff HEAD --');
$changes = trim($changes);
if (!strlen($changes)) {
throw new Exception(
pht(
'Merging local "%s" into "%s" produces an empty diff. '.
'This usually means these changes have already landed.',
$this->getSourceRef(),
$this->getTargetFullRef()));
}
$api->execxLocal(
'commit --author %s --date %s -F %s --',
$original_author,
$original_date,
$this->getCommitMessageFile());
$this->getWorkflow()->didCommitMerge();
list($stdout) = $api->execxLocal(
'rev-parse --verify %s',
'HEAD');
$this->mergedRef = trim($stdout);
}
private function pushChange() {
$api = $this->getRepositoryAPI();
if ($this->getIsGitPerforce()) {
$this->writeInfo(
pht('SUBMITTING'),
pht('Submitting changes to "%s".', $this->getTargetFullRef()));
$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,
$this->mergedRef);
if ($err) {
throw new ArcanistUsageException(
pht(
'Submit failed! Fix the error and run "arc land" again.'));
}
} else {
$this->writeInfo(
pht('PUSHING'),
pht('Pushing changes to "%s".', $this->getTargetFullRef()));
$err = $api->execPassthru(
'push -- %s %s:%s',
$this->getTargetRemote(),
$this->mergedRef,
$this->getTargetOnto());
if ($err) {
throw new ArcanistUsageException(
pht(
'Push failed! Fix the error and run "arc land" again.'));
}
}
}
private function reconcileLocalState() {
$api = $this->getRepositoryAPI();
// 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 have the same names as branches in the remote but no
// relationship to them.
if ($this->localRef != $this->getSourceRef()) {
// The user ran `arc land X` but was on a different branch, so just put
// them back wherever they were before.
$this->writeInfo(
pht('RESTORE'),
pht('Switching back to "%s".', $this->localRef));
$this->restoreLocalState();
return;
}
// We're going to try to find a path to the upstream target branch. We
// try in two different ways:
//
// - follow the source branch directly along tracking branches until
// we reach the upstream; or
// - follow a local branch with the same name as the target branch until
// we reach the upstream.
// First, get the path from whatever we landed to wherever it goes.
$local_branch = $this->getSourceRef();
$path = $api->getPathToUpstream($local_branch);
if ($path->getLength()) {
// We may want to discard the thing we landed from the path, if we're
// going to delete it. In this case, we don't want to update it or worry
// if it's dirty.
if ($this->getSourceRef() == $this->getTargetOnto()) {
// In this case, we've done something like land "master" onto itself,
// so we do want to update the actual branch. We're going to use the
// entire path.
} else {
// Otherwise, we're going to delete the branch at the end of the
// workflow, so throw it away the most-local branch that isn't long
// for this world.
$path->removeUpstream($local_branch);
if (!$path->getLength()) {
// The local branch tracked upstream directly; however, it
// may not be the only one to do so. If there's a local
// branch of the same name that tracks the remote, try
// switching to that.
$local_branch = $this->getTargetOnto();
list($err) = $api->execManualLocal(
'rev-parse --verify %s',
$local_branch);
if (!$err) {
$path = $api->getPathToUpstream($local_branch);
}
if (!$path->isConnectedToRemote()) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local branch "%s" directly tracks remote, staying on '.
'detached HEAD.',
$local_branch));
return;
}
}
$local_branch = head($path->getLocalBranches());
}
} else {
// The source branch has no upstream, so look for a local branch with
// the same name as the target branch. This corresponds to the common
// case where you have "master" and checkout local branches from it
// with "git checkout -b feature", then land onto "master".
$local_branch = $this->getTargetOnto();
list($err) = $api->execManualLocal(
'rev-parse --verify %s',
$local_branch);
if ($err) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local branch "%s" does not exist, staying on detached HEAD.',
$local_branch));
return;
}
$path = $api->getPathToUpstream($local_branch);
}
if ($path->getCycle()) {
$this->writeWarn(
pht('LOCAL CYCLE'),
pht(
'Local branch "%s" tracks an upstream but following it leads to '.
'a local cycle, staying on detached HEAD.',
$local_branch));
return;
}
$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.
// Just pretend the target branch is connected directly to the remote,
// since this is effectively the behavior of Perforce and appears to
// do the right thing.
$cascade_branches = array($local_branch);
} else {
if (!$path->isConnectedToRemote()) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local branch "%s" is not connected to a remote, staying on '.
'detached HEAD.',
$local_branch));
return;
}
$remote_remote = $path->getRemoteRemoteName();
$remote_branch = $path->getRemoteBranchName();
$remote_actual = $remote_remote.'/'.$remote_branch;
$remote_expect = $this->getTargetFullRef();
if ($remote_actual != $remote_expect) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local branch "%s" is connected to a remote ("%s") other than '.
'the target remote ("%s"), staying on detached HEAD.',
$local_branch,
$remote_actual,
$remote_expect));
return;
}
// If we get this far, we have a sequence of branches which ultimately
// connect to the remote. We're going to try to update them all in reverse
// order, from most-upstream to most-local.
$cascade_branches = $path->getLocalBranches();
$cascade_branches = array_reverse($cascade_branches);
}
// First, check if any of them are ahead of the remote.
$ahead_of_remote = array();
foreach ($cascade_branches as $cascade_branch) {
list($stdout) = $api->execxLocal(
'log %s..%s --',
$this->mergedRef,
$cascade_branch);
$stdout = trim($stdout);
if (strlen($stdout)) {
$ahead_of_remote[$cascade_branch] = $cascade_branch;
}
}
// We're going to handle the last branch (the thing we ultimately intend
// to check out) differently. It's OK if it's ahead of the remote, as long
// as we just landed it.
$local_ahead = isset($ahead_of_remote[$local_branch]);
unset($ahead_of_remote[$local_branch]);
$land_self = ($this->getTargetOnto() === $this->getSourceRef());
// We aren't going to pull anything if anything upstream from us is ahead
// of the remote, or the local is ahead of the remote and we didn't land
// it onto itself.
$skip_pull = ($ahead_of_remote || ($local_ahead && !$land_self));
if ($skip_pull) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local "%s" is ahead of remote "%s". Checking out "%s" but '.
'not pulling changes.',
nonempty(head($ahead_of_remote), $local_branch),
$this->getTargetFullRef(),
$local_branch));
$this->writeInfo(
pht('CHECKOUT'),
pht(
'Checking out "%s".',
$local_branch));
$api->execxLocal('checkout %s --', $local_branch);
return;
}
// If nothing upstream from our nearest branch is ahead of the remote,
// pull it all.
$cascade_targets = array();
if (!$ahead_of_remote) {
foreach ($cascade_branches as $cascade_branch) {
if ($local_ahead && ($local_branch == $cascade_branch)) {
continue;
}
$cascade_targets[] = $cascade_branch;
}
}
if ($is_perforce) {
// In Perforce, 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 this step and jump down to
// the "git reset --hard" below to get everything into the right state.
} else if ($cascade_targets) {
$this->writeInfo(
pht('UPDATE'),
pht(
'Local "%s" tracks target remote "%s", checking out and '.
'pulling changes.',
$local_branch,
$this->getTargetFullRef()));
foreach ($cascade_targets as $cascade_branch) {
$this->writeInfo(
pht('PULL'),
pht(
'Checking out and pulling "%s".',
$cascade_branch));
$api->execxLocal('checkout %s --', $cascade_branch);
$api->execxLocal(
'pull %s %s --',
$this->getTargetRemote(),
$cascade_branch);
}
if (!$local_ahead) {
return;
}
}
// In this case, the user did something like land a branch onto itself,
// and the branch is tracking the correct remote. We're going to discard
// the local state and reset it to the state we just pushed.
$this->writeInfo(
pht('RESET'),
pht(
'Local "%s" landed into remote "%s", resetting local branch to '.
'remote state.',
$this->getTargetOnto(),
$this->getTargetFullRef()));
$api->execxLocal('checkout %s --', $local_branch);
$api->execxLocal('reset --hard %s --', $this->getTargetFullRef());
return;
}
private function destroyLocalBranch() {
$api = $this->getRepositoryAPI();
$source_ref = $this->getSourceRef();
if ($source_ref == $this->getTargetOnto()) {
// If we landed a branch into a branch with the same name, so don't
// destroy it. This prevents us from cleaning up "master" if you're
// landing master into itself.
return;
}
// TODO: Maybe this should also recover the proper upstream?
// See T10321. If we were not landing a branch, don't try to clean it up.
// This happens most often when landing from a detached HEAD.
$is_branch = $this->isBranch($source_ref);
if (!$is_branch) {
echo tsprintf(
"%s\n",
pht(
'(Source "%s" is not a branch, leaving working copy as-is.)',
$source_ref));
return;
}
$recovery_command = csprintf(
'git checkout -b %R %R',
$source_ref,
$this->sourceCommit);
echo tsprintf(
"%s\n",
pht('Cleaning up branch "%s"...', $source_ref));
echo tsprintf(
"%s\n",
pht('(Use `%s` if you want it back.)', $recovery_command));
$api->execxLocal('branch -D -- %s', $source_ref);
}
/**
* Save the local working copy state so we can restore it later.
*/
private function saveLocalState() {
$api = $this->getRepositoryAPI();
$this->localCommit = $api->getWorkingCopyRevision();
list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD');
$ref = trim($ref);
if ($ref === 'HEAD') {
$ref = $this->localCommit;
}
$this->localRef = $ref;
$this->restoreWhenDestroyed = true;
}
/**
* Restore the working copy to the state it was in before we started
* performing writes.
*/
private function restoreLocalState() {
$api = $this->getRepositoryAPI();
$api->execxLocal('checkout %s --', $this->localRef);
$api->execxLocal('reset --hard %s --', $this->localCommit);
$api->execxLocal('submodule update --init --recursive');
$this->restoreWhenDestroyed = false;
}
private function getTargetFullRef() {
return $this->getTargetRemote().'/'.$this->getTargetOnto();
}
private function getAuthorAndDate($commit) {
$api = $this->getRepositoryAPI();
// TODO: This is working around Windows escaping problems, see T8298.
list($info) = $api->execxLocal(
'log -n1 --format=%C %s --',
'%aD%n%an%n%ae',
$commit);
$info = trim($info);
list($date, $author, $email) = explode("\n", $info, 3);
return array(
"$author <{$email}>",
$date,
);
}
private function didHoldChanges() {
if ($this->getIsGitPerforce()) {
$this->writeInfo(
pht('HOLD'),
pht(
'Holding change locally, it has not been submitted.'));
$push_command = csprintf(
'$ git p4 submit -M --commit %R --',
$this->mergedRef);
} else {
$this->writeInfo(
pht('HOLD'),
pht(
'Holding change locally, it has not been pushed.'));
$push_command = csprintf(
'$ git push -- %R %R:%R',
$this->getTargetRemote(),
$this->mergedRef,
$this->getTargetOnto());
}
$restore_command = csprintf(
'$ git checkout %R --',
$this->localRef);
echo tsprintf(
"\n%s\n\n".
"%s\n\n".
" **%s**\n\n".
"%s\n\n".
" **%s**\n\n".
"%s\n",
pht(
'This local working copy now contains the merged changes in a '.
'detached state.'),
pht('You can push the changes manually with this command:'),
$push_command,
pht(
'You can go back to how things were before you ran "arc land" with '.
'this command:'),
$restore_command,
pht(
'Local branches have not been changed, and are still in exactly the '.
'same state as before.'));
}
private function isBranch($ref) {
$api = $this->getRepositoryAPI();
list($err) = $api->execManualLocal(
'show-ref --verify --quiet -- %R',
'refs/heads/'.$ref);
return !$err;
}
private function getEngineOnto() {
$source_ref = $this->getSourceRef();
$onto = $this->getOntoArgument();
if ($onto !== null) {
$this->writeInfo(
pht('TARGET'),
pht(
'Landing onto "%s", selected with the "--onto" flag.',
$onto));
return $onto;
}
$api = $this->getRepositoryAPI();
$path = $api->getPathToUpstream($source_ref);
if ($path->getLength()) {
$cycle = $path->getCycle();
if ($cycle) {
$this->writeWarn(
pht('LOCAL CYCLE'),
pht(
'Local branch tracks an upstream, but following it leads to a '.
'local cycle; ignoring branch upstream.'));
echo tsprintf(
"\n %s\n\n",
implode(' -> ', $cycle));
} else {
if ($path->isConnectedToRemote()) {
$onto = $path->getRemoteBranchName();
$this->writeInfo(
pht('TARGET'),
pht(
'Landing onto "%s", selected by following tracking branches '.
'upstream to the closest remote.',
$onto));
return $onto;
} else {
$this->writeInfo(
pht('NO PATH TO UPSTREAM'),
pht(
'Local branch tracks an upstream, but there is no path '.
'to a remote; ignoring branch upstream.'));
}
}
}
$workflow = $this->getWorkflow();
$config_key = 'arc.land.onto.default';
$onto = $workflow->getConfigFromAnySource($config_key);
if ($onto !== null) {
$this->writeInfo(
pht('TARGET'),
pht(
'Landing onto "%s", selected by "%s" configuration.',
$onto,
$config_key));
return $onto;
}
$onto = 'master';
$this->writeInfo(
pht('TARGET'),
pht(
'Landing onto "%s", the default target under git.',
$onto));
return $onto;
}
private function getEngineRemote() {
$source_ref = $this->getSourceRef();
$remote = $this->getRemoteArgument();
if ($remote !== null) {
$this->writeInfo(
pht('REMOTE'),
pht(
'Using remote "%s", selected with the "--remote" flag.',
$remote));
return $remote;
}
$api = $this->getRepositoryAPI();
$path = $api->getPathToUpstream($source_ref);
$remote = $path->getRemoteRemoteName();
if ($remote !== null) {
$this->writeInfo(
pht('REMOTE'),
pht(
'Using remote "%s", selected by following tracking branches '.
'upstream to the closest remote.',
$remote));
return $remote;
}
$remote = 'p4';
if ($api->isPerforceRemote($remote)) {
$this->writeInfo(
pht('REMOTE'),
pht(
'Using Perforce remote "%s". The existence of this remote implies '.
'this working copy was synchronized from a Perforce repository.',
$remote));
return $remote;
}
$remote = 'origin';
$this->writeInfo(
pht('REMOTE'),
pht(
'Using remote "%s", the default remote under Git.',
$remote));
return $remote;
}
}

View file

@ -0,0 +1,149 @@
<?php
final class ArcanistLandCommit
extends Phobject {
private $hash;
private $summary;
private $displaySummary;
private $parents;
private $symbols = array();
private $explicitRevisionRef;
private $revisionRef = false;
private $parentCommits;
private $isHeadCommit;
private $isImplicitCommit;
public function setHash($hash) {
$this->hash = $hash;
return $this;
}
public function getHash() {
return $this->hash;
}
public function setSummary($summary) {
$this->summary = $summary;
return $this;
}
public function getSummary() {
return $this->summary;
}
public function getDisplaySummary() {
if ($this->displaySummary === null) {
$this->displaySummary = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(64)
->truncateString($this->getSummary());
}
return $this->displaySummary;
}
public function setParents(array $parents) {
$this->parents = $parents;
return $this;
}
public function getParents() {
return $this->parents;
}
public function addSymbol(ArcanistLandSymbol $symbol) {
$this->symbols[] = $symbol;
return $this;
}
public function getSymbols() {
return $this->symbols;
}
public function setExplicitRevisionref(ArcanistRevisionRef $ref) {
$this->explicitRevisionRef = $ref;
return $this;
}
public function getExplicitRevisionref() {
return $this->explicitRevisionRef;
}
public function setParentCommits(array $parent_commits) {
$this->parentCommits = $parent_commits;
return $this;
}
public function getParentCommits() {
return $this->parentCommits;
}
public function setIsHeadCommit($is_head_commit) {
$this->isHeadCommit = $is_head_commit;
return $this;
}
public function getIsHeadCommit() {
return $this->isHeadCommit;
}
public function setIsImplicitCommit($is_implicit_commit) {
$this->isImplicitCommit = $is_implicit_commit;
return $this;
}
public function getIsImplicitCommit() {
return $this->isImplicitCommit;
}
public function getAncestorRevisionPHIDs() {
$phids = array();
foreach ($this->getParentCommits() as $parent_commit) {
$phids += $parent_commit->getAncestorRevisionPHIDs();
}
$revision_ref = $this->getRevisionRef();
if ($revision_ref) {
$phids[$revision_ref->getPHID()] = $revision_ref->getPHID();
}
return $phids;
}
public function getRevisionRef() {
if ($this->revisionRef === false) {
$this->revisionRef = $this->newRevisionRef();
}
return $this->revisionRef;
}
private function newRevisionRef() {
$revision_ref = $this->getExplicitRevisionRef();
if ($revision_ref) {
return $revision_ref;
}
$parent_refs = array();
foreach ($this->getParentCommits() as $parent_commit) {
$parent_ref = $parent_commit->getRevisionRef();
if ($parent_ref) {
$parent_refs[$parent_ref->getPHID()] = $parent_ref;
}
}
if (count($parent_refs) > 1) {
throw new Exception(
pht(
'Too many distinct parent refs!'));
}
if ($parent_refs) {
return head($parent_refs);
}
return null;
}
}

View file

@ -0,0 +1,52 @@
<?php
final class ArcanistLandCommitSet
extends Phobject {
private $revisionRef;
private $commits;
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;
}
}

View file

@ -1,182 +0,0 @@
<?php
abstract class ArcanistLandEngine extends Phobject {
private $workflow;
private $repositoryAPI;
private $targetRemote;
private $targetOnto;
private $sourceRef;
private $commitMessageFile;
private $shouldHold;
private $shouldKeep;
private $shouldSquash;
private $shouldDeleteRemote;
private $shouldPreview;
private $remoteArgument;
private $ontoArgument;
// TODO: This is really grotesque.
private $buildMessageCallback;
final public function setWorkflow(ArcanistWorkflow $workflow) {
$this->workflow = $workflow;
return $this;
}
final public function getWorkflow() {
return $this->workflow;
}
final public function setRepositoryAPI(
ArcanistRepositoryAPI $repository_api) {
$this->repositoryAPI = $repository_api;
return $this;
}
final public function getRepositoryAPI() {
return $this->repositoryAPI;
}
final public function 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 setShouldSquash($should_squash) {
$this->shouldSquash = $should_squash;
return $this;
}
final public function getShouldSquash() {
return $this->shouldSquash;
}
final public function setShouldPreview($should_preview) {
$this->shouldPreview = $should_preview;
return $this;
}
final public function getShouldPreview() {
return $this->shouldPreview;
}
final public function setTargetRemote($target_remote) {
$this->targetRemote = $target_remote;
return $this;
}
final public function getTargetRemote() {
return $this->targetRemote;
}
final public function setTargetOnto($target_onto) {
$this->targetOnto = $target_onto;
return $this;
}
final public function getTargetOnto() {
return $this->targetOnto;
}
final public function setSourceRef($source_ref) {
$this->sourceRef = $source_ref;
return $this;
}
final public function getSourceRef() {
return $this->sourceRef;
}
final public function setBuildMessageCallback($build_message_callback) {
$this->buildMessageCallback = $build_message_callback;
return $this;
}
final public function getBuildMessageCallback() {
return $this->buildMessageCallback;
}
final public function setCommitMessageFile($commit_message_file) {
$this->commitMessageFile = $commit_message_file;
return $this;
}
final public function getCommitMessageFile() {
return $this->commitMessageFile;
}
final public function setRemoteArgument($remote_argument) {
$this->remoteArgument = $remote_argument;
return $this;
}
final public function getRemoteArgument() {
return $this->remoteArgument;
}
final public function setOntoArgument($onto_argument) {
$this->ontoArgument = $onto_argument;
return $this;
}
final public function getOntoArgument() {
return $this->ontoArgument;
}
abstract public function parseArguments();
abstract public function execute();
abstract protected function getLandingCommits();
protected function printLandingCommits() {
$logs = $this->getLandingCommits();
if (!$logs) {
throw new ArcanistUsageException(
pht(
'There are no commits on "%s" which are not already present on '.
'the target.',
$this->getSourceRef()));
}
$list = id(new PhutilConsoleList())
->setWrap(false)
->addItems($logs);
id(new PhutilConsoleBlock())
->addParagraph(
pht(
'These %s commit(s) will be landed:',
new PhutilNumber(count($logs))))
->addList($list)
->draw();
}
protected function writeWarn($title, $message) {
return $this->getWorkflow()->writeWarn($title, $message);
}
protected function writeInfo($title, $message) {
return $this->getWorkflow()->writeInfo($title, $message);
}
protected function writeOkay($title, $message) {
return $this->getWorkflow()->writeOkay($title, $message);
}
}

View file

@ -0,0 +1,27 @@
<?php
final class ArcanistLandSymbol
extends Phobject {
private $symbol;
private $commit;
public function setSymbol($symbol) {
$this->symbol = $symbol;
return $this;
}
public function getSymbol() {
return $this->symbol;
}
public function setCommit($commit) {
$this->commit = $commit;
return $this;
}
public function getCommit() {
return $this->commit;
}
}

View file

@ -0,0 +1,41 @@
<?php
final class ArcanistLandTarget
extends Phobject {
private $remote;
private $ref;
private $commit;
public function setRemote($remote) {
$this->remote = $remote;
return $this;
}
public function getRemote() {
return $this->remote;
}
public function setRef($ref) {
$this->ref = $ref;
return $this;
}
public function getRef() {
return $this->ref;
}
public function getLandTargetKey() {
return sprintf('%s/%s', $this->getRemote(), $this->getRef());
}
public function setLandTargetCommit($commit) {
$this->commit = $commit;
return $this;
}
public function getLandTargetCommit() {
return $this->commit;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,603 @@
<?php
final class ArcanistMercurialLandEngine
extends ArcanistLandEngine {
protected function getDefaultSymbols() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$bookmark = $api->getActiveBookmark();
if ($bookmark !== null) {
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the active bookmark, "%s".',
$bookmark));
return array($bookmark);
}
$branch = $api->getBranchName();
if ($branch !== null) {
$log->writeStatus(
pht('SOURCE'),
pht(
'Landing the current branch, "%s".',
$branch));
return array($branch);
}
throw new Exception(pht('TODO: Operate on raw revision.'));
}
protected function resolveSymbols(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
foreach ($symbols as $symbol) {
$raw_symbol = $symbol->getSymbol();
if ($api->isBookmark($raw_symbol)) {
$hash = $api->getBookmarkCommitHash($raw_symbol);
$symbol->setCommit($hash);
// TODO: Set that this is a bookmark?
continue;
}
if ($api->isBranch($raw_symbol)) {
$hash = $api->getBranchCommitHash($raw_symbol);
$symbol->setCommit($hash);
// TODO: Set that this is a branch?
continue;
}
throw new PhutilArgumentUsageException(
pht(
'Symbol "%s" is not a bookmark or branch name.',
$raw_symbol));
}
}
protected function selectOntoRemote(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$remote = $this->newOntoRemote($symbols);
// TODO: Verify this remote actually exists.
return $remote;
}
private function newOntoRemote(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$remote = $this->getOntoRemoteArgument();
if ($remote !== null) {
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected with the "--onto-remote" flag.',
$remote));
return $remote;
}
$remote = $this->getOntoRemoteFromConfiguration();
if ($remote !== null) {
$remote_key = $this->getOntoRemoteConfigurationKey();
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Remote "%s" was selected by reading "%s" configuration.',
$remote,
$remote_key));
return $remote;
}
$api = $this->getRepositoryAPI();
$default_remote = 'default';
$log->writeStatus(
pht('ONTO REMOTE'),
pht(
'Landing onto remote "%s", the default remote under Mercurial.',
$default_remote));
return $default_remote;
}
protected function selectOntoRefs(array $symbols) {
assert_instances_of($symbols, 'ArcanistLandSymbol');
$log = $this->getLogEngine();
$onto = $this->getOntoArguments();
if ($onto) {
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected with the "--onto" flag: %s.',
implode(', ', $onto)));
return $onto;
}
$onto = $this->getOntoFromConfiguration();
if ($onto) {
$onto_key = $this->getOntoConfigurationKey();
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Refs were selected by reading "%s" configuration: %s.',
$onto_key,
implode(', ', $onto)));
return $onto;
}
$api = $this->getRepositoryAPI();
$default_onto = 'default';
$log->writeStatus(
pht('ONTO TARGET'),
pht(
'Landing onto target "%s", the default target under Mercurial.',
$default_onto));
return array($default_onto);
}
protected function confirmOntoRefs(array $onto_refs) {
foreach ($onto_refs as $onto_ref) {
if (!strlen($onto_ref)) {
throw new PhutilArgumentUsageException(
pht(
'Selected "onto" ref "%s" is invalid: the empty string is not '.
'a valid ref.',
$onto_ref));
}
}
}
protected function selectIntoRemote() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
if ($this->getIntoLocalArgument()) {
$this->setIntoLocal(true);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into local state, selected with the "--into-local" '.
'flag.'));
return;
}
$into = $this->getIntoRemoteArgument();
if ($into !== null) {
// TODO: Verify that this is a valid path.
// TODO: Allow a raw URI?
$this->setIntoRemote($into);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s", selected with the "--into" flag.',
$into));
return;
}
$onto = $this->getOntoRemote();
$this->setIntoRemote($onto);
$log->writeStatus(
pht('INTO REMOTE'),
pht(
'Will merge into remote "%s" by default, because this is the remote '.
'the change is landing onto.',
$onto));
}
protected function selectIntoRef() {
$log = $this->getLogEngine();
if ($this->getIntoEmptyArgument()) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into empty state, selected with the "--into-empty" '.
'flag.'));
return;
}
$into = $this->getIntoArgument();
if ($into !== null) {
$this->setIntoRef($into);
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s", selected with the "--into" flag.',
$into));
return;
}
$ontos = $this->getOntoRefs();
$onto = head($ontos);
$this->setIntoRef($onto);
if (count($ontos) > 1) {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the first '.
'"onto" target.',
$onto));
} else {
$log->writeStatus(
pht('INTO TARGET'),
pht(
'Will merge into target "%s" by default, because this is the "onto" '.
'target.',
$onto));
}
}
protected function selectIntoCommit() {
// Make sure that our "into" target is valid.
$log = $this->getLogEngine();
if ($this->getIntoEmpty()) {
// If we're running under "--into-empty", we don't have to do anything.
$log->writeStatus(
pht('INTO COMMIT'),
pht('Preparing merge into the empty state.'));
return null;
}
if ($this->getIntoLocal()) {
// If we're running under "--into-local", just make sure that the
// target identifies some actual commit.
$api = $this->getRepositoryAPI();
$local_ref = $this->getIntoRef();
// TODO: This error handling could probably be cleaner.
$into_commit = $api->getCanonicalRevisionName($local_ref);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into local target "%s", at commit "%s".',
$local_ref,
$this->getDisplayHash($into_commit)));
return $into_commit;
}
$target = id(new ArcanistLandTarget())
->setRemote($this->getIntoRemote())
->setRef($this->getIntoRef());
$commit = $this->fetchTarget($target);
if ($commit !== null) {
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into "%s" from remote "%s", at commit "%s".',
$target->getRef(),
$target->getRemote(),
$this->getDisplayHash($commit)));
return $commit;
}
// If we have no valid target and the user passed "--into" explicitly,
// treat this as an error. For example, "arc land --into Q --onto Q",
// where "Q" does not exist, is an error.
if ($this->getIntoArgument()) {
throw new PhutilArgumentUsageException(
pht(
'Ref "%s" does not exist in remote "%s".',
$target->getRef(),
$target->getRemote()));
}
// Otherwise, treat this as implying "--into-empty". For example,
// "arc land --onto Q", where "Q" does not exist, is equivalent to
// "arc land --into-empty --onto Q".
$this->setIntoEmpty(true);
$log->writeStatus(
pht('INTO COMMIT'),
pht(
'Preparing merge into the empty state to create target "%s" '.
'in remote "%s".',
$target->getRef(),
$target->getRemote()));
return null;
}
private function fetchTarget(ArcanistLandTarget $target) {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
// TODO: Support bookmarks.
// TODO: Deal with bookmark save/restore behavior.
// TODO: Format this nicely with passthru.
// TODO: Raise a good error message when the ref does not exist.
$api->execPassthru(
'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);
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);
}
}
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',
$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();
}
}

View file

@ -0,0 +1,36 @@
<?php
final class ArcanistMercurialCommitMessageHardpointQuery
extends ArcanistWorkflowMercurialHardpointQuery {
public function getHardpoints() {
return array(
ArcanistCommitRef::HARDPOINT_MESSAGE,
);
}
protected function canLoadRef(ArcanistRef $ref) {
return ($ref instanceof ArcanistCommitRef);
}
public function loadHardpoint(array $refs, $hardpoint) {
$api = $this->getRepositoryAPI();
$hashes = mpull($refs, 'getCommitHash');
$unique_hashes = array_fuse($hashes);
// TODO: Batch this properly and make it future oriented.
$messages = array();
foreach ($unique_hashes as $unique_hash) {
$messages[$unique_hash] = $api->getCommitMessage($unique_hash);
}
foreach ($hashes as $ref_key => $hash) {
$hashes[$ref_key] = $messages[$hash];
}
yield $this->yieldMap($hashes);
}
}

View file

@ -0,0 +1,76 @@
<?php
final class ArcanistMercurialWorkingCopyRevisionHardpointQuery
extends ArcanistWorkflowMercurialHardpointQuery {
public function getHardpoints() {
return array(
ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS,
);
}
protected function canLoadRef(ArcanistRef $ref) {
return ($ref instanceof ArcanistWorkingCopyStateRef);
}
public function loadHardpoint(array $refs, $hardpoint) {
yield $this->yieldRequests(
$refs,
array(
ArcanistWorkingCopyStateRef::HARDPOINT_COMMITREF,
));
// TODO: This has a lot in common with the Git query in the same role.
$hashes = array();
$map = array();
foreach ($refs as $ref_key => $ref) {
$commit = $ref->getCommitRef();
$commit_hashes = array();
$commit_hashes[] = array(
'hgcm',
$commit->getCommitHash(),
);
foreach ($commit_hashes as $hash) {
$hashes[] = $hash;
$hash_key = $this->getHashKey($hash);
$map[$hash_key][$ref_key] = $ref;
}
}
$results = array_fill_keys(array_keys($refs), array());
if ($hashes) {
$revisions = (yield $this->yieldConduit(
'differential.query',
array(
'commitHashes' => $hashes,
)));
foreach ($revisions as $dict) {
$revision_hashes = idx($dict, 'hashes');
if (!$revision_hashes) {
continue;
}
$revision_ref = ArcanistRevisionRef::newFromConduitQuery($dict);
foreach ($revision_hashes as $revision_hash) {
$hash_key = $this->getHashKey($revision_hash);
$state_refs = idx($map, $hash_key, array());
foreach ($state_refs as $ref_key => $state_ref) {
$results[$ref_key][] = $revision_ref;
}
}
}
}
yield $this->yieldMap($results);
}
private function getHashKey(array $hash) {
return $hash[0].':'.$hash[1];
}
}

View file

@ -0,0 +1,11 @@
<?php
abstract class ArcanistWorkflowMercurialHardpointQuery
extends ArcanistRuntimeHardpointQuery {
final protected function canLoadHardpoint() {
$api = $this->getRepositoryAPI();
return ($api instanceof ArcanistMercurialAPI);
}
}

View file

@ -29,7 +29,7 @@ final class ArcanistRevisionBuildableHardpointQuery
$buildables = (yield $this->yieldConduitSearch( $buildables = (yield $this->yieldConduitSearch(
'harbormaster.buildable.search', 'harbormaster.buildable.search',
array( array(
'objectPHIDs' => $diff_map, 'objectPHIDs' => array_values($diff_map),
'manual' => false, 'manual' => false,
))); )));

View file

@ -1564,6 +1564,11 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI {
return ($uri !== null); return ($uri !== null);
} }
public function isFetchableRemote($remote_name) {
$uri = $this->getGitRemoteFetchURI($remote_name);
return ($uri !== null);
}
private function getGitRemoteFetchURI($remote_name) { private function getGitRemoteFetchURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = false); return $this->getGitRemoteURI($remote_name, $for_push = false);
} }
@ -1739,4 +1744,13 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI {
return false; return false;
} }
protected function newLandEngine() {
return new ArcanistGitLandEngine();
}
public function newLocalState() {
return id(new ArcanistGitLocalState())
->setRepositoryAPI($this);
}
} }

View file

@ -534,6 +534,8 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
} }
public function getAllBranches() { public function getAllBranches() {
// TODO: This is wrong, and returns bookmarks.
list($branch_info) = $this->execxLocal('bookmarks'); list($branch_info) = $this->execxLocal('bookmarks');
if (trim($branch_info) == 'no bookmarks set') { if (trim($branch_info) == 'no bookmarks set') {
return array(); return array();
@ -548,10 +550,14 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
$return = array(); $return = array();
foreach ($matches as $match) { foreach ($matches as $match) {
list(, $current, $name) = $match; list(, $current, $name, $hash) = $match;
list($id, $hash) = explode(':', $hash);
$return[] = array( $return[] = array(
'current' => (bool)$current, 'current' => (bool)$current,
'name' => rtrim($name), 'name' => rtrim($name),
'hash' => $hash,
); );
} }
return $return; return $return;
@ -562,9 +568,13 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
$refs = array(); $refs = array();
foreach ($branches as $branch) { foreach ($branches as $branch) {
$commit_ref = $this->newCommitRef()
->setCommitHash($branch['hash']);
$refs[] = $this->newBranchRef() $refs[] = $this->newBranchRef()
->setBranchName($branch['name']) ->setBranchName($branch['name'])
->setIsCurrentBranch($branch['current']); ->setIsCurrentBranch($branch['current'])
->attachCommitRef($commit_ref);
} }
return $refs; return $refs;
@ -1064,6 +1074,46 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
return $bookmarks; 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) { private function splitBranchOrBookmarkLine($line) {
// branches and bookmarks are printed in the format: // branches and bookmarks are printed in the format:
// default 0:a5ead76cdf85 (inactive) // default 0:a5ead76cdf85 (inactive)
@ -1108,4 +1158,14 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
return $env; return $env;
} }
protected function newLandEngine() {
return new ArcanistMercurialLandEngine();
}
public function newLocalState() {
return id(new ArcanistMercurialLocalState())
->setRepositoryAPI($this);
}
} }

View file

@ -734,4 +734,19 @@ abstract class ArcanistRepositoryAPI extends Phobject {
final public function newBranchRef() { final public function newBranchRef() {
return new ArcanistBranchRef(); return new ArcanistBranchRef();
} }
final public function getLandEngine() {
$engine = $this->newLandEngine();
if ($engine) {
$engine->setRepositoryAPI($this);
}
return $engine;
}
protected function newLandEngine() {
return null;
}
} }

View file

@ -0,0 +1,60 @@
<?php
final class ArcanistMercurialLocalState
extends ArcanistRepositoryLocalState {
private $localCommit;
private $localRef;
private $localPath;
public function getLocalRef() {
return $this->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;
}
protected function getIgnoreHints() {
// TODO: Provide this.
return array();
}
protected function saveStash() {
return null;
}
protected function restoreStash($stash_ref) {
return null;
}
protected function discardStash($stash_ref) {
return null;
}
}

View file

@ -151,6 +151,9 @@ abstract class ArcanistRepositoryLocalState
$this->executeSaveLocalState(); $this->executeSaveLocalState();
$this->shouldRestore = true; $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; return $this;
} }

File diff suppressed because it is too large Load diff

View file

@ -244,7 +244,7 @@ abstract class ArcanistWorkflow extends Phobject {
return $err; return $err;
} }
final protected function getLogEngine() { final public function getLogEngine() {
return $this->getRuntime()->getLogEngine(); return $this->getRuntime()->getLogEngine();
} }
@ -2357,6 +2357,15 @@ abstract class ArcanistWorkflow extends Phobject {
$prompts = $this->newPrompts(); $prompts = $this->newPrompts();
assert_instances_of($prompts, 'ArcanistPrompt'); assert_instances_of($prompts, 'ArcanistPrompt');
$prompts[] = $this->newPrompt('arc.state.stash')
->setDescription(
pht(
'Prompts the user to stash changes and continue when the '.
'working copy has untracked, uncommitted. or unstaged '.
'changes.'));
// TODO: Swap to ArrayCheck?
$map = array(); $map = array();
foreach ($prompts as $prompt) { foreach ($prompts as $prompt) {
$key = $prompt->getKey(); $key = $prompt->getKey();
@ -2380,7 +2389,7 @@ abstract class ArcanistWorkflow extends Phobject {
return $this->promptMap; return $this->promptMap;
} }
protected function getPrompt($key) { final public function getPrompt($key) {
$map = $this->getPromptMap(); $map = $this->getPromptMap();
$prompt = idx($map, $key); $prompt = idx($map, $key);