~ubuntu-branches/ubuntu/wily/phabricator/wily

« back to all changes in this revision

Viewing changes to src/repository/api/ArcanistGitAPI.php

  • Committer: Package Import Robot
  • Author(s): Richard Sellam
  • Date: 2014-11-01 23:20:06 UTC
  • mto: This revision was merged to the branch mainline in revision 4.
  • Revision ID: package-import@ubuntu.com-20141101232006-mvlnp0cil67tsboe
Tags: upstream-0~git20141101/arcanist
Import upstream version 0~git20141101, component arcanist

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
<?php
 
2
 
 
3
/**
 
4
 * Interfaces with Git working copies.
 
5
 */
 
6
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
 
7
 
 
8
  private $repositoryHasNoCommits = false;
 
9
  const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
 
10
 
 
11
  /**
 
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.
 
14
   */
 
15
  const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
 
16
 
 
17
  private $symbolicHeadCommit;
 
18
  private $resolvedHeadCommit;
 
19
 
 
20
  public static function newHookAPI($root) {
 
21
    return new ArcanistGitAPI($root);
 
22
  }
 
23
 
 
24
  protected function buildLocalFuture(array $argv) {
 
25
    $argv[0] = 'git '.$argv[0];
 
26
 
 
27
    $future = newv('ExecFuture', $argv);
 
28
    $future->setCWD($this->getPath());
 
29
    return $future;
 
30
  }
 
31
 
 
32
  public function execPassthru($pattern /* , ... */) {
 
33
    $args = func_get_args();
 
34
 
 
35
    static $git = null;
 
36
    if ($git === null) {
 
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);
 
43
      } else {
 
44
        $git = 'git';
 
45
      }
 
46
    }
 
47
 
 
48
    $args[0] = $git.' '.$args[0];
 
49
 
 
50
    return call_user_func_array('phutil_passthru', $args);
 
51
  }
 
52
 
 
53
 
 
54
  public function getSourceControlSystemName() {
 
55
    return 'git';
 
56
  }
 
57
 
 
58
  public function getMetadataPath() {
 
59
    static $path = null;
 
60
    if ($path === null) {
 
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
 
66
      // an absolute path.
 
67
      if ($path === '.git') {
 
68
        $path = $this->getPath('.git');
 
69
      }
 
70
    }
 
71
    return $path;
 
72
  }
 
73
 
 
74
  public function getHasCommits() {
 
75
    return !$this->repositoryHasNoCommits;
 
76
  }
 
77
 
 
78
  /**
 
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.
 
84
   */
 
85
  private function isDescendant($child, $parent) {
 
86
    list($common_ancestor) = $this->execxLocal(
 
87
      'merge-base %s %s',
 
88
      $child,
 
89
      $parent);
 
90
    $common_ancestor = trim($common_ancestor);
 
91
 
 
92
    return ($common_ancestor == $parent) && ($common_ancestor != $child);
 
93
  }
 
94
 
 
95
  public function getLocalCommitInformation() {
 
96
    if ($this->repositoryHasNoCommits) {
 
97
      // Zero commits.
 
98
      throw new Exception(
 
99
        "You can't get local commit information for a repository with no ".
 
100
        "commits.");
 
101
    } else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
 
102
      // One commit.
 
103
      $against = 'HEAD';
 
104
    } else {
 
105
 
 
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.
 
109
      // Particularly:
 
110
      //
 
111
      //    |
 
112
      //    D <----- master branch
 
113
      //    |
 
114
      //    C  Y <- feature branch
 
115
      //    | /|
 
116
      //    B  X
 
117
      //    | /
 
118
      //    A
 
119
      //    |
 
120
      //
 
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.
 
125
 
 
126
 
 
127
      if ($this->symbolicHeadCommit !== null) {
 
128
        $base_commit = $this->getBaseCommit();
 
129
        $resolved_base = $this->resolveCommit($base_commit);
 
130
 
 
131
        $head_commit = $this->symbolicHeadCommit;
 
132
        $resolved_head = $this->getHeadCommit();
 
133
 
 
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.
 
138
 
 
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
 
142
          // appear empty.
 
143
 
 
144
          throw new ArcanistUsageException(
 
145
            pht(
 
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.',
 
149
              $base_commit,
 
150
              $head_commit));
 
151
        }
 
152
      }
 
153
 
 
154
      $against = csprintf(
 
155
        '%s --not %s',
 
156
        $this->getHeadCommit(),
 
157
        $this->getBaseCommit());
 
158
    }
 
