1<?php
2
3final class ArcanistGitLandEngine
4  extends ArcanistLandEngine {
5
6  private $isGitPerforce;
7  private $landTargetCommitMap = array();
8  private $deletedBranches = array();
9
10  private function setIsGitPerforce($is_git_perforce) {
11    $this->isGitPerforce = $is_git_perforce;
12    return $this;
13  }
14
15  private function getIsGitPerforce() {
16    return $this->isGitPerforce;
17  }
18
19  protected function pruneBranches(array $sets) {
20    $api = $this->getRepositoryAPI();
21    $log = $this->getLogEngine();
22
23    $old_commits = array();
24    foreach ($sets as $set) {
25      $hash = last($set->getCommits())->getHash();
26      $old_commits[] = $hash;
27    }
28
29    $branch_map = $this->getBranchesForCommits(
30      $old_commits,
31      $is_contains = false);
32
33    foreach ($branch_map as $branch_name => $branch_hash) {
34      $recovery_command = csprintf(
35        'git checkout -b %s %s',
36        $branch_name,
37        $api->getDisplayHash($branch_hash));
38
39      $log->writeStatus(
40        pht('CLEANUP'),
41        pht('Cleaning up branch "%s". To recover, run:', $branch_name));
42
43      echo tsprintf(
44        "\n    **$** %s\n\n",
45        $recovery_command);
46
47      $api->execxLocal('branch -D -- %s', $branch_name);
48      $this->deletedBranches[$branch_name] = true;
49    }
50  }
51
52  private function getBranchesForCommits(array $hashes, $is_contains) {
53    $api = $this->getRepositoryAPI();
54
55    $format = '%(refname) %(objectname)';
56
57    $result = array();
58    foreach ($hashes as $hash) {
59      if ($is_contains) {
60        $command = csprintf(
61          'for-each-ref --contains %s --format %s --',
62          $hash,
63          $format);
64      } else {
65        $command = csprintf(
66          'for-each-ref --points-at %s --format %s --',
67          $hash,
68          $format);
69      }
70
71      list($foreach_lines) = $api->execxLocal('%C', $command);
72      $foreach_lines = phutil_split_lines($foreach_lines, false);
73
74      foreach ($foreach_lines as $line) {
75        if (!strlen($line)) {
76          continue;
77        }
78
79        $expect_parts = 2;
80        $parts = explode(' ', $line, $expect_parts);
81        if (count($parts) !== $expect_parts) {
82          throw new Exception(
83            pht(
84              'Failed to explode line "%s".',
85              $line));
86        }
87
88        $ref_name = $parts[0];
89        $ref_hash = $parts[1];
90
91        $matches = null;
92        $ok = preg_match('(^refs/heads/(.*)\z)', $ref_name, $matches);
93        if ($ok === false) {
94          throw new Exception(
95            pht(
96              'Failed to match against branch pattern "%s".',
97              $line));
98        }
99
100        if (!$ok) {
101          continue;
102        }
103
104        $result[$matches[1]] = $ref_hash;
105      }
106    }
107
108    // Sort the result so that branches are processed in natural order.
109    $names = array_keys($result);
110    natcasesort($names);
111    $result = array_select_keys($result, $names);
112
113    return $result;
114  }
115
116  protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) {
117    $api = $this->getRepositoryAPI();
118    $log = $this->getLogEngine();
119
120    // This has no effect when we're executing a merge strategy.
121    if (!$this->isSquashStrategy()) {
122      return;
123    }
124
125    $min_commit = head($set->getCommits())->getHash();
126    $old_commit = last($set->getCommits())->getHash();
127    $new_commit = $into_commit;
128
129    $branch_map = $this->getBranchesForCommits(
130      array($old_commit),
131      $is_contains = true);
132
133    $log = $this->getLogEngine();
134    foreach ($branch_map as $branch_name => $branch_head) {
135      // If this branch just points at the old state, don't bother rebasing
136      // it. We'll update or delete it later.
137      if ($branch_head === $old_commit) {
138        continue;
139      }
140
141      $log->writeStatus(
142        pht('CASCADE'),
143        pht(
144          'Rebasing "%s" onto landed state...',
145          $branch_name));
146
147      // If we used "--pick" to select this commit, we want to rebase branches
148      // that descend from it onto its ancestor, not onto the landed change.
149
150      // For example, if the change sequence was "W", "X", "Y", "Z" and we
151      // landed "Y" onto "master" using "--pick", we want to rebase "Z" onto
152      // "X" (so "W" and "X", which it will often depend on, are still
153      // its ancestors), not onto the new "master".
154
155      if ($set->getIsPick()) {
156        $rebase_target = $min_commit.'^';
157      } else {
158        $rebase_target = $new_commit;
159      }
160
161      try {
162        $api->execxLocal(
163          'rebase --onto %s -- %s %s',
164          $rebase_target,
165          $old_commit,
166          $branch_name);
167      } catch (CommandException $ex) {
168        $api->execManualLocal('rebase --abort');
169        $api->execManualLocal('reset --hard HEAD --');
170
171        $log->writeWarning(
172          pht('REBASE CONFLICT'),
173          pht(
174            'Branch "%s" does not rebase cleanly from "%s" onto '.
175            '"%s", skipping.',
176            $branch_name,
177            $api->getDisplayHash($old_commit),
178            $api->getDisplayHash($rebase_target)));
179      }
180    }
181  }
182
183  private function fetchTarget(ArcanistLandTarget $target) {
184    $api = $this->getRepositoryAPI();
185    $log = $this->getLogEngine();
186
187    // NOTE: Although this output isn't hugely useful, we need to passthru
188    // instead of using a subprocess here because `git fetch` may prompt the
189    // user to enter a password if they're fetching over HTTP with basic
190    // authentication. See T10314.
191
192    if ($this->getIsGitPerforce()) {
193      $log->writeStatus(
194        pht('P4 SYNC'),
195        pht(
196          'Synchronizing "%s" from Perforce...',
197          $target->getRef()));
198
199      $err = $this->newPassthru(
200        'p4 sync --silent --branch %s --',
201        $target->getRemote().'/'.$target->getRef());
202      if ($err) {
203        throw new ArcanistUsageException(
204          pht(
205            'Perforce sync failed! Fix the error and run "arc land" again.'));
206      }
207
208      return $this->getLandTargetLocalCommit($target);
209    }
210
211    $exists = $this->getLandTargetLocalExists($target);
212    if (!$exists) {
213      $log->writeWarning(
214        pht('TARGET'),
215        pht(
216          'No local copy of ref "%s" in remote "%s" exists, attempting '.
217          'fetch...',
218          $target->getRef(),
219          $target->getRemote()));
220
221      $this->fetchLandTarget($target, $ignore_failure = true);
222
223      $exists = $this->getLandTargetLocalExists($target);
224      if (!$exists) {
225        return null;
226      }
227
228      $log->writeStatus(
229        pht('FETCHED'),
230        pht(
231          'Fetched ref "%s" from remote "%s".',
232          $target->getRef(),
233          $target->getRemote()));
234
235      return $this->getLandTargetLocalCommit($target);
236    }
237
238    $log->writeStatus(
239      pht('FETCH'),
240      pht(
241        'Fetching "%s" from remote "%s"...',
242        $target->getRef(),
243        $target->getRemote()));
244
245    $this->fetchLandTarget($target, $ignore_failure = false);
246
247    return $this->getLandTargetLocalCommit($target);
248  }
249
250  protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) {
251    $api = $this->getRepositoryAPI();
252    $log = $this->getLogEngine();
253
254    $this->confirmLegacyStrategyConfiguration();
255
256    $is_empty = ($into_commit === null);
257
258    if ($is_empty) {
259      $empty_commit = ArcanistGitRawCommit::newEmptyCommit();
260      $into_commit = $api->writeRawCommit($empty_commit);
261    }
262
263    $commits = $set->getCommits();
264
265    $min_commit = head($commits);
266    $min_hash = $min_commit->getHash();
267
268    $max_commit = last($commits);
269    $max_hash = $max_commit->getHash();
270
271    // NOTE: See T11435 for some history. See PHI1727 for a case where a user
272    // modified their working copy while running "arc land". This attempts to
273    // resist incorrectly detecting simultaneous working copy modifications
274    // as changes.
275
276    list($changes) = $api->execxLocal(
277      'diff --no-ext-diff %s --',
278      gitsprintf(
279        '%s..%s',
280        $into_commit,
281        $max_hash));
282    $changes = trim($changes);
283    if (!strlen($changes)) {
284
285      // TODO: We could make a more significant effort to identify the
286      // human-readable symbol which led us to try to land this ref.
287
288      throw new PhutilArgumentUsageException(
289        pht(
290          'Merging local "%s" into "%s" produces an empty diff. '.
291          'This usually means these changes have already landed.',
292          $api->getDisplayHash($max_hash),
293          $api->getDisplayHash($into_commit)));
294    }
295
296    $log->writeStatus(
297      pht('MERGING'),
298      pht(
299        '%s %s',
300        $api->getDisplayHash($max_hash),
301        $max_commit->getDisplaySummary()));
302
303    $argv = array();
304    $argv[] = '--no-stat';
305    $argv[] = '--no-commit';
306
307    // When we're merging into the empty state, Git refuses to perform the
308    // merge until we tell it explicitly that we're doing something unusual.
309    if ($is_empty) {
310      $argv[] = '--allow-unrelated-histories';
311    }
312
313    if ($this->isSquashStrategy()) {
314      // NOTE: We're explicitly specifying "--ff" to override the presence
315      // of "merge.ff" options in user configuration.
316      $argv[] = '--ff';
317      $argv[] = '--squash';
318    } else {
319      $argv[] = '--no-ff';
320    }
321
322    $argv[] = '--';
323
324    $is_rebasing = false;
325    $is_merging = false;
326    try {
327      if ($this->isSquashStrategy() && !$is_empty) {
328        // If we're performing a squash merge, we're going to rebase the
329        // commit range first. We only want to merge the specific commits
330        // in the range, and merging too much can create conflicts.
331
332        $api->execxLocal('checkout %s --', $max_hash);
333
334        $is_rebasing = true;
335        $api->execxLocal(
336          'rebase --onto %s -- %s',
337          $into_commit,
338          $min_hash.'^');
339        $is_rebasing = false;
340
341        $merge_hash = $api->getCanonicalRevisionName('HEAD');
342      } else {
343        $merge_hash = $max_hash;
344      }
345
346      $api->execxLocal('checkout %s --', $into_commit);
347
348      $argv[] = $merge_hash;
349
350      $is_merging = true;
351      $api->execxLocal('merge %Ls', $argv);
352      $is_merging = false;
353    } catch (CommandException $ex) {
354      $direct_symbols = $max_commit->getDirectSymbols();
355      $indirect_symbols = $max_commit->getIndirectSymbols();
356      if ($direct_symbols) {
357        $message = pht(
358          'Local commit "%s" (%s) does not merge cleanly into "%s". '.
359          'Merge or rebase local changes so they can merge cleanly.',
360          $api->getDisplayHash($max_hash),
361          $this->getDisplaySymbols($direct_symbols),
362          $api->getDisplayHash($into_commit));
363      } else if ($indirect_symbols) {
364        $message = pht(
365          'Local commit "%s" (reachable from: %s) does not merge cleanly '.
366          'into "%s". Merge or rebase local changes so they can merge '.
367          'cleanly.',
368          $api->getDisplayHash($max_hash),
369          $this->getDisplaySymbols($indirect_symbols),
370          $api->getDisplayHash($into_commit));
371      } else {
372        $message = pht(
373          'Local commit "%s" does not merge cleanly into "%s". Merge or '.
374          'rebase local changes so they can merge cleanly.',
375          $api->getDisplayHash($max_hash),
376          $api->getDisplayHash($into_commit));
377      }
378
379      echo tsprintf(
380        "\n%!\n%W\n\n",
381        pht('MERGE CONFLICT'),
382        $message);
383
384      if ($this->getHasUnpushedChanges()) {
385        echo tsprintf(
386          "%?\n\n",
387          pht(
388            'Use "--incremental" to merge and push changes one by one.'));
389      }
390
391      if ($is_rebasing) {
392        $api->execManualLocal('rebase --abort');
393      }
394
395      if ($is_merging) {
396        $api->execManualLocal('merge --abort');
397      }
398
399      if ($is_merging || $is_rebasing) {
400        $api->execManualLocal('reset --hard HEAD --');
401      }
402
403      throw new PhutilArgumentUsageException(
404        pht('Encountered a merge conflict.'));
405    }
406
407    list($original_author, $original_date) = $this->getAuthorAndDate(
408      $max_hash);
409
410    $revision_ref = $set->getRevisionRef();
411    $commit_message = $revision_ref->getCommitMessage();
412
413    $future = $api->execFutureLocal(
414      'commit --author %s --date %s -F - --',
415      $original_author,
416      $original_date);
417    $future->write($commit_message);
418    $future->resolvex();
419
420    list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD');
421    $new_cursor = trim($stdout);
422
423    if ($is_empty) {
424      // See T12876. If we're landing into the empty state, we just did a fake
425      // merge on top of an empty commit. We're now on a commit with all of the
426      // right details except that it has an extra empty commit as a parent.
427
428      // Create a new commit which is the same as the current HEAD, except that
429      // it doesn't have the extra parent.
430
431      $raw_commit = $api->readRawCommit($new_cursor);
432      if ($this->isSquashStrategy()) {
433        $raw_commit->setParents(array());
434      } else {
435        $raw_commit->setParents(array($merge_hash));
436      }
437      $new_cursor = $api->writeRawCommit($raw_commit);
438
439      $api->execxLocal('checkout %s --', $new_cursor);
440    }
441
442    return $new_cursor;
443  }
444
445  protected function pushChange($into_commit) {
446    $api = $this->getRepositoryAPI();
447    $log = $this->getLogEngine();
448
449    if ($this->getIsGitPerforce()) {
450
451      // TODO: Specifying "--onto" more than once is almost certainly an error
452      // in Perforce.
453
454      $log->writeStatus(
455        pht('SUBMITTING'),
456        pht(
457          'Submitting changes to "%s".',
458          $this->getOntoRemote()));
459
460      $config_argv = array();
461
462      // Skip the "git p4 submit" interactive editor workflow. We expect
463      // the commit message that "arc land" has built to be satisfactory.
464      $config_argv[] = '-c';
465      $config_argv[] = 'git-p4.skipSubmitEdit=true';
466
467      // Skip the "git p4 submit" confirmation prompt if the user does not edit
468      // the submit message.
469      $config_argv[] = '-c';
470      $config_argv[] = 'git-p4.skipSubmitEditCheck=true';
471
472      $flags_argv = array();
473
474      // Disable implicit "git p4 rebase" as part of submit. We're allowing
475      // the implicit "git p4 sync" to go through since this puts us in a
476      // state which is generally similar to the state after "git push", with
477      // updated remotes.
478
479      // We could do a manual "git p4 sync" with a more narrow "--branch"
480      // instead, but it's not clear that this is beneficial.
481      $flags_argv[] = '--disable-rebase';
482
483      // Detect moves and submit them to Perforce as move operations.
484      $flags_argv[] = '-M';
485
486      // If we run into a conflict, abort the operation. We expect users to
487      // fix conflicts and run "arc land" again.
488      $flags_argv[] = '--conflict=quit';
489
490      $err = $this->newPassthru(
491        '%LR p4 submit %LR --commit %R --',
492        $config_argv,
493        $flags_argv,
494        $into_commit);
495      if ($err) {
496        throw new ArcanistLandPushFailureException(
497          pht(
498            'Submit failed! Fix the error and run "arc land" again.'));
499      }
500
501      return;
502    }
503
504    $log->writeStatus(
505      pht('PUSHING'),
506      pht('Pushing changes to "%s".', $this->getOntoRemote()));
507
508    $err = $this->newPassthru(
509      'push -- %s %Ls',
510      $this->getOntoRemote(),
511      $this->newOntoRefArguments($into_commit));
512
513    if ($err) {
514      throw new ArcanistLandPushFailureException(
515        pht(
516          'Push failed! Fix the error and run "arc land" again.'));
517    }
518  }
519
520  protected function reconcileLocalState(
521    $into_commit,
522    ArcanistRepositoryLocalState $state) {
523
524    $api = $this->getRepositoryAPI();
525    $log = $this->getWorkflow()->getLogEngine();
526
527    // Try to put the user into the best final state we can. This is very
528    // complicated because users are incredibly creative and their local
529    // branches may, for example, have the same names as branches in the
530    // remote but no relationship to them.
531
532    // First, we're going to try to update these local branches:
533    //
534    //   - the branch we started on originally; and
535    //   - the local upstreams of the branch we started on originally; and
536    //   - the local branch with the same name as the "into" ref; and
537    //   - the local branch with the same name as the "onto" ref.
538    //
539    // These branches may not all exist and may not all be unique.
540    //
541    // To be updated, these branches must:
542    //
543    //   - exist;
544    //   - have not been deleted; and
545    //   - be connected to the remote we pushed into.
546
547    $update_branches = array();
548
549    $local_ref = $state->getLocalRef();
550    if ($local_ref !== null) {
551      $update_branches[] = $local_ref;
552    }
553
554    $local_path = $state->getLocalPath();
555    if ($local_path) {
556      foreach ($local_path->getLocalBranches() as $local_branch) {
557        $update_branches[] = $local_branch;
558      }
559    }
560
561    if (!$this->getIntoEmpty() && !$this->getIntoLocal()) {
562      $update_branches[] = $this->getIntoRef();
563    }
564
565    foreach ($this->getOntoRefs() as $onto_ref) {
566      $update_branches[] = $onto_ref;
567    }
568
569    $update_branches = array_fuse($update_branches);
570
571    // Remove any branches we know we deleted.
572    foreach ($update_branches as $key => $update_branch) {
573      if (isset($this->deletedBranches[$update_branch])) {
574        unset($update_branches[$key]);
575      }
576    }
577
578    // Now, remove any branches which don't actually exist.
579    foreach ($update_branches as $key => $update_branch) {
580      list($err) = $api->execManualLocal(
581        'rev-parse --verify %s',
582        $update_branch);
583      if ($err) {
584        unset($update_branches[$key]);
585      }
586    }
587
588    $is_perforce = $this->getIsGitPerforce();
589    if ($is_perforce) {
590      // If we're in Perforce mode, we don't expect to have a meaningful
591      // path to the remote: the "p4" remote is not a real remote, and
592      // "git p4" commands do not configure branch upstreams to provide
593      // a path.
594
595      // Additionally, we've already set the remote to the right state with an
596      // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a
597      // meaningful operation.
598
599      // We're going to skip everything here and just switch to the most
600      // desirable branch (if we can find one), then reset the state (if that
601      // operation is safe).
602
603      if (!$update_branches) {
604        $log->writeStatus(
605          pht('DETACHED HEAD'),
606          pht(
607            'Unable to find any local branches to update, staying on '.
608            'detached head.'));
609        $state->discardLocalState();
610        return;
611      }
612
613      $dst_branch = head($update_branches);
614      if (!$this->isAncestorOf($dst_branch, $into_commit)) {
615        $log->writeStatus(
616          pht('CHECKOUT'),
617          pht(
618            'Local branch "%s" has unpublished changes, checking it out '.
619            'but leaving them in place.',
620            $dst_branch));
621        $do_reset = false;
622      } else {
623        $log->writeStatus(
624          pht('UPDATE'),
625          pht(
626            'Switching to local branch "%s".',
627            $dst_branch));
628        $do_reset = true;
629      }
630
631      $api->execxLocal('checkout %s --', $dst_branch);
632
633      if ($do_reset) {
634        $api->execxLocal('reset --hard %s --', $into_commit);
635      }
636
637      $state->discardLocalState();
638      return;
639    }
640
641    $onto_refs = array_fuse($this->getOntoRefs());
642
643    $pull_branches = array();
644    foreach ($update_branches as $update_branch) {
645      $update_path = $api->getPathToUpstream($update_branch);
646
647      // Remove any branches which contain upstream cycles.
648      if ($update_path->getCycle()) {
649        $log->writeWarning(
650          pht('LOCAL CYCLE'),
651          pht(
652            'Local branch "%s" tracks an upstream but following it leads to '.
653            'a local cycle, ignoring branch.',
654            $update_branch));
655        continue;
656      }
657
658      // Remove any branches not connected to a remote.
659      if (!$update_path->isConnectedToRemote()) {
660        continue;
661      }
662
663      // Remove any branches connected to a remote other than the remote
664      // we actually pushed to.
665      $remote_name = $update_path->getRemoteRemoteName();
666      if ($remote_name !== $this->getOntoRemote()) {
667        continue;
668      }
669
670      // Remove any branches not connected to a branch we pushed to.
671      $remote_branch = $update_path->getRemoteBranchName();
672      if (!isset($onto_refs[$remote_branch])) {
673        continue;
674      }
675
676      // This is the most-desirable path between some local branch and
677      // an impacted upstream. Select it and continue.
678      $pull_branches = $update_path->getLocalBranches();
679      break;
680    }
681
682    // When we update these branches later, we want to start with the branch
683    // closest to the upstream and work our way down.
684    $pull_branches = array_reverse($pull_branches);
685    $pull_branches = array_fuse($pull_branches);
686
687    // If we started on a branch and it still exists but is not impacted
688    // by the changes we made to the remote (i.e., we aren't actually going
689    // to pull or update it if we continue), just switch back to it now. It's
690    // okay if this branch is completely unrelated to the changes we just
691    // landed.
692
693    if ($local_ref !== null) {
694      if (isset($update_branches[$local_ref])) {
695        if (!isset($pull_branches[$local_ref])) {
696
697          $log->writeStatus(
698            pht('RETURN'),
699            pht(
700              'Returning to original branch "%s" in original state.',
701              $local_ref));
702
703          $state->restoreLocalState();
704          return;
705        }
706      }
707    }
708
709    // Otherwise, if we don't have any path from the upstream to any local
710    // branch, we don't want to switch to some unrelated branch which happens
711    // to have the same name as a branch we interacted with. Just stay where
712    // we ended up.
713
714    $dst_branch = null;
715    if ($pull_branches) {
716      $dst_branch = null;
717      foreach ($pull_branches as $pull_branch) {
718        if (!$this->isAncestorOf($pull_branch, $into_commit)) {
719
720          $log->writeStatus(
721            pht('LOCAL CHANGES'),
722            pht(
723              'Local branch "%s" has unpublished changes, ending updates.',
724              $pull_branch));
725
726          break;
727        }
728
729        $log->writeStatus(
730          pht('UPDATE'),
731          pht(
732            'Updating local branch "%s"...',
733            $pull_branch));
734
735        $api->execxLocal(
736          'branch -f %s %s --',
737          $pull_branch,
738          $into_commit);
739
740        $dst_branch = $pull_branch;
741      }
742    }
743
744    if ($dst_branch) {
745      $log->writeStatus(
746        pht('CHECKOUT'),
747        pht(
748          'Checking out "%s".',
749          $dst_branch));
750
751      $api->execxLocal('checkout %s --', $dst_branch);
752    } else {
753      $log->writeStatus(
754        pht('DETACHED HEAD'),
755        pht(
756          'Unable to find any local branches to update, staying on '.
757          'detached head.'));
758    }
759
760    $state->discardLocalState();
761  }
762
763  private function isAncestorOf($branch, $commit) {
764    $api = $this->getRepositoryAPI();
765
766    list($stdout) = $api->execxLocal(
767      'merge-base -- %s %s',
768      $branch,
769      $commit);
770    $merge_base = trim($stdout);
771
772    list($stdout) = $api->execxLocal(
773      'rev-parse --verify %s',
774      $branch);
775    $branch_hash = trim($stdout);
776
777    return ($merge_base === $branch_hash);
778  }
779
780  private function getAuthorAndDate($commit) {
781    $api = $this->getRepositoryAPI();
782
783    list($info) = $api->execxLocal(
784      'log -n1 --format=%s %s --',
785      '%aD%n%an%n%ae',
786      gitsprintf('%s', $commit));
787
788    $info = trim($info);
789    list($date, $author, $email) = explode("\n", $info, 3);
790
791    return array(
792      "$author <{$email}>",
793      $date,
794    );
795  }
796
797  protected function didHoldChanges($into_commit) {
798    $log = $this->getLogEngine();
799    $local_state = $this->getLocalState();
800
801    if ($this->getIsGitPerforce()) {
802      $message = pht(
803        'Holding changes locally, they have not been submitted.');
804
805      $push_command = csprintf(
806        'git p4 submit -M --commit %s --',
807        $into_commit);
808    } else {
809      $message = pht(
810        'Holding changes locally, they have not been pushed.');
811
812      $push_command = csprintf(
813        'git push -- %s %Ls',
814        $this->getOntoRemote(),
815        $this->newOntoRefArguments($into_commit));
816    }
817
818    echo tsprintf(
819      "\n%!\n%s\n\n",
820      pht('HOLD CHANGES'),
821      $message);
822
823    echo tsprintf(
824      "%s\n\n%>\n",
825      pht('To push changes manually, run this command:'),
826      $push_command);
827
828    $restore_commands = $local_state->getRestoreCommandsForDisplay();
829    if ($restore_commands) {
830      echo tsprintf(
831        "%s\n\n",
832        pht(
833          'To go back to how things were before you ran "arc land", run '.
834          'these %s command(s):',
835          phutil_count($restore_commands)));
836
837      foreach ($restore_commands as $restore_command) {
838        echo tsprintf('%>', $restore_command);
839      }
840
841      echo tsprintf("\n");
842    }
843
844    echo tsprintf(
845      "%s\n",
846      pht(
847        'Local branches have not been changed, and are still in the '.
848        'same state as before.'));
849  }
850
851  protected function resolveSymbols(array $symbols) {
852    assert_instances_of($symbols, 'ArcanistLandSymbol');
853    $api = $this->getRepositoryAPI();
854
855    foreach ($symbols as $symbol) {
856      $raw_symbol = $symbol->getSymbol();
857
858      list($err, $stdout) = $api->execManualLocal(
859        'rev-parse --verify %s',
860        $raw_symbol);
861
862      if ($err) {
863        throw new PhutilArgumentUsageException(
864          pht(
865            'Branch "%s" does not exist in the local working copy.',
866            $raw_symbol));
867      }
868
869      $commit = trim($stdout);
870      $symbol->setCommit($commit);
871    }
872  }
873
874  protected function confirmOntoRefs(array $onto_refs) {
875    $api = $this->getRepositoryAPI();
876
877    foreach ($onto_refs as $onto_ref) {
878      if (!strlen($onto_ref)) {
879        throw new PhutilArgumentUsageException(
880          pht(
881            'Selected "onto" ref "%s" is invalid: the empty string is not '.
882            'a valid ref.',
883            $onto_ref));
884      }
885    }
886
887    $markers = $api->newMarkerRefQuery()
888      ->withRemotes(array($this->getOntoRemoteRef()))
889      ->withNames($onto_refs)
890      ->execute();
891
892    $markers = mgroup($markers, 'getName');
893
894    $new_markers = array();
895    foreach ($onto_refs as $onto_ref) {
896      if (isset($markers[$onto_ref])) {
897        // Remote already has a branch with this name, so we're fine: we
898        // aren't creatinga new branch.
899        continue;
900      }
901
902      $new_markers[] = id(new ArcanistMarkerRef())
903        ->setMarkerType(ArcanistMarkerRef::TYPE_BRANCH)
904        ->setName($onto_ref);
905    }
906
907    if ($new_markers) {
908      echo tsprintf(
909        "\n%!\n%W\n\n",
910        pht('CREATE %s BRANCHE(S)', phutil_count($new_markers)),
911        pht(
912          'These %s symbol(s) do not exist in the remote. They will be '.
913          'created as new branches:',
914          phutil_count($new_markers)));
915
916      foreach ($new_markers as $new_marker) {
917        echo tsprintf('%s', $new_marker->newRefView());
918      }
919
920      echo tsprintf("\n");
921
922      $is_hold = $this->getShouldHold();
923      if ($is_hold) {
924        echo tsprintf(
925          "%?\n",
926          pht(
927            'You are using "--hold", so execution will stop before the '.
928            '%s branche(s) are actually created. You will be given '.
929            'instructions to create the branches.',
930            phutil_count($new_markers)));
931      }
932
933      $query = pht(
934        'Create %s new branche(s) in the remote?',
935        phutil_count($new_markers));
936
937      $this->getWorkflow()
938        ->getPrompt('arc.land.create')
939        ->setQuery($query)
940        ->execute();
941    }
942  }
943
944  protected function selectOntoRefs(array $symbols) {
945    assert_instances_of($symbols, 'ArcanistLandSymbol');
946    $log = $this->getLogEngine();
947
948    $onto = $this->getOntoArguments();
949    if ($onto) {
950
951      $log->writeStatus(
952        pht('ONTO TARGET'),
953        pht(
954          'Refs were selected with the "--onto" flag: %s.',
955          implode(', ', $onto)));
956
957      return $onto;
958    }
959
960    $onto = $this->getOntoFromConfiguration();
961    if ($onto) {
962      $onto_key = $this->getOntoConfigurationKey();
963
964      $log->writeStatus(
965        pht('ONTO TARGET'),
966        pht(
967          'Refs were selected by reading "%s" configuration: %s.',
968          $onto_key,
969          implode(', ', $onto)));
970
971      return $onto;
972    }
973
974    $api = $this->getRepositoryAPI();
975
976    $remote_onto = array();
977    foreach ($symbols as $symbol) {
978      $raw_symbol = $symbol->getSymbol();
979      $path = $api->getPathToUpstream($raw_symbol);
980
981      if (!$path->getLength()) {
982        continue;
983      }
984
985      $cycle = $path->getCycle();
986      if ($cycle) {
987        $log->writeWarning(
988          pht('LOCAL CYCLE'),
989          pht(
990            'Local branch "%s" tracks an upstream, but following it leads '.
991            'to a local cycle; ignoring branch upstream.',
992            $raw_symbol));
993
994        $log->writeWarning(
995          pht('LOCAL CYCLE'),
996          implode(' -> ', $cycle));
997
998        continue;
999      }
1000
1001      if (!$path->isConnectedToRemote()) {
1002        $log->writeWarning(
1003          pht('NO PATH TO REMOTE'),
1004          pht(
1005            'Local branch "%s" tracks an upstream, but there is no path '.
1006            'to a remote; ignoring branch upstream.',
1007            $raw_symbol));
1008
1009        continue;
1010      }
1011
1012      $onto = $path->getRemoteBranchName();
1013
1014      $remote_onto[$onto] = $onto;
1015    }
1016
1017    if (count($remote_onto) > 1) {
1018      throw new PhutilArgumentUsageException(
1019        pht(
1020          'The branches you are landing are connected to multiple different '.
1021          'remote branches via Git branch upstreams. Use "--onto" to select '.
1022          'the refs you want to push to.'));
1023    }
1024
1025    if ($remote_onto) {
1026      $remote_onto = array_values($remote_onto);
1027
1028      $log->writeStatus(
1029        pht('ONTO TARGET'),
1030        pht(
1031          'Landing onto target "%s", selected by following tracking branches '.
1032          'upstream to the closest remote branch.',
1033          head($remote_onto)));
1034
1035      return $remote_onto;
1036    }
1037
1038    $default_onto = 'master';
1039
1040    $log->writeStatus(
1041      pht('ONTO TARGET'),
1042      pht(
1043        'Landing onto target "%s", the default target under Git.',
1044        $default_onto));
1045
1046    return array($default_onto);
1047  }
1048
1049  protected function selectOntoRemote(array $symbols) {
1050    assert_instances_of($symbols, 'ArcanistLandSymbol');
1051    $remote = $this->newOntoRemote($symbols);
1052
1053    $api = $this->getRepositoryAPI();
1054    $log = $this->getLogEngine();
1055    $is_pushable = $api->isPushableRemote($remote);
1056    $is_perforce = $api->isPerforceRemote($remote);
1057
1058    if (!$is_pushable && !$is_perforce) {
1059      throw new PhutilArgumentUsageException(
1060        pht(
1061          'No pushable remote "%s" exists. Use the "--onto-remote" flag to '.
1062          'choose a valid, pushable remote to land changes onto.',
1063          $remote));
1064    }
1065
1066    if ($is_perforce) {
1067      $this->setIsGitPerforce(true);
1068
1069      $log->writeWarning(
1070        pht('P4 MODE'),
1071        pht(
1072          'Operating in Git/Perforce mode after selecting a Perforce '.
1073          'remote.'));
1074
1075      if (!$this->isSquashStrategy()) {
1076        throw new PhutilArgumentUsageException(
1077          pht(
1078            'Perforce mode does not support the "merge" land strategy. '.
1079            'Use the "squash" land strategy when landing to a Perforce '.
1080            'remote (you can use "--squash" to select this strategy).'));
1081      }
1082    }
1083
1084    return $remote;
1085  }
1086
1087  private function newOntoRemote(array $onto_symbols) {
1088    assert_instances_of($onto_symbols, 'ArcanistLandSymbol');
1089    $log = $this->getLogEngine();
1090
1091    $remote = $this->getOntoRemoteArgument();
1092    if ($remote !== null) {
1093
1094      $log->writeStatus(
1095        pht('ONTO REMOTE'),
1096        pht(
1097          'Remote "%s" was selected with the "--onto-remote" flag.',
1098          $remote));
1099
1100      return $remote;
1101    }
1102
1103    $remote = $this->getOntoRemoteFromConfiguration();
1104    if ($remote !== null) {
1105      $remote_key = $this->getOntoRemoteConfigurationKey();
1106
1107      $log->writeStatus(
1108        pht('ONTO REMOTE'),
1109        pht(
1110          'Remote "%s" was selected by reading "%s" configuration.',
1111          $remote,
1112          $remote_key));
1113
1114      return $remote;
1115    }
1116
1117    $api = $this->getRepositoryAPI();
1118
1119    $upstream_remotes = array();
1120    foreach ($onto_symbols as $onto_symbol) {
1121      $path = $api->getPathToUpstream($onto_symbol->getSymbol());
1122
1123      $remote = $path->getRemoteRemoteName();
1124      if ($remote !== null) {
1125        $upstream_remotes[$remote][] = $onto_symbol;
1126      }
1127    }
1128
1129    if (count($upstream_remotes) > 1) {
1130      throw new PhutilArgumentUsageException(
1131        pht(
1132          'The "onto" refs you have selected are connected to multiple '.
1133          'different remotes via Git branch upstreams. Use "--onto-remote" '.
1134          'to select a single remote.'));
1135    }
1136
1137    if ($upstream_remotes) {
1138      $upstream_remote = head_key($upstream_remotes);
1139
1140      $log->writeStatus(
1141        pht('ONTO REMOTE'),
1142        pht(
1143          'Remote "%s" was selected by following tracking branches '.
1144          'upstream to the closest remote.',
1145          $remote));
1146
1147      return $upstream_remote;
1148    }
1149
1150    $perforce_remote = 'p4';
1151    if ($api->isPerforceRemote($remote)) {
1152
1153      $log->writeStatus(
1154        pht('ONTO REMOTE'),
1155        pht(
1156          'Peforce remote "%s" was selected because the existence of '.
1157          'this remote implies this working copy was synchronized '.
1158          'from a Perforce repository.',
1159          $remote));
1160
1161      return $remote;
1162    }
1163
1164    $default_remote = 'origin';
1165
1166    $log->writeStatus(
1167      pht('ONTO REMOTE'),
1168      pht(
1169        'Landing onto remote "%s", the default remote under Git.',
1170        $default_remote));
1171
1172    return $default_remote;
1173  }
1174
1175  protected function selectIntoRemote() {
1176    $api = $this->getRepositoryAPI();
1177    $log = $this->getLogEngine();
1178
1179    if ($this->getIntoEmptyArgument()) {
1180      $this->setIntoEmpty(true);
1181
1182      $log->writeStatus(
1183        pht('INTO REMOTE'),
1184        pht(
1185          'Will merge into empty state, selected with the "--into-empty" '.
1186          'flag.'));
1187
1188      return;
1189    }
1190
1191    if ($this->getIntoLocalArgument()) {
1192      $this->setIntoLocal(true);
1193
1194      $log->writeStatus(
1195        pht('INTO REMOTE'),
1196        pht(
1197          'Will merge into local state, selected with the "--into-local" '.
1198          'flag.'));
1199
1200      return;
1201    }
1202
1203    $into = $this->getIntoRemoteArgument();
1204    if ($into !== null) {
1205
1206      // TODO: We could allow users to pass a URI argument instead, but
1207      // this also requires some updates to the fetch logic elsewhere.
1208
1209      if (!$api->isFetchableRemote($into)) {
1210        throw new PhutilArgumentUsageException(
1211          pht(
1212            'Remote "%s", specified with "--into", is not a valid fetchable '.
1213            'remote.',
1214            $into));
1215      }
1216
1217      $this->setIntoRemote($into);
1218
1219      $log->writeStatus(
1220        pht('INTO REMOTE'),
1221        pht(
1222          'Will merge into remote "%s", selected with the "--into" flag.',
1223          $into));
1224
1225      return;
1226    }
1227
1228    $onto = $this->getOntoRemote();
1229    $this->setIntoRemote($onto);
1230
1231    $log->writeStatus(
1232      pht('INTO REMOTE'),
1233      pht(
1234        'Will merge into remote "%s" by default, because this is the remote '.
1235        'the change is landing onto.',
1236        $onto));
1237  }
1238
1239  protected function selectIntoRef() {
1240    $log = $this->getLogEngine();
1241
1242    if ($this->getIntoEmptyArgument()) {
1243      $log->writeStatus(
1244        pht('INTO TARGET'),
1245        pht(
1246          'Will merge into empty state, selected with the "--into-empty" '.
1247          'flag.'));
1248
1249      return;
1250    }
1251
1252    $into = $this->getIntoArgument();
1253    if ($into !== null) {
1254      $this->setIntoRef($into);
1255
1256      $log->writeStatus(
1257        pht('INTO TARGET'),
1258        pht(
1259          'Will merge into target "%s", selected with the "--into" flag.',
1260          $into));
1261
1262      return;
1263    }
1264
1265    $ontos = $this->getOntoRefs();
1266    $onto = head($ontos);
1267
1268    $this->setIntoRef($onto);
1269    if (count($ontos) > 1) {
1270      $log->writeStatus(
1271        pht('INTO TARGET'),
1272        pht(
1273          'Will merge into target "%s" by default, because this is the first '.
1274          '"onto" target.',
1275          $onto));
1276    } else {
1277      $log->writeStatus(
1278        pht('INTO TARGET'),
1279        pht(
1280          'Will merge into target "%s" by default, because this is the "onto" '.
1281          'target.',
1282          $onto));
1283    }
1284  }
1285
1286  protected function selectIntoCommit() {
1287    $api = $this->getRepositoryAPI();
1288    // Make sure that our "into" target is valid.
1289    $log = $this->getLogEngine();
1290    $api = $this->getRepositoryAPI();
1291
1292    if ($this->getIntoEmpty()) {
1293      // If we're running under "--into-empty", we don't have to do anything.
1294
1295      $log->writeStatus(
1296        pht('INTO COMMIT'),
1297        pht('Preparing merge into the empty state.'));
1298
1299      return null;
1300    }
1301
1302    if ($this->getIntoLocal()) {
1303      // If we're running under "--into-local", just make sure that the
1304      // target identifies some actual commit.
1305      $local_ref = $this->getIntoRef();
1306
1307      list($err, $stdout) = $api->execManualLocal(
1308        'rev-parse --verify %s',
1309        $local_ref);
1310
1311      if ($err) {
1312        throw new PhutilArgumentUsageException(
1313          pht(
1314            'Local ref "%s" does not exist.',
1315            $local_ref));
1316      }
1317
1318      $into_commit = trim($stdout);
1319
1320      $log->writeStatus(
1321        pht('INTO COMMIT'),
1322        pht(
1323          'Preparing merge into local target "%s", at commit "%s".',
1324          $local_ref,
1325          $api->getDisplayHash($into_commit)));
1326
1327      return $into_commit;
1328    }
1329
1330    $target = id(new ArcanistLandTarget())
1331      ->setRemote($this->getIntoRemote())
1332      ->setRef($this->getIntoRef());
1333
1334    $commit = $this->fetchTarget($target);
1335    if ($commit !== null) {
1336      $log->writeStatus(
1337        pht('INTO COMMIT'),
1338        pht(
1339          'Preparing merge into "%s" from remote "%s", at commit "%s".',
1340          $target->getRef(),
1341          $target->getRemote(),
1342          $api->getDisplayHash($commit)));
1343      return $commit;
1344    }
1345
1346    // If we have no valid target and the user passed "--into" explicitly,
1347    // treat this as an error. For example, "arc land --into Q --onto Q",
1348    // where "Q" does not exist, is an error.
1349    if ($this->getIntoArgument()) {
1350      throw new PhutilArgumentUsageException(
1351        pht(
1352          'Ref "%s" does not exist in remote "%s".',
1353          $target->getRef(),
1354          $target->getRemote()));
1355    }
1356
1357    // Otherwise, treat this as implying "--into-empty". For example,
1358    // "arc land --onto Q", where "Q" does not exist, is equivalent to
1359    // "arc land --into-empty --onto Q".
1360    $this->setIntoEmpty(true);
1361
1362    $log->writeStatus(
1363      pht('INTO COMMIT'),
1364      pht(
1365        'Preparing merge into the empty state to create target "%s" '.
1366        'in remote "%s".',
1367        $target->getRef(),
1368        $target->getRemote()));
1369
1370    return null;
1371  }
1372
1373  private function getLandTargetLocalCommit(ArcanistLandTarget $target) {
1374    $commit = $this->resolveLandTargetLocalCommit($target);
1375
1376    if ($commit === null) {
1377      throw new Exception(
1378        pht(
1379          'No ref "%s" exists in remote "%s".',
1380          $target->getRef(),
1381          $target->getRemote()));
1382    }
1383
1384    return $commit;
1385  }
1386
1387  private function getLandTargetLocalExists(ArcanistLandTarget $target) {
1388    $commit = $this->resolveLandTargetLocalCommit($target);
1389    return ($commit !== null);
1390  }
1391
1392  private function resolveLandTargetLocalCommit(ArcanistLandTarget $target) {
1393    $target_key = $target->getLandTargetKey();
1394
1395    if (!array_key_exists($target_key, $this->landTargetCommitMap)) {
1396      $full_ref = sprintf(
1397        'refs/remotes/%s/%s',
1398        $target->getRemote(),
1399        $target->getRef());
1400
1401      $api = $this->getRepositoryAPI();
1402
1403      list($err, $stdout) = $api->execManualLocal(
1404        'rev-parse --verify %s',
1405        $full_ref);
1406
1407      if ($err) {
1408        $result = null;
1409      } else {
1410        $result = trim($stdout);
1411      }
1412
1413      $this->landTargetCommitMap[$target_key] = $result;
1414    }
1415
1416    return $this->landTargetCommitMap[$target_key];
1417  }
1418
1419  private function fetchLandTarget(
1420    ArcanistLandTarget $target,
1421    $ignore_failure = false) {
1422    $api = $this->getRepositoryAPI();
1423
1424    $err = $this->newPassthru(
1425      'fetch --no-tags --quiet -- %s %s',
1426      $target->getRemote(),
1427      $target->getRef());
1428    if ($err && !$ignore_failure) {
1429      throw new ArcanistUsageException(
1430        pht(
1431          'Fetch of "%s" from remote "%s" failed! Fix the error and '.
1432          'run "arc land" again.',
1433          $target->getRef(),
1434          $target->getRemote()));
1435    }
1436
1437    // TODO: If the remote is a bare URI, we could read ".git/FETCH_HEAD"
1438    // here and write the commit into the map. For now, settle for clearing
1439    // the cache.
1440
1441    // We could also fetch into some named "refs/arc-land-temporary" named
1442    // ref, then read that.
1443
1444    if (!$err) {
1445      $target_key = $target->getLandTargetKey();
1446      unset($this->landTargetCommitMap[$target_key]);
1447    }
1448  }
1449
1450  protected function selectCommits($into_commit, array $symbols) {
1451    assert_instances_of($symbols, 'ArcanistLandSymbol');
1452    $api = $this->getRepositoryAPI();
1453
1454    $commit_map = array();
1455    foreach ($symbols as $symbol) {
1456      $symbol_commit = $symbol->getCommit();
1457      $format = '--format=%H%x00%P%x00%s%x00';
1458
1459      if ($into_commit === null) {
1460        list($commits) = $api->execxLocal(
1461          'log %s %s --',
1462          $format,
1463          gitsprintf('%s', $symbol_commit));
1464      } else {
1465        list($commits) = $api->execxLocal(
1466          'log %s %s --not %s --',
1467          $format,
1468          gitsprintf('%s', $symbol_commit),
1469          gitsprintf('%s', $into_commit));
1470      }
1471
1472      $commits = phutil_split_lines($commits, false);
1473      $is_first = true;
1474      foreach ($commits as $line) {
1475        if (!strlen($line)) {
1476          continue;
1477        }
1478
1479        $parts = explode("\0", $line, 4);
1480        if (count($parts) < 3) {
1481          throw new Exception(
1482            pht(
1483              'Unexpected output from "git log ...": %s',
1484              $line));
1485        }
1486
1487        $hash = $parts[0];
1488        if (!isset($commit_map[$hash])) {
1489          $parents = $parts[1];
1490          $parents = trim($parents);
1491          if (strlen($parents)) {
1492            $parents = explode(' ', $parents);
1493          } else {
1494            $parents = array();
1495          }
1496
1497          $summary = $parts[2];
1498
1499          $commit_map[$hash] = id(new ArcanistLandCommit())
1500            ->setHash($hash)
1501            ->setParents($parents)
1502            ->setSummary($summary);
1503        }
1504
1505        $commit = $commit_map[$hash];
1506        if ($is_first) {
1507          $commit->addDirectSymbol($symbol);
1508          $is_first = false;
1509        }
1510
1511        $commit->addIndirectSymbol($symbol);
1512      }
1513    }
1514
1515    return $this->confirmCommits($into_commit, $symbols, $commit_map);
1516  }
1517
1518  protected function getDefaultSymbols() {
1519    $api = $this->getRepositoryAPI();
1520    $log = $this->getLogEngine();
1521
1522    $branch = $api->getBranchName();
1523    if ($branch !== null) {
1524      $log->writeStatus(
1525        pht('SOURCE'),
1526        pht(
1527          'Landing the current branch, "%s".',
1528          $branch));
1529
1530      return array($branch);
1531    }
1532
1533    $commit = $api->getCurrentCommitRef();
1534
1535    $log->writeStatus(
1536      pht('SOURCE'),
1537      pht(
1538        'Landing the current HEAD, "%s".',
1539        $commit->getCommitHash()));
1540
1541    return array($commit->getCommitHash());
1542  }
1543
1544  private function newOntoRefArguments($into_commit) {
1545    $api = $this->getRepositoryAPI();
1546    $refspecs = array();
1547
1548    foreach ($this->getOntoRefs() as $onto_ref) {
1549      $refspecs[] = sprintf(
1550        '%s:refs/heads/%s',
1551        $api->getDisplayHash($into_commit),
1552        $onto_ref);
1553    }
1554
1555    return $refspecs;
1556  }
1557
1558  private function confirmLegacyStrategyConfiguration() {
1559    // TODO: See T13547. Remove this check in the future. This prevents users
1560    // from accidentally executing a "squash" workflow under a configuration
1561    // which would previously have executed a "merge" workflow.
1562
1563    // We're fine if we have an explicit "--strategy".
1564    if ($this->getStrategyArgument() !== null) {
1565      return;
1566    }
1567
1568    // We're fine if we have an explicit "arc.land.strategy".
1569    if ($this->getStrategyFromConfiguration() !== null) {
1570      return;
1571    }
1572
1573    // We're fine if "history.immutable" is not set to "true".
1574    $source_list = $this->getWorkflow()->getConfigurationSourceList();
1575    $config_list = $source_list->getStorageValueList('history.immutable');
1576    if (!$config_list) {
1577      return;
1578    }
1579
1580    $config_value = (bool)last($config_list)->getValue();
1581    if (!$config_value) {
1582      return;
1583    }
1584
1585    // We're in trouble: we would previously have selected "merge" and will
1586    // now select "squash". Make sure the user knows what they're in for.
1587
1588    echo tsprintf(
1589      "\n%!\n%W\n\n",
1590      pht('MERGE STRATEGY IS AMBIGUOUS'),
1591      pht(
1592        'See <%s>. The default merge strategy under Git with '.
1593        '"history.immutable" has changed from "merge" to "squash". Your '.
1594        'configuration is ambiguous under this behavioral change. '.
1595        '(Use "--strategy" or configure "arc.land.strategy" to bypass '.
1596        'this check.)',
1597        'https://secure.phabricator.com/T13547'));
1598
1599    throw new PhutilArgumentUsageException(
1600      pht(
1601        'Desired merge strategy is ambiguous, choose an explicit strategy.'));
1602  }
1603
1604}
1605