Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2891727
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
19 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php
index 945a5f39..f2b5d632 100644
--- a/src/land/ArcanistGitLandEngine.php
+++ b/src/land/ArcanistGitLandEngine.php
@@ -1,393 +1,529 @@
<?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->writeInfo(
pht('HOLD'),
pht('Holding change locally, it has not been pushed.'));
} 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));
$api->execxLocal(
'fetch -- %s %s',
$this->getTargetRemote(),
$this->getTargetOnto());
}
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()) {
$api->execxLocal(
'merge --no-stat --no-commit --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 --');
- // TODO: Maybe throw a better or more helpful exception here?
+ 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()));
+ }
- throw $ex;
+ list($changes) = $api->execxLocal('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()));
list($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.
- echo tsprintf(
- "%s\n",
+ $this->writeInfo(
+ pht('RESTORE'),
pht('Switching back to "%s".', $this->localRef));
$this->restoreLocalState();
return;
}
- list($err) = $api->execManualLocal(
- 'rev-parse --verify %s',
- $this->getTargetOnto());
- if ($err) {
- echo tsprintf(
- "%s\n",
+ // 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()) {
+ $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" does not exist, staying on detached HEAD.',
- $this->getTargetOnto()));
+ 'Local branch "%s" tracks an upstream but following it leads to '.
+ 'a local cycle, staying on detached HEAD.',
+ $local_branch));
return;
}
- list($err, $upstream) = $api->execManualLocal(
- 'rev-parse --verify --symbolic-full-name %s',
- $this->getTargetOnto().'@{upstream}');
- if ($err) {
- echo tsprintf(
- "%s\n",
+ if (!$path->isConnectedToRemote()) {
+ $this->writeInfo(
+ pht('UPDATE'),
pht(
- 'Local branch "%s" has no upstream, staying on detached HEAD.',
- $this->getTargetOnto()));
+ 'Local branch "%s" is not connected to a remote, staying on '.
+ 'detached HEAD.',
+ $local_branch));
return;
}
- $upstream = trim($upstream);
- $expect_upstream = 'refs/remotes/'.$this->getTargetFullRef();
- if ($upstream != $expect_upstream) {
- echo tsprintf(
- "%s\n",
+ $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" tracks remote "%s" (not target remote "%s"), '.
- 'staying on detached HEAD.',
- $this->getTargetOnto(),
- $upstream,
- $expect_upstream));
+ '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;
}
- list($stdout) = $api->execxLocal(
- 'log %s..%s --',
- $this->mergedRef,
- $this->getTargetOnto());
- $stdout = trim($stdout);
+ // 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.
- if (!strlen($stdout)) {
- echo tsprintf(
- "%s\n",
+ $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" tracks target remote "%s", checking out and '.
- 'pulling changes.',
- $this->getTargetOnto(),
- $this->getTargetFullRef()));
+ '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));
- $api->execxLocal('checkout %s --', $this->getTargetOnto());
- $api->execxLocal('pull --');
+ $this->writeInfo(
+ pht('CHECKOUT'),
+ pht(
+ 'Checking out "%s".',
+ $local_branch));
+
+ $api->execxLocal('checkout %s --', $local_branch);
return;
}
- if ($this->getTargetOnto() !== $this->getSourceRef()) {
- echo tsprintf(
- "%s\n",
+ // 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" is ahead of remote "%s". Checking out but '.
- 'not pulling changes.',
- $this->getTargetOnto(),
+ 'Local "%s" tracks target remote "%s", checking out and '.
+ 'pulling changes.',
+ $local_branch,
$this->getTargetFullRef()));
- $api->execxLocal('checkout %s --', $this->getTargetOnto());
+ foreach ($cascade_targets as $cascade_branch) {
+ $this->writeInfo(
+ pht('PULL'),
+ pht(
+ 'Checking out and pulling "%s".',
+ $cascade_branch));
- return;
+ $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.
- echo tsprintf(
- "%s\n",
+ $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 --', $this->getTargetOnto());
+ $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,
);
}
}
diff --git a/src/repository/api/ArcanistGitUpstreamPath.php b/src/repository/api/ArcanistGitUpstreamPath.php
index 1d3feed0..ad487106 100644
--- a/src/repository/api/ArcanistGitUpstreamPath.php
+++ b/src/repository/api/ArcanistGitUpstreamPath.php
@@ -1,82 +1,90 @@
<?php
final class ArcanistGitUpstreamPath extends Phobject {
private $path = array();
const TYPE_LOCAL = 'local';
const TYPE_REMOTE = 'remote';
public function addUpstream($key, array $spec) {
$this->path[$key] = $spec;
return $this;
}
+ public function removeUpstream($key) {
+ unset($this->path[$key]);
+ return $this;
+ }
+
public function getUpstream($key) {
return idx($this->path, $key);
}
public function getLength() {
return count($this->path);
}
/**
* Test if this path eventually connects to a remote.
*
* @return bool True if the path connects to a remote.
*/
public function isConnectedToRemote() {
$last = last($this->path);
if (!$last) {
return false;
}
return ($last['type'] == self::TYPE_REMOTE);
}
+ public function getLocalBranches() {
+ return array_keys($this->path);
+ }
public function getRemoteBranchName() {
if (!$this->isConnectedToRemote()) {
return null;
}
return idx(last($this->path), 'name');
}
public function getRemoteRemoteName() {
if (!$this->isConnectedToRemote()) {
return null;
}
return idx(last($this->path), 'remote');
}
/**
* If this path contains a cycle, return a description of it.
*
* @return list<string>|null Cycle, if the path contains one.
*/
public function getCycle() {
$last = last($this->path);
if (!$last) {
return null;
}
if (empty($last['cycle'])) {
return null;
}
$parts = array();
foreach ($this->path as $key => $item) {
$parts[] = $key;
}
$parts[] = $item['name'];
$parts[] = pht('...');
return $parts;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jan 19, 15:40 (3 w, 8 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1126087
Default Alt Text
(19 KB)
Attached To
Mode
rARC Arcanist
Attached
Detach File
Event Timeline
Log In to Comment