159
 
 
160
    // NOTE: Windows escaping of "%" symbols apparently is inherently broken;
 
161
    // when passed through escapeshellarg() they are replaced with spaces.
 
162
 
 
163
    // TODO: Learn how cmd.exe works and find some clever workaround?
 
164
 
 
165
    // NOTE: If we use "%x00", output is truncated in Windows.
 
166
 
 
167
    list($info) = $this->execxLocal(
 
168
      phutil_is_windows()
 
169
        ? 'log %C --format=%C --'
 
170
        : 'log %C --format=%s --',
 
171
      $against,
 
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');
 
174
 
 
175
    $commits = array();
 
176
 
 
177
    $info = trim($info, " \n\2");
 
178
    if (!strlen($info)) {
 
179
      return array();
 
180
    }
 
181
 
 
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);
 
187
 
 
188
      $commits[$commit] = array(
 
189
        'commit'  => $commit,
 
190
        'tree'    => $tree,
 
191
        'parents' => array_filter(explode(' ', $parents)),
 
192
        'time'    => $time,
 
193
        'author'  => $author,
 
194
        'summary' => $title,
 
195
        'message' => $message,
 
196
        'authorEmail' => $author_email,
 
197
      );
 
198
    }
 
199
 
 
200
    return $commits;
 
201
  }
 
202
 
 
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;
 
209
      }
 
210
 
 
211
      list($err, $merge_base) = $this->execManualLocal(
 
212
        'merge-base %s %s',
 
213
        $symbolic_commit,
 
214
        $this->getHeadCommit());
 
215
      if ($err) {
 
216
        throw new ArcanistUsageException(
 
217
          "Unable to find any git commit named '{$symbolic_commit}' in ".
 
218
          "this repository.");
 
219
      }
 
220
 
 
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.");
 
225
      } else {
 
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}'.");
 
230
      }
 
231
 
 
232
      return trim($merge_base);
 
233
    }
 
234
 
 
235
    // Detect zero-commit or one-commit repositories. There is only one
 
236
    // relative-commit value that makes any sense in these repositories: the
 
237
    // empty tree.
 
238
    list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
 
239
    if ($err) {
 
240
      list($err) = $this->execManualLocal('rev-parse --verify HEAD');
 
241
      if ($err) {
 
242
        $this->repositoryHasNoCommits = true;
 
243
      }
 
244
 
 
245
      if ($this->repositoryHasNoCommits) {
 
246
        $this->setBaseCommitExplanation(
 
247
          'the repository has no commits.');
 
248
      } else {
 
249
        $this->setBaseCommitExplanation(
 
250
          'the repository has only one commit.');
 
251
      }
 
252
 
 
253
      return self::GIT_MAGIC_ROOT_COMMIT;
 
254
    }
 
255
 
 
256
    if ($this->getBaseCommitArgumentRules() ||
 
257
        $this->getConfigurationManager()->getConfigFromAnySource('base')) {
 
258
      $base = $this->resolveBaseCommit();
 
259
      if (!$base) {
 
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 ".
 
263
          "explicitly.");
 
264
      }
 
265
      return $base;
 
266
    }
 
267
 
 
268
    $do_write = false;
 
269
    $default_relative = null;
 
270
    $working_copy = $this->getWorkingCopyIdentity();
 
271
    if ($working_copy) {
 
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.");
 
278
    }
 
279
 
 
280
    if (!$default_relative) {
 
281
      list($err, $upstream) = $this->execManualLocal(
 
282
        'rev-parse --abbrev-ref --symbolic-full-name %s',
 
283
        '@{upstream}');
 
284
 
 
285
      if (!$err) {
 
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.");
 
290
      }
 
291
    }
 
292
 
 
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'.");
 
300
      }
 
301
    }
 
302
 
 
303
    if (!$default_relative) {
 
304
 
 
305
      // TODO: Remove the history lesson soon.
 
306
 
 
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.)");
 
325
 
 
326
      $prompt = 'What default do you want to use? [origin/master]';
 
327
      $default = phutil_console_prompt($prompt);
 
328
 
 
329
      if (!strlen(trim($default))) {
 
330
        $default = 'origin/master';
 
331
      }
 
332
 
 
333
      $default_relative = $default;
 
334
      $do_write = true;
 
335
    }
 
336
 
 
337
    list($object_type) = $this->execxLocal(
 
338
      'cat-file -t %s',
 
339
      $default_relative);
 
