mirror of
https://we.phorge.it/source/arcanist.git
synced 2025-02-01 01:18:21 +01:00
d0353d2381
Test Plan: Ran `phpstan analyze -a autoload.php arcanist/src` with `autoload.php` containing: <?php require_once 'libphutil/src/__phutil_library_init__.php'; require_once 'arcanist/src/__phutil_library_init__.php'; Reviewers: #blessed_reviewers, epriestley Reviewed By: #blessed_reviewers, epriestley Subscribers: Korvin Differential Revision: https://secure.phabricator.com/D17367
595 lines
17 KiB
PHP
595 lines
17 KiB
PHP
<?php
|
|
|
|
final class ArcanistGitLandEngine
|
|
extends ArcanistLandEngine {
|
|
|
|
private $localRef;
|
|
private $localCommit;
|
|
private $sourceCommit;
|
|
private $mergedRef;
|
|
private $restoreWhenDestroyed;
|
|
|
|
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) {
|
|
throw new Exception(
|
|
pht(
|
|
'Branch "%s" does not exist in 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();
|
|
|
|
$this->writeInfo(
|
|
pht('FETCH'),
|
|
pht('Fetching %s...', $ref));
|
|
|
|
// 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.
|
|
|
|
$err = $api->execPassthru(
|
|
'fetch --quiet -- %s %s',
|
|
$this->getTargetRemote(),
|
|
$this->getTargetOnto());
|
|
|
|
if ($err) {
|
|
throw new ArcanistUsageException(
|
|
pht(
|
|
'Fetch failed! Fix the error and run "%s" again.',
|
|
'arc land'));
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
$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 "%s" again.',
|
|
'arc land'));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 ($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 --');
|
|
}
|
|
|
|
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();
|
|
|
|
if ($this->getSourceRef() == $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?
|
|
|
|
$recovery_command = csprintf(
|
|
'git checkout -b %R %R',
|
|
$this->getSourceRef(),
|
|
$this->sourceCommit);
|
|
|
|
echo tsprintf(
|
|
"%s\n",
|
|
pht('Cleaning up branch "%s"...', $this->getSourceRef()));
|
|
|
|
echo tsprintf(
|
|
"%s\n",
|
|
pht('(Use `%s` if you want it back.)', $recovery_command));
|
|
|
|
$api->execxLocal('branch -D -- %s', $this->getSourceRef());
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
$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.'));
|
|
}
|
|
|
|
}
|