Changeset View
Changeset View
Standalone View
Standalone View
src/land/engine/ArcanistGitLandEngine.php
Show First 20 Lines • Show All 294 Lines • ▼ Show 20 Lines | protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { | ||||
$log->writeStatus( | $log->writeStatus( | ||||
pht('MERGING'), | pht('MERGING'), | ||||
pht( | pht( | ||||
'%s %s', | '%s %s', | ||||
$api->getDisplayHash($max_hash), | $api->getDisplayHash($max_hash), | ||||
$max_commit->getDisplaySummary())); | $max_commit->getDisplaySummary())); | ||||
$argv = array(); | // See T13576. We have several different approaches for performing the | ||||
$argv[] = '--no-stat'; | // actual merge. | ||||
$argv[] = '--no-commit'; | // | ||||
// In the simplest case, we're using the "merge" strategy. This means | |||||
// When we're merging into the empty state, Git refuses to perform the | // we always want to merge the entire history, and we can just use a | ||||
// merge until we tell it explicitly that we're doing something unusual. | // "git merge" to accomplish our goal. No other approach is permissible | ||||
if ($is_empty) { | // here, so if that doesn't work we're all done and just tell the user | ||||
$argv[] = '--allow-unrelated-histories'; | // to go resolve conflicts. | ||||
} | // | ||||
// If we're using the "squash" strategy, we may be merging a range of | |||||
// commits that aren't direct descendants of any ancestor of the state | |||||
// we're merging into. That is, there may be some ancestors of this | |||||
// range that we do NOT want to merge. A simple way to get into this | |||||
// state is to use "--pick". We need to slice off only the commits we | |||||
// want to merge to ensure we don't bring anything extra along. | |||||
// | |||||
// If history is simple and linear, we can select this slice with | |||||
// "git rebase". However, if history includes merge commits, it seems | |||||
// as though there are many cases where a (non-interactive) rebase is | |||||
// doomed to failure. | |||||
// | |||||
// If a "git rebase" fails, try to "reduce" the slice first, by using | |||||
// a "git merge --squash" to collapse the whole slice on top of its | |||||
// parent. This produces a single non-merge commit with all the changes, | |||||
// which we can then rebase and merge. | |||||
if ($this->isSquashStrategy()) { | $try = array(); | ||||
// NOTE: We're explicitly specifying "--ff" to override the presence | if ($this->isSquashStrategy() && !$is_empty) { | ||||
// of "merge.ff" options in user configuration. | $try[] = 'rebase-merge'; | ||||
$argv[] = '--ff'; | $try[] = 'reduce-rebase-merge'; | ||||
$argv[] = '--squash'; | |||||
} else { | } else { | ||||
$argv[] = '--no-ff'; | $try[] = 'merge'; | ||||
} | } | ||||
$argv[] = '--'; | $merge_exceptions = array(); | ||||
$merge_complete = false; | |||||
foreach ($try as $approach) { | |||||
$reduce_min = null; | |||||
$reduce_max = null; | |||||
$is_rebasing = false; | $rebase_min = null; | ||||
$is_merging = false; | $rebase_max = null; | ||||
try { | |||||
if ($this->isSquashStrategy() && !$is_empty) { | |||||
// If we're performing a squash merge, we're going to rebase the | |||||
// commit range first. We only want to merge the specific commits | |||||
// in the range, and merging too much can create conflicts. | |||||
$api->execxLocal('checkout %s --', $max_hash); | $merge_hash = null; | ||||
$force_resolve = false; | |||||
$is_rebasing = true; | switch ($approach) { | ||||
$api->execxLocal( | case 'reduce-rebase-merge': | ||||
'rebase --onto %s -- %s', | $reduce_min = $min_hash; | ||||
$into_commit, | $reduce_max = $max_hash; | ||||
$min_hash.'^'); | |||||
$is_rebasing = false; | |||||
$merge_hash = $api->getCanonicalRevisionName('HEAD'); | $log->writeStatus( | ||||
} else { | pht('MERGE'), | ||||
pht('Attempting to reduce and rebase changes.')); | |||||
break; | |||||
case 'rebase-merge': | |||||
$rebase_min = $min_hash; | |||||
$rebase_max = $max_hash; | |||||
$log->writeStatus( | |||||
pht('MERGE'), | |||||
pht('Attempting to rebase changes.')); | |||||
break; | |||||
case 'merge': | |||||
$merge_hash = $max_hash; | $merge_hash = $max_hash; | ||||
$log->writeStatus( | |||||
pht('MERGE'), | |||||
pht('Attempting to merge changes.')); | |||||
break; | |||||
default: | |||||
throw new Exception( | |||||
pht( | |||||
'Unknown merge approach "%s".', | |||||
$approach)); | |||||
} | } | ||||
$api->execxLocal('checkout %s --', $into_commit); | try { | ||||
if ($reduce_max) { | |||||
$reduce_dst = $reduce_min.'^'; | |||||
$argv[] = $merge_hash; | // Squash the "into" state on top of the range. The goal is to | ||||
// guarantee that there are no unresolved conflicts between the | |||||
// maximum commit and the "into" state, because we're going to | |||||
// force conflicts to resolve in our favor later. | |||||
$is_merging = true; | $this->applyMergeOperation( | ||||
$api->execxLocal('merge %Ls', $argv); | $into_commit, | ||||
$is_merging = false; | $reduce_max, | ||||
true, | |||||
$is_empty); | |||||
$join_hash = $this->applyCommitOperation( | |||||
sprintf( | |||||
'arc land: join (%s -> %s)', | |||||
$api->getDisplayHash($into_commit), | |||||
$api->getDisplayHash($reduce_max)), | |||||
null, | |||||
null, | |||||
$allow_empty = true); | |||||
// Squash the range, including the new merge, into a single | |||||
// commit. The goal here is to produce a new range with no merge | |||||
// commits so we can rebase it (we'll produce a sequence exactly | |||||
// one commit long). | |||||
$this->applyMergeOperation( | |||||
$join_hash, | |||||
$reduce_dst, | |||||
true, | |||||
$is_empty); | |||||
$reduce_hash = $this->applyCommitOperation( | |||||
sprintf( | |||||
'arc land: reduce (%s..%s -> %s)', | |||||
$api->getDisplayHash($reduce_min), | |||||
$api->getDisplayHash($reduce_max), | |||||
$reduce_dst)); | |||||
// We've reduced the range into a new range that is one commit | |||||
// long, has no merge commits, and has no conflicts against the | |||||
// "into" state. | |||||
// We'll rebase it and force conflicts to resolve in favor of the | |||||
// reduced state. The hope is that we've taken sufficient steps to | |||||
// guarantee this resolution is always reasonable. | |||||
$rebase_min = $reduce_hash; | |||||
$rebase_max = $reduce_hash; | |||||
$force_resolve = true; | |||||
} | |||||
if ($rebase_max) { | |||||
$merge_hash = $this->applyRebaseOperation( | |||||
$rebase_min, | |||||
$rebase_max, | |||||
$into_commit, | |||||
$force_resolve); | |||||
} | |||||
$this->applyMergeOperation( | |||||
$merge_hash, | |||||
$into_commit, | |||||
$this->isSquashStrategy(), | |||||
$is_empty); | |||||
$log->writeStatus(pht('DONE'), pht('Merge succeeded.')); | |||||
$merge_complete = true; | |||||
} catch (CommandException $ex) { | } catch (CommandException $ex) { | ||||
$merge_exceptions[] = $ex; | |||||
} | |||||
if ($merge_complete) { | |||||
break; | |||||
} | |||||
} | |||||
if (!$merge_complete) { | |||||
$direct_symbols = $max_commit->getDirectSymbols(); | $direct_symbols = $max_commit->getDirectSymbols(); | ||||
$indirect_symbols = $max_commit->getIndirectSymbols(); | $indirect_symbols = $max_commit->getIndirectSymbols(); | ||||
if ($direct_symbols) { | if ($direct_symbols) { | ||||
$message = pht( | $message = pht( | ||||
'Local commit "%s" (%s) does not merge cleanly into "%s". '. | 'Local commit "%s" (%s) does not merge cleanly into "%s". '. | ||||
'Merge or rebase local changes so they can merge cleanly.', | 'Rebase or merge local changes so they can merge cleanly.', | ||||
$api->getDisplayHash($max_hash), | $api->getDisplayHash($max_hash), | ||||
$this->getDisplaySymbols($direct_symbols), | $this->getDisplaySymbols($direct_symbols), | ||||
$api->getDisplayHash($into_commit)); | $api->getDisplayHash($into_commit)); | ||||
} else if ($indirect_symbols) { | } else if ($indirect_symbols) { | ||||
$message = pht( | $message = pht( | ||||
'Local commit "%s" (reachable from: %s) does not merge cleanly '. | 'Local commit "%s" (reachable from: %s) does not merge cleanly '. | ||||
'into "%s". Merge or rebase local changes so they can merge '. | 'into "%s". Rebase or merge local changes so they can merge '. | ||||
'cleanly.', | 'cleanly.', | ||||
$api->getDisplayHash($max_hash), | $api->getDisplayHash($max_hash), | ||||
$this->getDisplaySymbols($indirect_symbols), | $this->getDisplaySymbols($indirect_symbols), | ||||
$api->getDisplayHash($into_commit)); | $api->getDisplayHash($into_commit)); | ||||
} else { | } else { | ||||
$message = pht( | $message = pht( | ||||
'Local commit "%s" does not merge cleanly into "%s". Merge or '. | 'Local commit "%s" does not merge cleanly into "%s". Rebase or '. | ||||
'rebase local changes so they can merge cleanly.', | 'merge local changes so they can merge cleanly.', | ||||
$api->getDisplayHash($max_hash), | $api->getDisplayHash($max_hash), | ||||
$api->getDisplayHash($into_commit)); | $api->getDisplayHash($into_commit)); | ||||
} | } | ||||
echo tsprintf( | echo tsprintf( | ||||
"\n%!\n%W\n\n", | "\n%!\n%W\n\n", | ||||
pht('MERGE CONFLICT'), | pht('MERGE CONFLICT'), | ||||
$message); | $message); | ||||
if ($this->getHasUnpushedChanges()) { | if ($this->getHasUnpushedChanges()) { | ||||
echo tsprintf( | echo tsprintf( | ||||
"%?\n\n", | "%?\n\n", | ||||
pht( | pht( | ||||
'Use "--incremental" to merge and push changes one by one.')); | 'Use "--incremental" to merge and push changes one by one.')); | ||||
} | } | ||||
if ($is_rebasing) { | |||||
$api->execManualLocal('rebase --abort'); | |||||
} | |||||
if ($is_merging) { | |||||
$api->execManualLocal('merge --abort'); | |||||
} | |||||
if ($is_merging || $is_rebasing) { | |||||
$api->execManualLocal('reset --hard HEAD --'); | |||||
} | |||||
throw new PhutilArgumentUsageException( | throw new PhutilArgumentUsageException( | ||||
pht('Encountered a merge conflict.')); | pht('Encountered a merge conflict.')); | ||||
} | } | ||||
list($original_author, $original_date) = $this->getAuthorAndDate( | list($original_author, $original_date) = $this->getAuthorAndDate( | ||||
$max_hash); | $max_hash); | ||||
$revision_ref = $set->getRevisionRef(); | $revision_ref = $set->getRevisionRef(); | ||||
$commit_message = $revision_ref->getCommitMessage(); | $commit_message = $revision_ref->getCommitMessage(); | ||||
$future = $api->execFutureLocal( | $new_cursor = $this->applyCommitOperation( | ||||
'commit --author %s --date %s -F - --', | $commit_message, | ||||
$original_author, | $original_author, | ||||
$original_date); | $original_date); | ||||
$future->write($commit_message); | |||||
$future->resolvex(); | |||||
list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD'); | |||||
$new_cursor = trim($stdout); | |||||
if ($is_empty) { | if ($is_empty) { | ||||
// See T12876. If we're landing into the empty state, we just did a fake | // 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 | // 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. | // 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 | // Create a new commit which is the same as the current HEAD, except that | ||||
// it doesn't have the extra parent. | // it doesn't have the extra parent. | ||||
▲ Show 20 Lines • Show All 1,166 Lines • ▼ Show 20 Lines | echo tsprintf( | ||||
'this check.)', | 'this check.)', | ||||
'https://secure.phabricator.com/T13547')); | 'https://secure.phabricator.com/T13547')); | ||||
throw new PhutilArgumentUsageException( | throw new PhutilArgumentUsageException( | ||||
pht( | pht( | ||||
'Desired merge strategy is ambiguous, choose an explicit strategy.')); | 'Desired merge strategy is ambiguous, choose an explicit strategy.')); | ||||
} | } | ||||
private function applyRebaseOperation( | |||||
$src_min, | |||||
$src_max, | |||||
$dst, | |||||
$force_resolve) { | |||||
$api = $this->getRepositoryAPI(); | |||||
$api->execxLocal('checkout %s --', $src_max); | |||||
$argv = array(); | |||||
$argv[] = '--onto'; | |||||
$argv[] = gitsprintf('%s', $dst); | |||||
if ($force_resolve) { | |||||
$argv[] = '--strategy'; | |||||
$argv[] = 'recursive'; | |||||
$argv[] = '--strategy-option'; | |||||
$argv[] = 'theirs'; | |||||
} | |||||
$argv[] = '--'; | |||||
$argv[] = gitsprintf('%s', $src_min.'^'); | |||||
try { | |||||
$api->execxLocal('rebase %Ls', $argv); | |||||
} catch (CommandException $ex) { | |||||
$api->execManualLocal('rebase --abort'); | |||||
$api->execManualLocal('reset --hard HEAD --'); | |||||
throw $ex; | |||||
} | |||||
$merge_hash = $api->getCanonicalRevisionName('HEAD'); | |||||
return $merge_hash; | |||||
} | |||||
private function applyMergeOperation($src, $dst, $is_squash, $is_empty) { | |||||
$api = $this->getRepositoryAPI(); | |||||
$api->execxLocal('checkout %s --', $dst); | |||||
$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 ($is_squash) { | |||||
// 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[] = $src; | |||||
try { | |||||
$api->execxLocal('merge %Ls', $argv); | |||||
} catch (CommandException $ex) { | |||||
$api->execManualLocal('merge --abort'); | |||||
$api->execManualLocal('reset --hard HEAD'); | |||||
throw $ex; | |||||
} | |||||
} | |||||
private function applyCommitOperation( | |||||
$message, | |||||
$author = null, | |||||
$date = null, | |||||
$allow_empty = false) { | |||||
$api = $this->getRepositoryAPI(); | |||||
$argv = array(); | |||||
if ($author !== null) { | |||||
$argv[] = '--author'; | |||||
$argv[] = $author; | |||||
} | |||||
if ($date !== null) { | |||||
$argv[] = '--date'; | |||||
$argv[] = $date; | |||||
} | |||||
if ($allow_empty) { | |||||
$argv[] = '--allow-empty'; | |||||
} | |||||
$future = $api->execFutureLocal( | |||||
'commit %Ls -F - --', | |||||
$argv); | |||||
$future->write($message); | |||||
$future->resolvex(); | |||||
list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD'); | |||||
$new_commit = trim($stdout); | |||||
return $new_commit; | |||||
} | |||||
} | } |