340
 
 
341
    if (trim($object_type) !== 'commit') {
 
342
      throw new Exception(
 
343
        "Relative commit '{$default_relative}' is not the name of a commit!");
 
344
    }
 
345
 
 
346
    if ($do_write) {
 
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 ".
 
352
        "just specified.");
 
353
    }
 
354
 
 
355
    list($merge_base) = $this->execxLocal(
 
356
      'merge-base %s HEAD',
 
357
      $default_relative);
 
358
 
 
359
    return trim($merge_base);
 
360
  }
 
361
 
 
362
  public function getHeadCommit() {
 
363
    if ($this->resolvedHeadCommit === null) {
 
364
      $this->resolvedHeadCommit = $this->resolveCommit(
 
365
        coalesce($this->symbolicHeadCommit, 'HEAD'));
 
366
    }
 
367
 
 
368
    return $this->resolvedHeadCommit;
 
369
  }
 
370
 
 
371
  final public function setHeadCommit($symbolic_commit) {
 
372
    $this->symbolicHeadCommit = $symbolic_commit;
 
373
    $this->reloadCommitRange();
 
374
    return $this;
 
375
  }
 
376
 
 
377
  /**
 
378
   * Translates a symbolic commit (like "HEAD^") to a commit identifier.
 
379
   * @param string_symbol commit.
 
380
   * @return string the commit SHA.
 
381
   */
 
382
  private function resolveCommit($symbolic_commit) {
 
383
    list($err, $commit_hash) = $this->execManualLocal(
 
384
      'rev-parse %s',
 
385
      $symbolic_commit);
 
386
 
 
387
    if ($err) {
 
388
      throw new ArcanistUsageException(
 
389
        "Unable to find any git commit named '{$symbolic_commit}' in ".
 
390
        "this repository.");
 
391
    }
 
392
 
 
393
    return trim($commit_hash);
 
394
  }
 
395
 
 
396
  private function getDiffFullOptions($detect_moves_and_renames = true) {
 
397
    $options = array(
 
398
      self::getDiffBaseOptions(),
 
399
      '--no-color',
 
400
      '--src-prefix=a/',
 
401
      '--dst-prefix=b/',
 
402
      '-U'.$this->getDiffLinesOfContext(),
 
403
    );
 
404
 
 
405
    if ($detect_moves_and_renames) {
 
406
      $options[] = '-M';
 
407
      $options[] = '-C';
 
408
    }
 
409
 
 
410
    return implode(' ', $options);
 
411
  }
 
412
 
 
413
  private function getDiffBaseOptions() {
 
414
    $options = array(
 
415
      // Disable external diff drivers, like graphical differs, since Arcanist
 
416
      // needs to capture the diff text.
 
417
      '--no-ext-diff',
 
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.
 
422
      '--no-textconv',
 
423
    );
 
424
    return implode(' ', $options);
 
425
  }
 
426
 
 
427
  /**
 
428
   * @param the base revision
 
429
   * @param head revision. If this is null, the generated diff will include the
 
430
   * working copy
 
431
   */
 
432
  public function getFullGitDiff($base, $head = null) {
 
433
    $options = $this->getDiffFullOptions();
 
434
 
 
435
    if ($head !== null) {
 
436
      list($stdout) = $this->execxLocal(
 
437
        "diff {$options} %s %s --",
 
438
        $base,
 
439
        $head);
 
440
    } else {
 
441
      list($stdout) = $this->execxLocal(
 
442
        "diff {$options} %s --",
 
443
        $base);
 
444
    }
 
445
 
 
446
    return $stdout;
 
447
  }
 
448
 
 
449
  /**
 
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.
 
454
   */
 
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(),
 
460
      $path);
 
461
    return $stdout;
 
462
  }
 
463
 
 
464
  public function getBranchName() {
 
465
    // TODO: consider:
 
466
    //
 
467
    //    $ git rev-parse --abbrev-ref `git symbolic-ref HEAD`
 
468
    //
 
469
    // But that may fail if you're not on a branch.
 
470
    list($stdout) = $this->execxLocal('branch --no-color');
 
471
 
 
472
    // Assume that any branch beginning with '(' means 'no branch', or whatever
 
473
    // 'no branch' is in the current locale.
 
474
    $matches = null;
 
475
    if (preg_match('/^\* ([^\(].*)$/m', $stdout, $matches)) {
 
476
      return $matches[1];
 
477
    }
 
478
 
 
479
    return null;
 
480
  }
 
