4
* Interfaces with Git working copies.
6
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
8
private $repositoryHasNoCommits = false;
9
const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
12
* For the repository's initial commit, 'git diff HEAD^' and similar do
13
* not work. Using this instead does work; it is the hash of the empty tree.
15
const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
17
private $symbolicHeadCommit;
18
private $resolvedHeadCommit;
20
public static function newHookAPI($root) {
21
return new ArcanistGitAPI($root);
24
protected function buildLocalFuture(array $argv) {
25
$argv[0] = 'git '.$argv[0];
27
$future = newv('ExecFuture', $argv);
28
$future->setCWD($this->getPath());
32
public function execPassthru($pattern /* , ... */) {
33
$args = func_get_args();
37
if (phutil_is_windows()) {
38
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
39
// everything goes to hell if we don't. We must provide an absolute
40
// path to Git for this to work properly.
41
$git = Filesystem::resolveBinary('git');
42
$git = csprintf('%s', $git);
48
$args[0] = $git.' '.$args[0];
50
return call_user_func_array('phutil_passthru', $args);
54
public function getSourceControlSystemName() {
58
public function getMetadataPath() {
61
list($stdout) = $this->execxLocal('rev-parse --git-dir');
62
$path = rtrim($stdout, "\n");
63
// the output of git rev-parse --git-dir is an absolute path, unless
64
// the cwd is the root of the repository, in which case it uses the
65
// relative path of .git. If we get this relative path, turn it into
67
if ($path === '.git') {
68
$path = $this->getPath('.git');
74
public function getHasCommits() {
75
return !$this->repositoryHasNoCommits;
79
* Tests if a child commit is descendant of a parent commit.
80
* If child and parent are the same, it returns false.
81
* @param Child commit SHA.
82
* @param Parent commit SHA.
83
* @return bool True if the child is a descendant of the parent.
85
private function isDescendant($child, $parent) {
86
list($common_ancestor) = $this->execxLocal(
90
$common_ancestor = trim($common_ancestor);
92
return ($common_ancestor == $parent) && ($common_ancestor != $child);
95
public function getLocalCommitInformation() {
96
if ($this->repositoryHasNoCommits) {
99
"You can't get local commit information for a repository with no ".
101
} else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
106
// 2..N commits. We include commits reachable from HEAD which are
107
// not reachable from the base commit; this is consistent with user
108
// expectations even though it is not actually the diff range.
112
// D <----- master branch
114
// C Y <- feature branch
121
// If "A, B, C, D" are master, and the user is at Y, when they run
122
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
123
// this as being the commits X and Y. If we log "B..Y", we only show
124
// Y. With "Y --not B", we show X and Y.
127
if ($this->symbolicHeadCommit !== null) {
128
$base_commit = $this->getBaseCommit();
129
$resolved_base = $this->resolveCommit($base_commit);
131
$head_commit = $this->symbolicHeadCommit;
132
$resolved_head = $this->getHeadCommit();
134
if (!$this->isDescendant($resolved_head, $resolved_base)) {
135
// NOTE: Since the base commit will have been resolved as the
136
// merge-base of the specified base and the specified HEAD, we can't
137
// easily tell exactly what's wrong with the range.
139
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
140
// is reversed, but resolving the commit "HEAD" will compute its
141
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
144
throw new ArcanistUsageException(
146
'The specified commit range is empty, backward or invalid: the '.
147
'base (%s) is not an ancestor of the head (%s). You can not '.
148
'diff an empty or reversed commit range.',
156
$this->getHeadCommit(),
157
$this->getBaseCommit());
160
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
161
// when passed through escapeshellarg() they are replaced with spaces.
163
// TODO: Learn how cmd.exe works and find some clever workaround?
165
// NOTE: If we use "%x00", output is truncated in Windows.
167
list($info) = $this->execxLocal(
169
? 'log %C --format=%C --'
170
: 'log %C --format=%s --',
172
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
173
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02');
177
$info = trim($info, " \n\2");
178
if (!strlen($info)) {
182
$info = explode("\2", $info);
183
foreach ($info as $line) {
184
list($commit, $tree, $parents, $time, $author, $author_email,
185
$title, $message) = explode("\1", trim($line), 8);
186
$message = rtrim($message);
188
$commits[$commit] = array(
191
'parents' => array_filter(explode(' ', $parents)),
195
'message' => $message,
196
'authorEmail' => $author_email,
203
protected function buildBaseCommit($symbolic_commit) {
204
if ($symbolic_commit !== null) {
205
if ($symbolic_commit == ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) {
206
$this->setBaseCommitExplanation(
207
'you explicitly specified the empty tree.');
208
return $symbolic_commit;
211
list($err, $merge_base) = $this->execManualLocal(
214
$this->getHeadCommit());
216
throw new ArcanistUsageException(
217
"Unable to find any git commit named '{$symbolic_commit}' in ".
221
if ($this->symbolicHeadCommit === null) {
222
$this->setBaseCommitExplanation(
223
"it is the merge-base of the explicitly specified base commit ".
224
"'{$symbolic_commit}' and HEAD.");
226
$this->setBaseCommitExplanation(
227
"it is the merge-base of the explicitly specified base commit ".
228
"'{$symbolic_commit}' and the explicitly specified head ".
229
"commit '{$this->symbolicHeadCommit}'.");
232
return trim($merge_base);
235
// Detect zero-commit or one-commit repositories. There is only one
236
// relative-commit value that makes any sense in these repositories: the
238
list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
240
list($err) = $this->execManualLocal('rev-parse --verify HEAD');
242
$this->repositoryHasNoCommits = true;
245
if ($this->repositoryHasNoCommits) {
246
$this->setBaseCommitExplanation(
247
'the repository has no commits.');
249
$this->setBaseCommitExplanation(
250
'the repository has only one commit.');
253
return self::GIT_MAGIC_ROOT_COMMIT;
256
if ($this->getBaseCommitArgumentRules() ||
257
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
258
$base = $this->resolveBaseCommit();
260
throw new ArcanistUsageException(
261
"None of the rules in your 'base' configuration matched a valid ".
262
"commit. Adjust rules or specify which commit you want to use ".
269
$default_relative = null;
270
$working_copy = $this->getWorkingCopyIdentity();
272
$default_relative = $working_copy->getProjectConfig(
273
'git.default-relative-commit');
274
$this->setBaseCommitExplanation(
275
"it is the merge-base of '{$default_relative}' and HEAD, as ".
276
"specified in 'git.default-relative-commit' in '.arcconfig'. This ".
277
"setting overrides other settings.");
280
if (!$default_relative) {
281
list($err, $upstream) = $this->execManualLocal(
282
'rev-parse --abbrev-ref --symbolic-full-name %s',
286
$default_relative = trim($upstream);
287
$this->setBaseCommitExplanation(
288
"it is the merge-base of '{$default_relative}' (the Git upstream ".
289
"of the current branch) HEAD.");
293
if (!$default_relative) {
294
$default_relative = $this->readScratchFile('default-relative-commit');
295
$default_relative = trim($default_relative);
296
if ($default_relative) {
297
$this->setBaseCommitExplanation(
298
"it is the merge-base of '{$default_relative}' and HEAD, as ".
299
"specified in '.git/arc/default-relative-commit'.");
303
if (!$default_relative) {
305
// TODO: Remove the history lesson soon.
307
echo phutil_console_format(
308
"<bg:green>** Select a Default Commit Range **</bg>\n\n");
309
echo phutil_console_wrap(
310
"You're running a command which operates on a range of revisions ".
311
"(usually, from some revision to HEAD) but have not specified the ".
312
"revision that should determine the start of the range.\n\n".
313
"Previously, arc assumed you meant 'HEAD^' when you did not specify ".
314
"a start revision, but this behavior does not make much sense in ".
315
"most workflows outside of Facebook's historic git-svn workflow.\n\n".
316
"arc no longer assumes 'HEAD^'. You must specify a relative commit ".
317
"explicitly when you invoke a command (e.g., `arc diff HEAD^`, not ".
318
"just `arc diff`) or select a default for this working copy.\n\n".
319
"In most cases, the best default is 'origin/master'. You can also ".
320
"select 'HEAD^' to preserve the old behavior, or some other remote ".
321
"or branch. But you almost certainly want to select ".
322
"'origin/master'.\n\n".
323
"(Technically: the merge-base of the selected revision and HEAD is ".
324
"used to determine the start of the commit range.)");
326
$prompt = 'What default do you want to use? [origin/master]';
327
$default = phutil_console_prompt($prompt);
329
if (!strlen(trim($default))) {
330
$default = 'origin/master';
333
$default_relative = $default;
337
list($object_type) = $this->execxLocal(
341
if (trim($object_type) !== 'commit') {
343
"Relative commit '{$default_relative}' is not the name of a commit!");
347
// Don't perform this write until we've verified that the object is a
348
// valid commit name.
349
$this->writeScratchFile('default-relative-commit', $default_relative);
350
$this->setBaseCommitExplanation(
351
"it is the merge-base of '{$default_relative}' and HEAD, as you ".
355
list($merge_base) = $this->execxLocal(
356
'merge-base %s HEAD',
359
return trim($merge_base);
362
public function getHeadCommit() {
363
if ($this->resolvedHeadCommit === null) {
364
$this->resolvedHeadCommit = $this->resolveCommit(
365
coalesce($this->symbolicHeadCommit, 'HEAD'));
368
return $this->resolvedHeadCommit;
371
final public function setHeadCommit($symbolic_commit) {
372
$this->symbolicHeadCommit = $symbolic_commit;
373
$this->reloadCommitRange();
378
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
379
* @param string_symbol commit.
380
* @return string the commit SHA.
382
private function resolveCommit($symbolic_commit) {
383
list($err, $commit_hash) = $this->execManualLocal(
388
throw new ArcanistUsageException(
389
"Unable to find any git commit named '{$symbolic_commit}' in ".
393
return trim($commit_hash);
396
private function getDiffFullOptions($detect_moves_and_renames = true) {
398
self::getDiffBaseOptions(),
402
'-U'.$this->getDiffLinesOfContext(),
405
if ($detect_moves_and_renames) {
410
return implode(' ', $options);
413
private function getDiffBaseOptions() {
415
// Disable external diff drivers, like graphical differs, since Arcanist
416
// needs to capture the diff text.
418
// Disable textconv so we treat binary files as binary, even if they have
419
// an alternative textual representation. TODO: Ideally, Differential
420
// would ship up the binaries for 'arc patch' but display the textconv
421
// output in the visual diff.
424
return implode(' ', $options);
428
* @param the base revision
429
* @param head revision. If this is null, the generated diff will include the
432
public function getFullGitDiff($base, $head = null) {
433
$options = $this->getDiffFullOptions();
435
if ($head !== null) {
436
list($stdout) = $this->execxLocal(
437
"diff {$options} %s %s --",
441
list($stdout) = $this->execxLocal(
442
"diff {$options} %s --",
450
* @param string Path to generate a diff for.
451
* @param bool If true, detect moves and renames. Otherwise, ignore
452
* moves/renames; this is useful because it prompts git to
453
* generate real diff text.
455
public function getRawDiffText($path, $detect_moves_and_renames = true) {
456
$options = $this->getDiffFullOptions($detect_moves_and_renames);
457
list($stdout) = $this->execxLocal(
458
"diff {$options} %s -- %s",
459
$this->getBaseCommit(),
464
public function getBranchName() {
467
// $ git rev-parse --abbrev-ref `git symbolic-ref HEAD`
469
// But that may fail if you're not on a branch.
470
list($stdout) = $this->execxLocal('branch --no-color');
472
// Assume that any branch beginning with '(' means 'no branch', or whatever
473
// 'no branch' is in the current locale.
475
if (preg_match('/^\* ([^\(].*)$/m', $stdout, $matches)) {
482
public function getRemoteURI() {
483
list($stdout) = $this->execxLocal('remote show -n origin');
486
if (preg_match('/^\s*Fetch URL: (.*)$/m', $stdout, $matches)) {
487
return trim($matches[1]);
493
public function getSourceControlPath() {
494
// TODO: Try to get something useful here.
498
public function getGitCommitLog() {
499
$relative = $this->getBaseCommit();
500
if ($this->repositoryHasNoCommits) {
503
} else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
505
list($stdout) = $this->execxLocal(
506
'log --format=medium HEAD');
509
list($stdout) = $this->execxLocal(
510
'log --first-parent --format=medium %s..%s',
511
$this->getBaseCommit(),
512
$this->getHeadCommit());
517
public function getGitHistoryLog() {
518
list($stdout) = $this->execxLocal(
519
'log --format=medium -n%d %s',
520
self::SEARCH_LENGTH_FOR_PARENT_REVISIONS,
521
$this->getBaseCommit());
525
public function getSourceControlBaseRevision() {
526
list($stdout) = $this->execxLocal(
528
$this->getBaseCommit());
529
return rtrim($stdout, "\n");
532
public function getCanonicalRevisionName($string) {
534
if (preg_match('/@([0-9]+)$/', $string, $match)) {
535
$stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
537
list($stdout) = $this->execxLocal(
539
? 'show -s --format=%C %s --'
540
: 'show -s --format=%s %s --',
544
return rtrim($stdout);
547
private function executeSVNFindRev($input, $vcs) {
549
list($stdout) = $this->execxLocal(
553
throw new ArcanistUsageException(
554
"Cannot find the {$vcs} equivalent of {$input}.");
556
// When git performs a partial-rebuild during svn
557
// look-up, we need to parse the final line
558
$lines = explode("\n", $stdout);
559
$stdout = $lines[count($lines) - 2];
560
return rtrim($stdout);
563
// Convert svn revision number to git hash
564
public function getHashFromFromSVNRevisionNumber($revision_id) {
565
return $this->executeSVNFindRev('r'.$revision_id, 'Git');
569
// Convert a git hash to svn revision number
570
public function getSVNRevisionNumberFromHash($hash) {
571
return $this->executeSVNFindRev($hash, 'SVN');
575
protected function buildUncommittedStatus() {
576
$diff_options = $this->getDiffBaseOptions();
578
if ($this->repositoryHasNoCommits) {
579
$diff_base = self::GIT_MAGIC_ROOT_COMMIT;
584
// Find uncommitted changes.
585
$uncommitted_future = $this->buildLocalFuture(
587
'diff %C --raw %s --',
592
$untracked_future = $this->buildLocalFuture(
594
'ls-files --others --exclude-standard',
598
$unstaged_future = $this->buildLocalFuture(
600
'diff-files --name-only',
606
// NOTE: `git diff-files` races with each of these other commands
607
// internally, and resolves with inconsistent results if executed
608
// in parallel. To work around this, DO NOT run it at the same time.
609
// After the other commands exit, we can start the `diff-files` command.
612
Futures($futures)->resolveAll();
614
// We're clear to start the `git diff-files` now.
615
$unstaged_future->start();
617
$result = new PhutilArrayWithDefaultValue();
619
list($stdout) = $uncommitted_future->resolvex();
620
$uncommitted_files = $this->parseGitStatus($stdout);
621
foreach ($uncommitted_files as $path => $mask) {
622
$result[$path] |= ($mask | self::FLAG_UNCOMMITTED);
625
list($stdout) = $untracked_future->resolvex();
626
$stdout = rtrim($stdout, "\n");
627
if (strlen($stdout)) {
628
$stdout = explode("\n", $stdout);
629
foreach ($stdout as $path) {
630
$result[$path] |= self::FLAG_UNTRACKED;
634
list($stdout, $stderr) = $unstaged_future->resolvex();
635
$stdout = rtrim($stdout, "\n");
636
if (strlen($stdout)) {
637
$stdout = explode("\n", $stdout);
638
foreach ($stdout as $path) {
639
$result[$path] |= self::FLAG_UNSTAGED;
643
return $result->toArray();
646
protected function buildCommitRangeStatus() {
647
list($stdout, $stderr) = $this->execxLocal(
648
'diff %C --raw %s --',
649
$this->getDiffBaseOptions(),
650
$this->getBaseCommit());
652
return $this->parseGitStatus($stdout);
655
public function getGitConfig($key, $default = null) {
656
list($err, $stdout) = $this->execManualLocal('config %s', $key);
660
return rtrim($stdout);
663
public function getAuthor() {
664
list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
665
return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
668
public function addToCommit(array $paths) {
672
$this->reloadWorkingCopy();
676
public function doCommit($message) {
677
$tmp_file = new TempFile();
678
Filesystem::writeFile($tmp_file, $message);
680
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
681
// so we do not provide it and thus require a message.
687
$this->reloadWorkingCopy();
692
public function amendCommit($message = null) {
693
if ($message === null) {
694
$this->execxLocal('commit --amend --allow-empty -C HEAD');
696
$tmp_file = new TempFile();
697
Filesystem::writeFile($tmp_file, $message);
699
'commit --amend --allow-empty -F %s',
703
$this->reloadWorkingCopy();
707
public function getPreReceiveHookStatus($old_ref, $new_ref) {
708
$options = $this->getDiffBaseOptions();
709
list($stdout) = $this->execxLocal(
710
"diff {$options} --raw %s %s --",
713
return $this->parseGitStatus($stdout, $full = true);
716
private function parseGitStatus($status, $full = false) {
717
static $flags = array(
718
'A' => self::FLAG_ADDED,
719
'M' => self::FLAG_MODIFIED,
720
'D' => self::FLAG_DELETED,
723
$status = trim($status);
725
foreach (explode("\n", $status) as $line) {
727
$lines[] = preg_split("/[ \t]/", $line, 6);
732
foreach ($lines as $line) {
736
foreach ($flags as $key => $bits) {
742
$files[$file] = array(
744
'ref' => rtrim($line[3], '.'),
747
$files[$file] = $mask;
754
public function getAllFiles() {
755
$future = $this->buildLocalFuture(array('ls-files -z'));
756
return id(new LinesOfALargeExecFuture($future))
757
->setDelimiter("\0");
760
public function getChangedFiles($since_commit) {
761
list($stdout) = $this->execxLocal(
764
return $this->parseGitStatus($stdout);
767
public function getBlame($path) {
768
// TODO: 'git blame' supports --porcelain and we should probably use it.
769
list($stdout) = $this->execxLocal(
770
'blame --date=iso -w -M %s -- %s',
771
$this->getBaseCommit(),
775
foreach (explode("\n", trim($stdout)) as $line) {
776
if (!strlen($line)) {
780
// lines predating a git repo's history are blamed to the oldest revision,
781
// with the commit hash prepended by a ^. we shouldn't count these lines
782
// as blaming to the oldest diff's unfortunate author
783
if ($line[0] == '^') {
789
'/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/',
793
throw new Exception("Bad blame? `{$line}'");
795
$revision = $matches[1];
796
$author = $matches[2];
798
$blame[] = array($author, $revision);
804
public function getOriginalFileData($path) {
805
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
808
public function getCurrentFileData($path) {
809
return $this->getFileDataAtRevision($path, 'HEAD');
812
private function parseGitTree($stdout) {
815
$stdout = trim($stdout);
816
if (!strlen($stdout)) {
820
$lines = explode("\n", $stdout);
821
foreach ($lines as $line) {
824
'/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/',
828
throw new Exception('Failed to parse git ls-tree output!');
830
$result[$matches[4]] = array(
831
'mode' => $matches[1],
832
'type' => $matches[2],
833
'ref' => $matches[3],
839
private function getFileDataAtRevision($path, $revision) {
840
// NOTE: We don't want to just "git show {$revision}:{$path}" since if the
841
// path was a directory at the given revision we'll get a list of its files
842
// and treat it as though it as a file containing a list of other files,
845
list($stdout) = $this->execxLocal(
850
$info = $this->parseGitTree($stdout);
851
if (empty($info[$path])) {
852
// No such path, or the path is a directory and we executed 'ls-tree dir/'
853
// and got a list of its contents back.
857
if ($info[$path]['type'] != 'blob') {
858
// Path is or was a directory, not a file.
862
list($stdout) = $this->execxLocal(
864
$info[$path]['ref']);
869
* Returns names of all the branches in the current repository.
871
* @return list<dict<string, string>> Dictionary of branch information.
873
public function getAllBranches() {
874
list($branch_info) = $this->execxLocal(
875
'branch --no-color');
876
$lines = explode("\n", rtrim($branch_info));
879
foreach ($lines as $line) {
881
if (preg_match('@^[* ]+\(no branch|detached from \w+/\w+\)@', $line)) {
882
// This is indicating that the working copy is in a detached state;
887
list($current, $name) = preg_split('/\s+/', $line, 2);
889
'current' => !empty($current),
897
public function getWorkingCopyRevision() {
898
list($stdout) = $this->execxLocal('rev-parse HEAD');
899
return rtrim($stdout, "\n");
902
public function getUnderlyingWorkingCopyRevision() {
903
list($err, $stdout) = $this->execManualLocal('svn find-rev HEAD');
904
if (!$err && $stdout) {
905
return rtrim($stdout, "\n");
907
return $this->getWorkingCopyRevision();
910
public function isHistoryDefaultImmutable() {
914
public function supportsAmend() {
918
public function supportsCommitRanges() {
922
public function supportsLocalCommits() {
926
public function hasLocalCommit($commit) {
928
if (!$this->getCanonicalRevisionName($commit)) {
931
} catch (CommandException $exception) {
937
public function getAllLocalChanges() {
938
$diff = $this->getFullGitDiff($this->getBaseCommit());
939
if (!strlen(trim($diff))) {
942
$parser = new ArcanistDiffParser();
943
return $parser->parseDiff($diff);
946
public function supportsLocalBranchMerge() {
950
public function performLocalBranchMerge($branch, $message) {
952
throw new ArcanistUsageException(
953
'Under git, you must specify the branch you want to merge.');
955
$err = phutil_passthru(
956
'(cd %s && git merge --no-ff -m %s %s)',
962
throw new ArcanistUsageException('Merge failed!');
966
public function getFinalizedRevisionMessage() {
967
return "You may now push this commit upstream, as appropriate (e.g. with ".
968
"'git push', or 'git svn dcommit', or by printing and faxing it).";
971
public function getCommitMessage($commit) {
972
list($message) = $this->execxLocal(
973
'log -n1 --format=%C %s --',
979
public function loadWorkingCopyDifferentialRevisions(
980
ConduitClient $conduit,
983
$messages = $this->getGitCommitLog();
984
if (!strlen($messages)) {
988
$parser = new ArcanistDiffParser();
989
$messages = $parser->parseDiff($messages);
991
// First, try to find revisions by explicit revision IDs in commit messages.
992
$reason_map = array();
993
$revision_ids = array();
994
foreach ($messages as $message) {
995
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus(
996
$message->getMetadata('message'));
997
if ($object->getRevisionID()) {
998
$revision_ids[] = $object->getRevisionID();
999
$reason_map[$object->getRevisionID()] = $message->getCommitHash();
1003
if ($revision_ids) {
1004
$results = $conduit->callMethodSynchronous(
1005
'differential.query',
1007
'ids' => $revision_ids,
1010
foreach ($results as $key => $result) {
1011
$hash = substr($reason_map[$result['id']], 0, 16);
1012
$results[$key]['why'] =
1013
"Commit message for '{$hash}' has explicit 'Differential Revision'.";
1019
// If we didn't succeed, try to find revisions by hash.
1021
foreach ($this->getLocalCommitInformation() as $commit) {
1022
$hashes[] = array('gtcm', $commit['commit']);
1023
$hashes[] = array('gttr', $commit['tree']);
1026
$results = $conduit->callMethodSynchronous(
1027
'differential.query',
1029
'commitHashes' => $hashes,
1032
foreach ($results as $key => $result) {
1033
$results[$key]['why'] =
1034
'A git commit or tree hash in the commit range is already attached '.
1035
'to the Differential revision.';
1041
public function updateWorkingCopy() {
1042
$this->execxLocal('pull');
1043
$this->reloadWorkingCopy();
1046
public function getCommitSummary($commit) {
1047
if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
1048
return '(The Empty Tree)';
1051
list($summary) = $this->execxLocal(
1052
'log -n 1 --format=%C %s',
1056
return trim($summary);
1059
public function backoutCommit($commit_hash) {
1061
'revert %s -n --no-edit', $commit_hash);
1062
$this->reloadWorkingCopy();
1063
if (!$this->getUncommittedStatus()) {
1064
throw new ArcanistUsageException(
1065
"{$commit_hash} has already been reverted.");
1069
public function getBackoutMessage($commit_hash) {
1070
return 'This reverts commit '.$commit_hash.'.';
1073
public function isGitSubversionRepo() {
1074
return Filesystem::pathExists($this->getPath('.git/svn'));
1077
public function resolveBaseCommitRule($rule, $source) {
1078
list($type, $name) = explode(':', $rule, 2);
1083
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
1084
list($err, $merge_base) = $this->execManualLocal(
1085
'merge-base %s HEAD',
1088
$this->setBaseCommitExplanation(
1089
"it is the merge-base of '{$matches[1]}' and HEAD, as ".
1090
"specified by '{$rule}' in your {$source} 'base' ".
1092
return trim($merge_base);
1094
} else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
1095
list($err, $merge_base) = $this->execManualLocal(
1096
'merge-base %s HEAD',
1101
$merge_base = trim($merge_base);
1103
list($commits) = $this->execxLocal(
1104
'log --format=%C %s..HEAD --',
1107
$commits = array_filter(explode("\n", $commits));
1113
$commits[] = $merge_base;
1115
$head_branch_count = null;
1116
foreach ($commits as $commit) {
1117
list($branches) = $this->execxLocal(
1118
'branch --contains %s',
1120
$branches = array_filter(explode("\n", $branches));
1121
if ($head_branch_count === null) {
1122
// If this is the first commit, it's HEAD. Count how many
1123
// branches it is on; we want to include commits on the same
1124
// number of branches. This covers a case where this branch
1125
// has sub-branches and we're running "arc diff" here again
1126
// for whatever reason.
1127
$head_branch_count = count($branches);
1128
} else if (count($branches) > $head_branch_count) {
1129
foreach ($branches as $key => $branch) {
1130
$branches[$key] = trim($branch, ' *');
1132
$branches = implode(', ', $branches);
1133
$this->setBaseCommitExplanation(
1134
"it is the first commit between '{$merge_base}' (the ".
1135
"merge-base of '{$matches[1]}' and HEAD) which is also ".
1136
"contained by another branch ({$branches}).");
1141
list($err) = $this->execManualLocal(
1145
$this->setBaseCommitExplanation(
1146
"it is specified by '{$rule}' in your {$source} 'base' ".
1155
$this->setBaseCommitExplanation(
1156
"you specified '{$rule}' in your {$source} 'base' ".
1158
return self::GIT_MAGIC_ROOT_COMMIT;
1160
$text = $this->getCommitMessage('HEAD');
1161
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
1163
if ($message->getRevisionID()) {
1164
$this->setBaseCommitExplanation(
1165
"HEAD has been amended with 'Differential Revision:', ".
1166
"as specified by '{$rule}' in your {$source} 'base' ".
1172
list($err, $upstream) = $this->execManualLocal(
1173
'rev-parse --abbrev-ref --symbolic-full-name %s',
1176
$upstream = rtrim($upstream);
1177
list($upstream_merge_base) = $this->execxLocal(
1178
'merge-base %s HEAD',
1180
$upstream_merge_base = rtrim($upstream_merge_base);
1181
$this->setBaseCommitExplanation(
1182
"it is the merge-base of the upstream of the current branch ".
1183
"and HEAD, and matched the rule '{$rule}' in your {$source} ".
1184
"'base' configuration.");
1185
return $upstream_merge_base;
1189
$this->setBaseCommitExplanation(
1190
"you specified '{$rule}' in your {$source} 'base' ".
1201
public function canStashChanges() {
1205
public function stashChanges() {
1206
$this->execxLocal('stash');
1207
$this->reloadWorkingCopy();
1210
public function unstashChanges() {
1211
$this->execxLocal('stash pop');
1214
protected function didReloadCommitRange() {
1215
// After an amend, the symbolic head may resolve to a different commit.
1216
$this->resolvedHeadCommit = null;