481
 
 
482
  public function getRemoteURI() {
 
483
    list($stdout) = $this->execxLocal('remote show -n origin');
 
484
 
 
485
    $matches = null;
 
486
    if (preg_match('/^\s*Fetch URL: (.*)$/m', $stdout, $matches)) {
 
487
      return trim($matches[1]);
 
488
    }
 
489
 
 
490
    return null;
 
491
  }
 
492
 
 
493
  public function getSourceControlPath() {
 
494
    // TODO: Try to get something useful here.
 
495
    return null;
 
496
  }
 
497
 
 
498
  public function getGitCommitLog() {
 
499
    $relative = $this->getBaseCommit();
 
500
    if ($this->repositoryHasNoCommits) {
 
501
      // No commits yet.
 
502
      return '';
 
503
    } else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
 
504
      // First commit.
 
505
      list($stdout) = $this->execxLocal(
 
506
        'log --format=medium HEAD');
 
507
    } else {
 
508
      // 2..N commits.
 
509
      list($stdout) = $this->execxLocal(
 
510
        'log --first-parent --format=medium %s..%s',
 
511
        $this->getBaseCommit(),
 
512
        $this->getHeadCommit());
 
513
    }
 
514
    return $stdout;
 
515
  }
 
516
 
 
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());
 
522
    return $stdout;
 
523
  }
 
524
 
 
525
  public function getSourceControlBaseRevision() {
 
526
    list($stdout) = $this->execxLocal(
 
527
      'rev-parse %s',
 
528
      $this->getBaseCommit());
 
529
    return rtrim($stdout, "\n");
 
530
  }
 
531
 
 
532
  public function getCanonicalRevisionName($string) {
 
533
    $match = null;
 
534
    if (preg_match('/@([0-9]+)$/', $string, $match)) {
 
535
      $stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
 
536
    } else {
 
537
      list($stdout) = $this->execxLocal(
 
538
        phutil_is_windows()
 
539
        ? 'show -s --format=%C %s --'
 
540
        : 'show -s --format=%s %s --',
 
541
        '%H',
 
542
        $string);
 
543
    }
 
544
    return rtrim($stdout);
 
545
  }
 
546
 
 
547
  private function executeSVNFindRev($input, $vcs) {
 
548
    $match = array();
 
549
    list($stdout) = $this->execxLocal(
 
550
      'svn find-rev %s',
 
551
      $input);
 
552
    if (!$stdout) {
 
553
      throw new ArcanistUsageException(
 
554
        "Cannot find the {$vcs} equivalent of {$input}.");
 
555
    }
 
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);
 
561
  }
 
562
 
 
563
  // Convert svn revision number to git hash
 
564
  public function getHashFromFromSVNRevisionNumber($revision_id) {
 
565
    return $this->executeSVNFindRev('r'.$revision_id, 'Git');
 
566
  }
 
567
 
 
568
 
 
569
  // Convert a git hash to svn revision number
 
570
  public function getSVNRevisionNumberFromHash($hash) {
 
571
    return $this->executeSVNFindRev($hash, 'SVN');
 
572
  }
 
573
 
 
574
 
 
575
  protected function buildUncommittedStatus() {
 
576
    $diff_options = $this->getDiffBaseOptions();
 
577
 
 
578
    if ($this->repositoryHasNoCommits) {
 
579
      $diff_base = self::GIT_MAGIC_ROOT_COMMIT;
 
580
    } else {
 
581
      $diff_base = 'HEAD';
 
582
    }
 
583
 
 
584
    // Find uncommitted changes.
 
585
    $uncommitted_future = $this->buildLocalFuture(
 
586
      array(
 
587
        'diff %C --raw %s --',
 
588
        $diff_options,
 
589
        $diff_base,
 
590
      ));
 
591
 
 
592
    $untracked_future = $this->buildLocalFuture(
 
593
      array(
 
594
        'ls-files --others --exclude-standard',
 
595
      ));
 
596
 
 
597
    // Unstaged changes
 
598
    $unstaged_future = $this->buildLocalFuture(
 
599
      array(
 
600
        'diff-files --name-only',
 
601
      ));
 
602
 
 
603
    $futures = array(
 
604
      $uncommitted_future,
 
605
      $untracked_future,
 
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.
 
610
    );
 
611
 
 
612
    Futures($futures)->resolveAll();
 
613
 
 
614
    // We're clear to start the `git diff-files` now.
 
615
    $unstaged_future->start();
 
616
 
 
617
    $result = new PhutilArrayWithDefaultValue();
 
618
 
 
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);
 
623
    }
 
624
 
 
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;
 
631
      }
 
632
    }
 
633
 
 
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;
 
640
      }
 
641
    }
 
642
 
 
643
    return $result->toArray();
 
644
  }
 
645
 
 
646
  protected function buildCommitRangeStatus() {
 
647
    list($stdout, $stderr) = $this->execxLocal(
 
648
      'diff %C --raw %s --',
 
649
      $this->getDiffBaseOptions(),
 
650
      $this->getBaseCommit());
 
651
 
 
652
    return $this->parseGitStatus($stdout);
 
653
  }
 
654
 
 
655
  public function getGitConfig($key, $default = null) {
 
656
    list($err, $stdout) = $this->execManualLocal('config %s', $key);
 
657
    if ($err) {
 
658
      return $default;
 
659
    }
 
660
    return rtrim($stdout);
 
661
  }
 
662
 
 
663
  public function getAuthor() {
 
664
    list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
 
665
    return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
 
666
  }
 
667
 
 
668
  public function addToCommit(array $paths) {
 
669
    $this->execxLocal(
 
670
      'add -A -- %Ls',
 
671
      $paths);
 
672
    $this->reloadWorkingCopy();
 
673
    return $this;
 
674
  }
 
675
 
 
676
  public function doCommit($message) {
 
677
    $tmp_file = new TempFile();
 
678
    Filesystem::writeFile($tmp_file, $message);
 
679
 
 
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.
 
682
 
 
683
    $this->execxLocal(
 
684
      'commit -F %s',
 
685
      $tmp_file);
 
686
 
 
687
    $this->reloadWorkingCopy();
 
688
 
 
689
    return $this;
 
690
  }
 
691
 
 
692
  public function amendCommit($message = null) {
 
693
    if ($message === null) {
 
694
      $this->execxLocal('commit --amend --allow-empty -C HEAD');
 
695
    } else {
 
696
      $tmp_file = new TempFile();
 
697
      Filesystem::writeFile($tmp_file, $message);
 
698
      $this->execxLocal(
 
699
        'commit --amend --allow-empty -F %s',
 
700
        $tmp_file);
 
701
    }
 
702
 
 
703
    $this->reloadWorkingCopy();
 
704
    return $this;
 
705
  }
 
706
 
 
707
  public function getPreReceiveHookStatus($old_ref, $new_ref) {
 
708
    $options = $this->getDiffBaseOptions();
 
709
    list($stdout) = $this->execxLocal(
 
710
      "diff {$options} --raw %s %s --",
 
711
      $old_ref,
 
712
      $new_ref);
 
713
    return $this->parseGitStatus($stdout, $full = true);
 
714
  }
 
715
 
 
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,
 
721
    );
 
722
 
 
723
    $status = trim($status);
 
724
    $lines = array();
 
725
    foreach (explode("\n", $status) as $line) {
 
726
      if ($line) {
 
727
        $lines[] = preg_split("/[ \t]/", $line, 6);
 
728
      }
 
729
    }
 
730
 
 
731
    $files = array();
 
732
    foreach ($lines as $line) {
 
733
      $mask = 0;
 
734
      $flag = $line[4];
 
735
      $file = $line[5];
 
736
      foreach ($flags as $key => $bits) {
 
737
        if ($flag == $key) {
 
738
          $mask |= $bits;
 
739
        }
 
740
      }
 
741
      if ($full) {
 
742
        $files[$file] = array(
 
743
          'mask' => $mask,
 
744
          'ref'  => rtrim($line[3], '.'),
 
745
        );
 
746
      } else {
 
747
        $files[$file] = $mask;
 
748
      }
 
749
    }
 
750
 
 
751
    return $files;
 
752
  }
 
753
 
 
754
  public function getAllFiles() {
 
755
    $future = $this->buildLocalFuture(array('ls-files -z'));
 
756
    return id(new LinesOfALargeExecFuture($future))
 
757
      ->setDelimiter("\0");
 
758
  }
 
759
 
 
760
  public function getChangedFiles($since_commit) {
 
761
    list($stdout) = $this->execxLocal(
 
762
      'diff --raw %s',
 
763
      $since_commit);
 
764
    return $this->parseGitStatus($stdout);
 
765
  }
 
766
 
 
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(),
 
772
      $path);
 
773
 
 
774
    $blame = array();
 
775
    foreach (explode("\n", trim($stdout)) as $line) {
 
776
      if (!strlen($line)) {
 
777
        continue;
 
778
      }
 
779
 
 
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] == '^') {
 
784
        continue;
 
785
      }
 
786
 
 
787
      $matches = null;
 
788
      $ok = preg_match(
 
789
        '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/',
 
790
        $line,
 
791
        $matches);
 
792
      if (!$ok) {
 
793
        throw new Exception("Bad blame? `{$line}'");
 
794
      }
 
795
      $revision = $matches[1];
 
796
      $author = $matches[2];
 
797
 
 
798
      $blame[] = array($author, $revision);
 
799
    }
 
800
 
 
801
    return $blame;
 
802
  }
 
803
 
 
804
  public function getOriginalFileData($path) {
 
805
    return $this->getFileDataAtRevision($path, $this->getBaseCommit());
 
806
  }
 
807
 
 
808
  public function getCurrentFileData($path) {
 
809
    return $this->getFileDataAtRevision($path, 'HEAD');
 
810
  }
 
811
 
 
812
  private function parseGitTree($stdout) {
 
813
    $result = array();
 
814
 
 
815
    $stdout = trim($stdout);
 
816
    if (!strlen($stdout)) {
 
817
      return $result;
 
818
    }
 
819
 
 
820
    $lines = explode("\n", $stdout);
 
821
    foreach ($lines as $line) {
 
822
      $matches = array();
 
823
      $ok = preg_match(
 
824
        '/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/',
 
825
        $line,
 
826
        $matches);
 
827
      if (!$ok) {
 
828
        throw new Exception('Failed to parse git ls-tree output!');
 
829
      }
 
830
      $result[$matches[4]] = array(
 
831
        'mode' => $matches[1],
 
832
        'type' => $matches[2],
 
833
        'ref'  => $matches[3],
 
834
      );
 
835
    }
 
836
    return $result;
 
837
  }
 
838
 
 
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,
 
843
    // which is silly.
 
844
 
 
845
    list($stdout) = $this->execxLocal(
 
846
      'ls-tree %s -- %s',
 
847
      $revision,
 
848
      $path);
 
849
 
 
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.
 
854
      return null;
 
855
    }
 
856
 
 
857
    if ($info[$path]['type'] != 'blob') {
 
858
      // Path is or was a directory, not a file.
 
859
      return null;
 
860
    }
 
861
 
 
862
    list($stdout) = $this->execxLocal(
 
863
      'cat-file blob %s',
 
864
       $info[$path]['ref']);
 
865
    return $stdout;
 
866
  }
 
867
 
 
868
  /**
 
869
   * Returns names of all the branches in the current repository.
 
870
   *
 
871
   * @return list<dict<string, string>> Dictionary of branch information.
 
872
   */
 
873
  public function getAllBranches() {
 
874
    list($branch_info) = $this->execxLocal(
 
875
      'branch --no-color');
 
876
    $lines = explode("\n", rtrim($branch_info));
 
877
 
 
878
    $result = array();
 
879
    foreach ($lines as $line) {
 
880
 
 
881
      if (preg_match('@^[* ]+\(no branch|detached from \w+/\w+\)@', $line)) {
 
882
        // This is indicating that the working copy is in a detached state;
 
883
        // just ignore it.
 
884
        continue;
 
885
      }
 
886
 
 
887
      list($current, $name) = preg_split('/\s+/', $line, 2);
 
888
      $result[] = array(
 
889
        'current' => !empty($current),
 
890
        'name'    => $name,
 
891
      );
 
892
    }
 
893
 
 
894
    return $result;
 
895
  }
 
896
 
 
897
  public function getWorkingCopyRevision() {
 
898
    list($stdout) = $this->execxLocal('rev-parse HEAD');
 
899
    return rtrim($stdout, "\n");
 
900
  }
 
901
 
 
902
  public function getUnderlyingWorkingCopyRevision() {
 
903
    list($err, $stdout) = $this->execManualLocal('svn find-rev HEAD');
 
904
    if (!$err && $stdout) {
 
905
      return rtrim($stdout, "\n");
 
906
    }
 
907
    return $this->getWorkingCopyRevision();
 
908
  }
 
909
 
 
910
  public function isHistoryDefaultImmutable() {
 
911
    return false;
 
912
  }
 
913
 
 
914
  public function supportsAmend() {
 
915
    return true;
 
916
  }
 
917
 
 
918
  public function supportsCommitRanges() {
 
919
    return true;
 
920
  }
 
921
 
 
922
  public function supportsLocalCommits() {
 
923
    return true;
 
924
  }
 
925
 
 
926
  public function hasLocalCommit($commit) {
 
927
    try {
 
928
      if (!$this->getCanonicalRevisionName($commit)) {
 
929
        return false;
 
930
      }
 
931
    } catch (CommandException $exception) {
 
932
      return false;
 
933
    }
 
934
    return true;
 
935
  }
 
936
 
 
937
  public function getAllLocalChanges() {
 
938
    $diff = $this->getFullGitDiff($this->getBaseCommit());
 
939
    if (!strlen(trim($diff))) {
 
940
      return array();
 
941
    }
 
942
    $parser = new ArcanistDiffParser();
 
943
    return $parser->parseDiff($diff);
 
944
  }
 
945
 
 
946
  public function supportsLocalBranchMerge() {
 
947
    return true;
 
948
  }
 
949
 
 
950
  public function performLocalBranchMerge($branch, $message) {
 
951
    if (!$branch) {
 
952
      throw new ArcanistUsageException(
 
953
        'Under git, you must specify the branch you want to merge.');
 
954
    }
 
955
    $err = phutil_passthru(
 
956
      '(cd %s && git merge --no-ff -m %s %s)',
 
957
      $this->getPath(),
 
958
      $message,
 
959
      $branch);
 
960
 
 
961
    if ($err) {
 
962
      throw new ArcanistUsageException('Merge failed!');
 
963
    }
 
964
  }
 
965
 
 
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).";
 
969
  }
 
970
 
 
971
  public function getCommitMessage($commit) {
 
972
    list($message) = $this->execxLocal(
 
973
      'log -n1 --format=%C %s --',
 
974
      '%s%n%n%b',
 
975
      $commit);
 
976
    return $message;
 
977
  }
 
978
 
 
979
  public function loadWorkingCopyDifferentialRevisions(
 
980
    ConduitClient $conduit,
 
981
    array $query) {
 
982
 
 
983
    $messages = $this->getGitCommitLog();
 
984
    if (!strlen($messages)) {
 
985
      return array();
 
986
    }
 
987
 
 
988
    $parser = new ArcanistDiffParser();
 
989
    $messages = $parser->parseDiff($messages);
 
990
 
 
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();
 
1000
      }
 
1001
    }
 
1002
 
 
1003
    if ($revision_ids) {
 
1004
      $results = $conduit->callMethodSynchronous(
 
1005
        'differential.query',
 
1006
        $query + array(
 
1007
          'ids' => $revision_ids,
 
1008
        ));
 
1009
 
 
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'.";
 
1014
      }
 
1015
 
 
1016
      return $results;
 
1017
    }
 
1018
 
 
1019
    // If we didn't succeed, try to find revisions by hash.
 
1020
    $hashes = array();
 
1021
    foreach ($this->getLocalCommitInformation() as $commit) {
 
1022
      $hashes[] = array('gtcm', $commit['commit']);
 
1023
      $hashes[] = array('gttr', $commit['tree']);
 
1024
    }
 
1025
 
 
1026
    $results = $conduit->callMethodSynchronous(
 
1027
      'differential.query',
 
1028
      $query + array(
 
1029
        'commitHashes' => $hashes,
 
1030
      ));
 
1031
 
 
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.';
 
1036
    }
 
1037
 
 
1038
    return $results;
 
1039
  }
 
1040
 
 
1041
  public function updateWorkingCopy() {
 
1042
    $this->execxLocal('pull');
 
1043
    $this->reloadWorkingCopy();
 
1044
  }
 
1045
 
 
1046
  public function getCommitSummary($commit) {
 
1047
    if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
 
1048
      return '(The Empty Tree)';
 
1049
    }
 
1050
 
 
1051
    list($summary) = $this->execxLocal(
 
1052
      'log -n 1 --format=%C %s',
 
1053
      '%s',
 
1054
      $commit);
 
1055
 
 
1056
    return trim($summary);
 
1057
  }
 
1058
 
 
1059
  public function backoutCommit($commit_hash) {
 
1060
    $this->execxLocal(
 
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.");
 
1066
    }
 
1067
  }
 
1068
 
 
1069
  public function getBackoutMessage($commit_hash) {
 
1070
    return 'This reverts commit '.$commit_hash.'.';
 
1071
  }
 
1072
 
 
1073
  public function isGitSubversionRepo() {
 
1074
    return Filesystem::pathExists($this->getPath('.git/svn'));
 
1075
  }
 
1076
 
 
1077
  public function resolveBaseCommitRule($rule, $source) {
 
1078
    list($type, $name) = explode(':', $rule, 2);
 
1079
 
 
1080
    switch ($type) {
 
1081
      case 'git':
 
1082
        $matches = null;
 
1083
        if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
 
1084
          list($err, $merge_base) = $this->execManualLocal(
 
1085
            'merge-base %s HEAD',
 
1086
            $matches[1]);
 
1087
          if (!$err) {
 
1088
            $this->setBaseCommitExplanation(
 
1089
              "it is the merge-base of '{$matches[1]}' and HEAD, as ".
 
1090
              "specified by '{$rule}' in your {$source} 'base' ".
 
1091
              "configuration.");
 
1092
            return trim($merge_base);
 
1093
          }
 
1094
        } else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
 
1095
          list($err, $merge_base) = $this->execManualLocal(
 
1096
            'merge-base %s HEAD',
 
1097
            $matches[1]);
 
1098
          if ($err) {
 
1099
            return null;
 
1100
          }
 
1101
          $merge_base = trim($merge_base);
 
1102
 
 
1103
          list($commits) = $this->execxLocal(
 
1104
            'log --format=%C %s..HEAD --',
 
1105
            '%H',
 
1106
            $merge_base);
 
1107
          $commits = array_filter(explode("\n", $commits));
 
1108
 
 
1109
          if (!$commits) {
 
1110
            return null;
 
1111
          }
 
1112
 
 
1113
          $commits[] = $merge_base;
 
1114
 
 
1115
          $head_branch_count = null;
 
1116
          foreach ($commits as $commit) {
 
1117
            list($branches) = $this->execxLocal(
 
1118
              'branch --contains %s',
 
1119
              $commit);
 
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, ' *');
 
1131
              }
 
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}).");
 
1137
              return $commit;
 
1138
            }
 
1139
          }
 
1140
        } else {
 
1141
          list($err) = $this->execManualLocal(
 
1142
            'cat-file -t %s',
 
1143
            $name);
 
1144
          if (!$err) {
 
1145
            $this->setBaseCommitExplanation(
 
1146
              "it is specified by '{$rule}' in your {$source} 'base' ".
 
1147
              "configuration.");
 
1148
            return $name;
 
1149
          }
 
1150
        }
 
1151
        break;
 
1152
      case 'arc':
 
1153
        switch ($name) {
 
1154
          case 'empty':
 
1155
            $this->setBaseCommitExplanation(
 
1156
              "you specified '{$rule}' in your {$source} 'base' ".
 
1157
              "configuration.");
 
1158
            return self::GIT_MAGIC_ROOT_COMMIT;
 
1159
          case 'amended':
 
1160
            $text = $this->getCommitMessage('HEAD');
 
1161
            $message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
 
1162
              $text);
 
1163
            if ($message->getRevisionID()) {
 
1164
              $this->setBaseCommitExplanation(
 
1165
                "HEAD has been amended with 'Differential Revision:', ".
 
1166
                "as specified by '{$rule}' in your {$source} 'base' ".
 
1167
                "configuration.");
 
1168
              return 'HEAD^';
 
1169
            }
 
1170
            break;
 
1171
          case 'upstream':
 
1172
            list($err, $upstream) = $this->execManualLocal(
 
1173
              'rev-parse --abbrev-ref --symbolic-full-name %s',
 
1174
              '@{upstream}');
 
1175
            if (!$err) {
 
1176
              $upstream = rtrim($upstream);
 
1177
              list($upstream_merge_base) = $this->execxLocal(
 
1178
                'merge-base %s HEAD',
 
1179
                $upstream);
 
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;
 
1186
            }
 
1187
            break;
 
1188
          case 'this':
 
1189
            $this->setBaseCommitExplanation(
 
1190
              "you specified '{$rule}' in your {$source} 'base' ".
 
1191
              "configuration.");
 
1192
            return 'HEAD^';
 
1193
        }
 
1194
      default:
 
1195
        return null;
 
1196
    }
 
1197
 
 
1198
    return null;
 
1199
  }
 
1200
 
 
1201
  public function canStashChanges() {
 
1202
    return true;
 
1203
  }
 
1204
 
 
1205
  public function stashChanges() {
 
1206
    $this->execxLocal('stash');
 
1207
    $this->reloadWorkingCopy();
 
1208
  }
 
1209
 
 
1210
  public function unstashChanges() {
 
1211
    $this->execxLocal('stash pop');
 
1212
  }
 
1213
 
 
1214
  protected function didReloadCommitRange() {
 
1215
    // After an amend, the symbolic head may resolve to a different commit.
 
1216
    $this->resolvedHeadCommit = null;
 
1217
  }
 
1218
 
 
1219
}