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

« back to all changes in this revision

Viewing changes to src/repository/api/ArcanistMercurialAPI.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 the Mercurial working copies.
 
5
 */
 
6
final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
 
7
 
 
8
  private $branch;
 
9
  private $localCommitInfo;
 
10
  private $rawDiffCache = array();
 
11
 
 
12
  private $supportsRebase;
 
13
  private $supportsPhases;
 
14
 
 
15
  protected function buildLocalFuture(array $argv) {
 
16
    // Mercurial has a "defaults" feature which basically breaks automation by
 
17
    // allowing the user to add random flags to any command. This feature is
 
18
    // "deprecated" and "a bad idea" that you should "forget ... existed"
 
19
    // according to project lead Matt Mackall:
 
20
    //
 
21
    //  http://markmail.org/message/hl3d6eprubmkkqh5
 
22
    //
 
23
    // There is an HGPLAIN environmental variable which enables "plain mode"
 
24
    // and hopefully disables this stuff.
 
25
 
 
26
    if (phutil_is_windows()) {
 
27
      $argv[0] = 'set HGPLAIN=1 & hg '.$argv[0];
 
28
    } else {
 
29
      $argv[0] = 'HGPLAIN=1 hg '.$argv[0];
 
30
    }
 
31
 
 
32
    $future = newv('ExecFuture', $argv);
 
33
    $future->setCWD($this->getPath());
 
34
    return $future;
 
35
  }
 
36
 
 
37
  public function execPassthru($pattern /* , ... */) {
 
38
    $args = func_get_args();
 
39
    if (phutil_is_windows()) {
 
40
      $args[0] = 'hg '.$args[0];
 
41
    } else {
 
42
      $args[0] = 'HGPLAIN=1 hg '.$args[0];
 
43
    }
 
44
 
 
45
    return call_user_func_array('phutil_passthru', $args);
 
46
  }
 
47
 
 
48
  public function getSourceControlSystemName() {
 
49
    return 'hg';
 
50
  }
 
51
 
 
52
  public function getMetadataPath() {
 
53
    return $this->getPath('.hg');
 
54
  }
 
55
 
 
56
  public function getSourceControlBaseRevision() {
 
57
    return $this->getCanonicalRevisionName($this->getBaseCommit());
 
58
  }
 
59
 
 
60
  public function getCanonicalRevisionName($string) {
 
61
    $match = null;
 
62
    if ($this->isHgSubversionRepo() &&
 
63
        preg_match('/@([0-9]+)$/', $string, $match)) {
 
64
      $string = hgsprintf('svnrev(%s)', $match[1]);
 
65
    }
 
66
 
 
67
    list($stdout) = $this->execxLocal(
 
68
      'log -l 1 --template %s -r %s --',
 
69
      '{node}',
 
70
      $string);
 
71
    return $stdout;
 
72
  }
 
73
 
 
74
  public function getHashFromFromSVNRevisionNumber($revision_id) {
 
75
    $matches = array();
 
76
    $string = hgsprintf('svnrev(%s)', $revision_id);
 
77
    list($stdout) = $this->execxLocal(
 
78
      'log -l 1 --template %s -r %s --',
 
79
      '{node}',
 
80
       $string);
 
81
    if (!$stdout) {
 
82
      throw new ArcanistUsageException(
 
83
        "Cannot find the HG equivalent of {$revision_id} given.");
 
84
    }
 
85
    return $stdout;
 
86
  }
 
87
 
 
88
 
 
89
  public function getSVNRevisionNumberFromHash($hash) {
 
90
    $matches = array();
 
91
    list($stdout) = $this->execxLocal(
 
92
      'log -r %s --template {svnrev}', $hash);
 
93
    if (!$stdout) {
 
94
      throw new ArcanistUsageException(
 
95
        "Cannot find the SVN equivalent of {$hash} given.");
 
96
    }
 
97
    return $stdout;
 
98
  }
 
99
 
 
100
  public function getSourceControlPath() {
 
101
    return '/';
 
102
  }
 
103
 
 
104
  public function getBranchName() {
 
105
    if (!$this->branch) {
 
106
      list($stdout) = $this->execxLocal('branch');
 
107
      $this->branch = trim($stdout);
 
108
    }
 
109
    return $this->branch;
 
110
  }
 
111
 
 
112
  public function didReloadCommitRange() {
 
113
    $this->localCommitInfo = null;
 
114
  }
 
115
 
 
116
  protected function buildBaseCommit($symbolic_commit) {
 
117
    if ($symbolic_commit !== null) {
 
118
      try {
 
119
        $commit = $this->getCanonicalRevisionName(
 
120
          hgsprintf('ancestor(%s,.)', $symbolic_commit));
 
121
      } catch (Exception $ex) {
 
122
        // Try it as a revset instead of a commit id
 
123
        try {
 
124
          $commit = $this->getCanonicalRevisionName(
 
125
            hgsprintf('ancestor(%R,.)', $symbolic_commit));
 
126
        } catch (Exception $ex) {
 
127
          throw new ArcanistUsageException(
 
128
            "Commit '{$symbolic_commit}' is not a valid Mercurial commit ".
 
129
            "identifier.");
 
130
        }
 
131
      }
 
132
 
 
133
      $this->setBaseCommitExplanation(
 
134
        'it is the greatest common ancestor of the working directory '.
 
135
        'and the commit you specified explicitly.');
 
136
      return $commit;
 
137
    }
 
138
 
 
139
    if ($this->getBaseCommitArgumentRules() ||
 
140
        $this->getConfigurationManager()->getConfigFromAnySource('base')) {
 
141
      $base = $this->resolveBaseCommit();
 
142
      if (!$base) {
 
143
        throw new ArcanistUsageException(
 
144
          "None of the rules in your 'base' configuration matched a valid ".
 
145
          "commit. Adjust rules or specify which commit you want to use ".
 
146
          "explicitly.");
 
147
      }
 
148
      return $base;
 
149
    }
 
150
 
 
151
    // Mercurial 2.1 and up have phases which indicate if something is
 
152
    // published or not. To find which revs are outgoing, it's much
 
153
    // faster to check the phase instead of actually checking the server.
 
154
    if ($this->supportsPhases()) {
 
155
      list($err, $stdout) = $this->execManualLocal(
 
156
        'log --branch %s -r %s --style default',
 
157
        $this->getBranchName(),
 
158
        'draft()');
 
159
    } else {
 
160
      list($err, $stdout) = $this->execManualLocal(
 
161
        'outgoing --branch %s --style default',
 
162
        $this->getBranchName());
 
163
    }
 
164
 
 
165
    if (!$err) {
 
166
      $logs = ArcanistMercurialParser::parseMercurialLog($stdout);
 
167
    } else {
 
168
      // Mercurial (in some versions?) raises an error when there's nothing
 
169
      // outgoing.
 
170
      $logs = array();
 
171
    }
 
172
 
 
173
    if (!$logs) {
 
174
      $this->setBaseCommitExplanation(
 
175
        'you have no outgoing commits, so arc assumes you intend to submit '.
 
176
        'uncommitted changes in the working copy.');
 
177
      return $this->getWorkingCopyRevision();
 
178
    }
 
179
 
 
180
    $outgoing_revs = ipull($logs, 'rev');
 
181
 
 
182
    // This is essentially an implementation of a theoretical `hg merge-base`
 
183
    // command.
 
184
    $against = $this->getWorkingCopyRevision();
 
185
    while (true) {
 
186
      // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is
 
187
      // new as of July 2011, so do this in a compatible way. Also, "hg log"
 
188
      // and "hg outgoing" don't necessarily show parents (even if given an
 
189
      // explicit template consisting of just the parents token) so we need
 
190
      // to separately execute "hg parents".
 
191
 
 
192
      list($stdout) = $this->execxLocal(
 
193
        'parents --style default --rev %s',
 
194
        $against);
 
195
      $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout);
 
196
 
 
197
      list($p1, $p2) = array_merge($parents_logs, array(null, null));
 
198
 
 
199
      if ($p1 && !in_array($p1['rev'], $outgoing_revs)) {
 
200
        $against = $p1['rev'];
 
201
        break;
 
202
      } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) {
 
203
        $against = $p2['rev'];
 
204
        break;
 
205
      } else if ($p1) {
 
206
        $against = $p1['rev'];
 
207
      } else {
 
208
        // This is the case where you have a new repository and the entire
 
209
        // thing is outgoing; Mercurial literally accepts "--rev null" as
 
210
        // meaning "diff against the empty state".
 
211
        $against = 'null';
 
212
        break;
 
213
      }
 
214
    }
 
215
 
 
216
    if ($against == 'null') {
 
217
      $this->setBaseCommitExplanation(
 
218
        'this is a new repository (all changes are outgoing).');
 
219
    } else {
 
220
      $this->setBaseCommitExplanation(
 
221
        'it is the first commit reachable from the working copy state '.
 
222
        'which is not outgoing.');
 
223
    }
 
224
 
 
225
    return $against;
 
226
  }
 
227
 
 
228
  public function getLocalCommitInformation() {
 
229
    if ($this->localCommitInfo === null) {
 
230
      $base_commit = $this->getBaseCommit();
 
231
      list($info) = $this->execxLocal(
 
232
        'log --template %s --rev %s --branch %s --',
 
233
        "{node}\1{rev}\1{author}\1".
 
234
          "{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2",
 
235
        hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
 
236
        $this->getBranchName());
 
237
      $logs = array_filter(explode("\2", $info));
 
238
 
 
239
      $last_node = null;
 
240
 
 
241
      $futures = array();
 
242
 
 
243
      $commits = array();
 
244
      foreach ($logs as $log) {
 
245
        list($node, $rev, $full_author, $date, $branch, $tag,
 
246
          $parents, $desc) = explode("\1", $log, 9);
 
247
 
 
248
        list ($author, $author_email) = $this->parseFullAuthor($full_author);
 
249
 
 
250
        // NOTE: If a commit has only one parent, {parents} returns empty.
 
251
        // If it has two parents, {parents} returns revs and short hashes, not
 
252
        // full hashes. Try to avoid making calls to "hg parents" because it's
 
253
        // relatively expensive.
 
254
        $commit_parents = null;
 
255
        if (!$parents) {
 
256
          if ($last_node) {
 
257
            $commit_parents = array($last_node);
 
258
          }
 
259
        }
 
260
 
 
261
        if (!$commit_parents) {
 
262
          // We didn't get a cheap hit on previous commit, so do the full-cost
 
263
          // "hg parents" call. We can run these in parallel, at least.
 
264
          $futures[$node] = $this->execFutureLocal(
 
265
            'parents --template %s --rev %s',
 
266
            '{node}\n',
 
267
            $node);
 
268
        }
 
269
 
 
270
        $commits[$node] = array(
 
271
          'author'  => $author,
 
272
          'time'    => strtotime($date),
 
273
          'branch'  => $branch,
 
274
          'tag'     => $tag,
 
275
          'commit'  => $node,
 
276
          'rev'     => $node, // TODO: Remove eventually.
 
277
          'local'   => $rev,
 
278
          'parents' => $commit_parents,
 
279
          'summary' => head(explode("\n", $desc)),
 
280
          'message' => $desc,
 
281
          'authorEmail' => $author_email,
 
282
        );
 
283
 
 
284
        $last_node = $node;
 
285
      }
 
286
 
 
287
      foreach (Futures($futures)->limit(4) as $node => $future) {
 
288
        list($parents) = $future->resolvex();
 
289
        $parents = array_filter(explode("\n", $parents));
 
290
        $commits[$node]['parents'] = $parents;
 
291
      }
 
292
 
 
293
      // Put commits in newest-first order, to be consistent with Git and the
 
294
      // expected order of "hg log" and "git log" under normal circumstances.
 
295
      // The order of ancestors() is oldest-first.
 
296
      $commits = array_reverse($commits);
 
297
 
 
298
      $this->localCommitInfo = $commits;
 
299
    }
 
300
 
 
301
    return $this->localCommitInfo;
 
302
  }
 
303
 
 
304
  public function getAllFiles() {
 
305
    // TODO: Handle paths with newlines.
 
306
    $future = $this->buildLocalFuture(array('manifest'));
 
307
    return new LinesOfALargeExecFuture($future);
 
308
  }
 
309
 
 
310
  public function getChangedFiles($since_commit) {
 
311
    list($stdout) = $this->execxLocal(
 
312
      'status --rev %s',
 
313
      $since_commit);
 
314
    return ArcanistMercurialParser::parseMercurialStatus($stdout);
 
315
  }
 
316
 
 
317
  public function getBlame($path) {
 
318
    list($stdout) = $this->execxLocal(
 
319
      'annotate -u -v -c --rev %s -- %s',
 
320
      $this->getBaseCommit(),
 
321
      $path);
 
322
 
 
323
    $lines = phutil_split_lines($stdout, $retain_line_endings = true);
 
324
 
 
325
    $blame = array();
 
326
    foreach ($lines as $line) {
 
327
      if (!strlen($line)) {
 
328
        continue;
 
329
      }
 
330
 
 
331
      $matches = null;
 
332
      $ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches);
 
333
 
 
334
      if (!$ok) {
 
335
        throw new Exception("Unable to parse Mercurial blame line: {$line}");
 
336
      }
 
337
 
 
338
      $revision = $matches[2];
 
339
      $author = trim($matches[1]);
 
340
      $blame[] = array($author, $revision);
 
341
    }
 
342
 
 
343
    return $blame;
 
344
  }
 
345
 
 
346
  protected function buildUncommittedStatus() {
 
347
    list($stdout) = $this->execxLocal('status');
 
348
 
 
349
    $results = new PhutilArrayWithDefaultValue();
 
350
 
 
351
    $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
 
352
    foreach ($working_status as $path => $mask) {
 
353
      if (!($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED)) {
 
354
        // Mark tracked files as uncommitted.
 
355
        $mask |= self::FLAG_UNCOMMITTED;
 
356
      }
 
357
 
 
358
      $results[$path] |= $mask;
 
359
    }
 
360
 
 
361
    return $results->toArray();
 
362
  }
 
363
 
 
364
  protected function buildCommitRangeStatus() {
 
365
    // TODO: Possibly we should use "hg status --rev X --rev ." for this
 
366
    // instead, but we must run "hg diff" later anyway in most cases, so
 
367
    // building and caching it shouldn't hurt us.
 
368
 
 
369
    $diff = $this->getFullMercurialDiff();
 
370
    if (!$diff) {
 
371
      return array();
 
372
    }
 
373
 
 
374
    $parser = new ArcanistDiffParser();
 
375
    $changes = $parser->parseDiff($diff);
 
376
 
 
377
    $status_map = array();
 
378
    foreach ($changes as $change) {
 
379
      $flags = 0;
 
380
      switch ($change->getType()) {
 
381
        case ArcanistDiffChangeType::TYPE_ADD:
 
382
        case ArcanistDiffChangeType::TYPE_MOVE_HERE:
 
383
        case ArcanistDiffChangeType::TYPE_COPY_HERE:
 
384
          $flags |= self::FLAG_ADDED;
 
385
          break;
 
386
        case ArcanistDiffChangeType::TYPE_CHANGE:
 
387
        case ArcanistDiffChangeType::TYPE_COPY_AWAY: // Check for changes?
 
388
          $flags |= self::FLAG_MODIFIED;
 
389
          break;
 
390
        case ArcanistDiffChangeType::TYPE_DELETE:
 
391
        case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
 
392
        case ArcanistDiffChangeType::TYPE_MULTICOPY:
 
393
          $flags |= self::FLAG_DELETED;
 
394
          break;
 
395
      }
 
396
      $status_map[$change->getCurrentPath()] = $flags;
 
397
    }
 
398
 
 
399
    return $status_map;
 
400
  }
 
401
 
 
402
  protected function didReloadWorkingCopy() {
 
403
    // Diffs are against ".", so we need to drop the cache if we change the
 
404
    // working copy.
 
405
    $this->rawDiffCache = array();
 
406
    $this->branch = null;
 
407
  }
 
408
 
 
409
  private function getDiffOptions() {
 
410
    $options = array(
 
411
      '--git',
 
412
      '-U'.$this->getDiffLinesOfContext(),
 
413
    );
 
414
    return implode(' ', $options);
 
415
  }
 
416
 
 
417
  public function getRawDiffText($path) {
 
418
    $options = $this->getDiffOptions();
 
419
 
 
420
    $range = $this->getBaseCommit();
 
421
 
 
422
    $raw_diff_cache_key = $options.' '.$range.' '.$path;
 
423
    if (idx($this->rawDiffCache, $raw_diff_cache_key)) {
 
424
      return idx($this->rawDiffCache, $raw_diff_cache_key);
 
425
    }
 
426
 
 
427
    list($stdout) = $this->execxLocal(
 
428
      'diff %C --rev %s -- %s',
 
429
      $options,
 
430
      $range,
 
431
      $path);
 
432
 
 
433
    $this->rawDiffCache[$raw_diff_cache_key] = $stdout;
 
434
 
 
435
    return $stdout;
 
436
  }
 
437
 
 
438
  public function getFullMercurialDiff() {
 
439
    return $this->getRawDiffText('');
 
440
  }
 
441
 
 
442
  public function getOriginalFileData($path) {
 
443
    return $this->getFileDataAtRevision($path, $this->getBaseCommit());
 
444
  }
 
445
 
 
446
  public function getCurrentFileData($path) {
 
447
    return $this->getFileDataAtRevision(
 
448
      $path,
 
449
      $this->getWorkingCopyRevision());
 
450
  }
 
451
 
 
452
  public function getBulkOriginalFileData($paths) {
 
453
    return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit());
 
454
  }
 
455
 
 
456
  public function getBulkCurrentFileData($paths) {
 
457
    return $this->getBulkFileDataAtRevision(
 
458
      $paths,
 
459
      $this->getWorkingCopyRevision());
 
460
  }
 
461
 
 
462
  private function getBulkFileDataAtRevision($paths, $revision) {
 
463
    // Calling 'hg cat' on each file individually is slow (1 second per file
 
464
    // on a large repo) because mercurial has to decompress and parse the
 
465
    // entire manifest every time. Do it in one large batch instead.
 
466
 
 
467
    // hg cat will write the file data to files in a temp directory
 
468
    $tmpdir = Filesystem::createTemporaryDirectory();
 
469
 
 
470
    // Mercurial doesn't create the directories for us :(
 
471
    foreach ($paths as $path) {
 
472
      $tmppath = $tmpdir.'/'.$path;
 
473
      Filesystem::createDirectory(dirname($tmppath), 0755, true);
 
474
    }
 
475
 
 
476
    list($err, $stdout) = $this->execManualLocal(
 
477
      'cat --rev %s --output %s -- %C',
 
478
      $revision,
 
479
      // %p is the formatter for the repo-relative filepath
 
480
      $tmpdir.'/%p',
 
481
      implode(' ', $paths));
 
482
 
 
483
    $filedata = array();
 
484
    foreach ($paths as $path) {
 
485
      $tmppath = $tmpdir.'/'.$path;
 
486
      if (Filesystem::pathExists($tmppath)) {
 
487
        $filedata[$path] = Filesystem::readFile($tmppath);
 
488
      }
 
489
    }
 
490
 
 
491
    Filesystem::remove($tmpdir);
 
492
 
 
493
    return $filedata;
 
494
  }
 
495
 
 
496
  private function getFileDataAtRevision($path, $revision) {
 
497
    list($err, $stdout) = $this->execManualLocal(
 
498
      'cat --rev %s -- %s',
 
499
      $revision,
 
500
      $path);
 
501
    if ($err) {
 
502
      // Assume this is "no file at revision", i.e. a deleted or added file.
 
503
      return null;
 
504
    } else {
 
505
      return $stdout;
 
506
    }
 
507
  }
 
508
 
 
509
  public function getWorkingCopyRevision() {
 
510
    return '.';
 
511
  }
 
512
 
 
513
  public function isHistoryDefaultImmutable() {
 
514
    return true;
 
515
  }
 
516
 
 
517
  public function supportsAmend() {
 
518
    list($err, $stdout) = $this->execManualLocal('help commit');
 
519
    if ($err) {
 
520
      return false;
 
521
    } else {
 
522
      return (strpos($stdout, 'amend') !== false);
 
523
    }
 
524
  }
 
525
 
 
526
  public function supportsRebase() {
 
527
    if ($this->supportsRebase === null) {
 
528
      list ($err) = $this->execManualLocal('help rebase');
 
529
      $this->supportsRebase = $err === 0;
 
530
    }
 
531
 
 
532
    return $this->supportsRebase;
 
533
  }
 
534
 
 
535
  public function supportsPhases() {
 
536
    if ($this->supportsPhases === null) {
 
537
      list ($err) = $this->execManualLocal('help phase');
 
538
      $this->supportsPhases = $err === 0;
 
539
    }
 
540
 
 
541
    return $this->supportsPhases;
 
542
  }
 
543
 
 
544
  public function supportsCommitRanges() {
 
545
    return true;
 
546
  }
 
547
 
 
548
  public function supportsLocalCommits() {
 
549
    return true;
 
550
  }
 
551
 
 
552
  public function getAllBranches() {
 
553
    list($branch_info) = $this->execxLocal('bookmarks');
 
554
    if (trim($branch_info) == 'no bookmarks set') {
 
555
      return array();
 
556
    }
 
557
 
 
558
    $matches = null;
 
559
    preg_match_all(
 
560
      '/^\s*(\*?)\s*(.+)\s(\S+)$/m',
 
561
      $branch_info,
 
562
      $matches,
 
563
      PREG_SET_ORDER);
 
564
 
 
565
    $return = array();
 
566
    foreach ($matches as $match) {
 
567
      list(, $current, $name) = $match;
 
568
      $return[] = array(
 
569
        'current' => (bool)$current,
 
570
        'name'    => rtrim($name),
 
571
      );
 
572
    }
 
573
    return $return;
 
574
  }
 
575
 
 
576
  public function hasLocalCommit($commit) {
 
577
    try {
 
578
      $this->getCanonicalRevisionName($commit);
 
579
      return true;
 
580
    } catch (Exception $ex) {
 
581
      return false;
 
582
    }
 
583
  }
 
584
 
 
585
  public function getCommitMessage($commit) {
 
586
    list($message) = $this->execxLocal(
 
587
      'log --template={desc} --rev %s',
 
588
      $commit);
 
589
    return $message;
 
590
  }
 
591
 
 
592
  public function getAllLocalChanges() {
 
593
    $diff = $this->getFullMercurialDiff();
 
594
    if (!strlen(trim($diff))) {
 
595
      return array();
 
596
    }
 
597
    $parser = new ArcanistDiffParser();
 
598
    return $parser->parseDiff($diff);
 
599
  }
 
600
 
 
601
  public function supportsLocalBranchMerge() {
 
602
    return true;
 
603
  }
 
604
 
 
605
  public function performLocalBranchMerge($branch, $message) {
 
606
    if ($branch) {
 
607
      $err = phutil_passthru(
 
608
        '(cd %s && HGPLAIN=1 hg merge --rev %s && hg commit -m %s)',
 
609
        $this->getPath(),
 
610
        $branch,
 
611
        $message);
 
612
    } else {
 
613
      $err = phutil_passthru(
 
614
        '(cd %s && HGPLAIN=1 hg merge && hg commit -m %s)',
 
615
        $this->getPath(),
 
616
        $message);
 
617
    }
 
618
 
 
619
    if ($err) {
 
620
      throw new ArcanistUsageException('Merge failed!');
 
621
    }
 
622
  }
 
623
 
 
624
  public function getFinalizedRevisionMessage() {
 
625
    return "You may now push this commit upstream, as appropriate (e.g. with ".
 
626
           "'hg push' or by printing and faxing it).";
 
627
  }
 
628
 
 
629
  public function getCommitMessageLog() {
 
630
    $base_commit = $this->getBaseCommit();
 
631
    list($stdout) = $this->execxLocal(
 
632
      'log --template %s --rev %s --branch %s --',
 
633
      "{node}\1{desc}\2",
 
634
      hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
 
635
      $this->getBranchName());
 
636
 
 
637
    $map = array();
 
638
 
 
639
    $logs = explode("\2", trim($stdout));
 
640
    foreach (array_filter($logs) as $log) {
 
641
      list($node, $desc) = explode("\1", $log);
 
642
      $map[$node] = $desc;
 
643
    }
 
644
 
 
645
    return array_reverse($map);
 
646
  }
 
647
 
 
648
  public function loadWorkingCopyDifferentialRevisions(
 
649
    ConduitClient $conduit,
 
650
    array $query) {
 
651
 
 
652
    $messages = $this->getCommitMessageLog();
 
653
    $parser = new ArcanistDiffParser();
 
654
 
 
655
    // First, try to find revisions by explicit revision IDs in commit messages.
 
656
    $reason_map = array();
 
657
    $revision_ids = array();
 
658
    foreach ($messages as $node_id => $message) {
 
659
      $object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message);
 
660
 
 
661
      if ($object->getRevisionID()) {
 
662
        $revision_ids[] = $object->getRevisionID();
 
663
        $reason_map[$object->getRevisionID()] = $node_id;
 
664
      }
 
665
    }
 
666
 
 
667
    if ($revision_ids) {
 
668
      $results = $conduit->callMethodSynchronous(
 
669
        'differential.query',
 
670
        $query + array(
 
671
          'ids' => $revision_ids,
 
672
        ));
 
673
 
 
674
      foreach ($results as $key => $result) {
 
675
        $hash = substr($reason_map[$result['id']], 0, 16);
 
676
        $results[$key]['why'] =
 
677
          "Commit message for '{$hash}' has explicit 'Differential Revision'.";
 
678
      }
 
679
 
 
680
      return $results;
 
681
    }
 
682
 
 
683
    // Try to find revisions by hash.
 
684
    $hashes = array();
 
685
    foreach ($this->getLocalCommitInformation() as $commit) {
 
686
      $hashes[] = array('hgcm', $commit['commit']);
 
687
    }
 
688
 
 
689
    if ($hashes) {
 
690
 
 
691
      // NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working
 
692
      // copy with dirty changes, there may be no local commits.
 
693
 
 
694
      $results = $conduit->callMethodSynchronous(
 
695
        'differential.query',
 
696
        $query + array(
 
697
          'commitHashes' => $hashes,
 
698
        ));
 
699
 
 
700
      foreach ($results as $key => $hash) {
 
701
        $results[$key]['why'] =
 
702
          'A mercurial commit hash in the commit range is already attached '.
 
703
          'to the Differential revision.';
 
704
      }
 
705
 
 
706
      return $results;
 
707
    }
 
708
 
 
709
    return array();
 
710
  }
 
711
 
 
712
  public function updateWorkingCopy() {
 
713
    $this->execxLocal('up');
 
714
    $this->reloadWorkingCopy();
 
715
  }
 
716
 
 
717
  private function getMercurialConfig($key, $default = null) {
 
718
    list($stdout) = $this->execxLocal('showconfig %s', $key);
 
719
    if ($stdout == '') {
 
720
      return $default;
 
721
    }
 
722
    return rtrim($stdout);
 
723
  }
 
724
 
 
725
  public function getAuthor() {
 
726
    $full_author = $this->getMercurialConfig('ui.username');
 
727
    list($author, $author_email) = $this->parseFullAuthor($full_author);
 
728
    return $author;
 
729
  }
 
730
 
 
731
  /**
 
732
   * Parse the Mercurial author field.
 
733
   *
 
734
   * Not everyone enters their email address as a part of the username
 
735
   * field. Try to make it work when it's obvious.
 
736
   *
 
737
   * @param string $full_author
 
738
   * @return array
 
739
   */
 
740
  protected function parseFullAuthor($full_author) {
 
741
    if (strpos($full_author, '@') === false) {
 
742
      $author = $full_author;
 
743
      $author_email = null;
 
744
    } else {
 
745
      $email = new PhutilEmailAddress($full_author);
 
746
      $author = $email->getDisplayName();
 
747
      $author_email = $email->getAddress();
 
748
    }
 
749
 
 
750
    return array($author, $author_email);
 
751
  }
 
752
 
 
753
  public function addToCommit(array $paths) {
 
754
    $this->execxLocal(
 
755
      'addremove -- %Ls',
 
756
      $paths);
 
757
    $this->reloadWorkingCopy();
 
758
  }
 
759
 
 
760
  public function doCommit($message) {
 
761
    $tmp_file = new TempFile();
 
762
    Filesystem::writeFile($tmp_file, $message);
 
763
    $this->execxLocal('commit -l %s', $tmp_file);
 
764
    $this->reloadWorkingCopy();
 
765
  }
 
766
 
 
767
  public function amendCommit($message = null) {
 
768
    if ($message === null) {
 
769
      $message = $this->getCommitMessage('.');
 
770
    }
 
771
 
 
772
    $tmp_file = new TempFile();
 
773
    Filesystem::writeFile($tmp_file, $message);
 
774
 
 
775
    try {
 
776
      $this->execxLocal(
 
777
        'commit --amend -l %s',
 
778
        $tmp_file);
 
779
    } catch (CommandException $ex) {
 
780
      if (preg_match('/nothing changed/', $ex->getStdOut())) {
 
781
        // NOTE: Mercurial considers it an error to make a no-op amend. Although
 
782
        // we generally defer to the underlying VCS to dictate behavior, this
 
783
        // one seems a little goofy, and we use amend as part of various
 
784
        // workflows under the assumption that no-op amends are fine. If this
 
785
        // amend failed because it's a no-op, just continue.
 
786
      } else {
 
787
        throw $ex;
 
788
      }
 
789
    }
 
790
 
 
791
    $this->reloadWorkingCopy();
 
792
  }
 
793
 
 
794
  public function getCommitSummary($commit) {
 
795
    if ($commit == 'null') {
 
796
      return '(The Empty Void)';
 
797
    }
 
798
 
 
799
    list($summary) = $this->execxLocal(
 
800
      'log --template {desc} --limit 1 --rev %s',
 
801
      $commit);
 
802
 
 
803
    $summary = head(explode("\n", $summary));
 
804
 
 
805
    return trim($summary);
 
806
  }
 
807
 
 
808
  public function backoutCommit($commit_hash) {
 
809
    $this->execxLocal(
 
810
      'backout -r %s', $commit_hash);
 
811
    $this->reloadWorkingCopy();
 
812
    if (!$this->getUncommittedStatus()) {
 
813
      throw new ArcanistUsageException(
 
814
        "{$commit_hash} has already been reverted.");
 
815
    }
 
816
  }
 
817
 
 
818
  public function getBackoutMessage($commit_hash) {
 
819
    return 'Backed out changeset '.$commit_hash.'.';
 
820
  }
 
821
 
 
822
  public function resolveBaseCommitRule($rule, $source) {
 
823
    list($type, $name) = explode(':', $rule, 2);
 
824
 
 
825
    // NOTE: This function MUST return node hashes or symbolic commits (like
 
826
    // branch names or the word "tip"), not revsets. This includes ".^" and
 
827
    // similar, which a revset, not a symbolic commit identifier. If you return
 
828
    // a revset it will be escaped later and looked up literally.
 
829
 
 
830
    switch ($type) {
 
831
      case 'hg':
 
832
        $matches = null;
 
833
        if (preg_match('/^gca\((.+)\)$/', $name, $matches)) {
 
834
          list($err, $merge_base) = $this->execManualLocal(
 
835
            'log --template={node} --rev %s',
 
836
            sprintf('ancestor(., %s)', $matches[1]));
 
837
          if (!$err) {
 
838
            $this->setBaseCommitExplanation(
 
839
              "it is the greatest common ancestor of '{$matches[1]}' and ., as".
 
840
              " specified by '{$rule}' in your {$source} 'base' ".
 
841
              "configuration.");
 
842
            return trim($merge_base);
 
843
          }
 
844
        } else {
 
845
          list($err, $commit) = $this->execManualLocal(
 
846
            'log --template {node} --rev %s',
 
847
            hgsprintf('%s', $name));
 
848
 
 
849
          if ($err) {
 
850
            list($err, $commit) = $this->execManualLocal(
 
851
              'log --template {node} --rev %s',
 
852
              $name);
 
853
          }
 
854
          if (!$err) {
 
855
            $this->setBaseCommitExplanation(
 
856
              "it is specified by '{$rule}' in your {$source} 'base' ".
 
857
              "configuration.");
 
858
            return trim($commit);
 
859
          }
 
860
        }
 
861
        break;
 
862
      case 'arc':
 
863
        switch ($name) {
 
864
          case 'empty':
 
865
            $this->setBaseCommitExplanation(
 
866
              "you specified '{$rule}' in your {$source} 'base' ".
 
867
              "configuration.");
 
868
            return 'null';
 
869
          case 'outgoing':
 
870
            list($err, $outgoing_base) = $this->execManualLocal(
 
871
              'log --template={node} --rev %s',
 
872
              'limit(reverse(ancestors(.) - outgoing()), 1)');
 
873
            if (!$err) {
 
874
              $this->setBaseCommitExplanation(
 
875
                "it is the first ancestor of the working copy that is not ".
 
876
                "outgoing, and it matched the rule {$rule} in your {$source} ".
 
877
                "'base' configuration.");
 
878
              return trim($outgoing_base);
 
879
            }
 
880
          case 'amended':
 
881
            $text = $this->getCommitMessage('.');
 
882
            $message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
 
883
              $text);
 
884
            if ($message->getRevisionID()) {
 
885
              $this->setBaseCommitExplanation(
 
886
                "'.' has been amended with 'Differential Revision:', ".
 
887
                "as specified by '{$rule}' in your {$source} 'base' ".
 
888
                "configuration.");
 
889
              // NOTE: This should be safe because Mercurial doesn't support
 
890
              // amend until 2.2.
 
891
              return $this->getCanonicalRevisionName('.^');
 
892
            }
 
893
            break;
 
894
          case 'bookmark':
 
895
            $revset =
 
896
              'limit('.
 
897
              '  sort('.
 
898
              '    (ancestors(.) and bookmark() - .) or'.
 
899
              '    (ancestors(.) - outgoing()), '.
 
900
              '  -rev),'.
 
901
              '1)';
 
902
            list($err, $bookmark_base) = $this->execManualLocal(
 
903
              'log --template={node} --rev %s',
 
904
              $revset);
 
905
            if (!$err) {
 
906
              $this->setBaseCommitExplanation(
 
907
                "it is the first ancestor of . that either has a bookmark, or ".
 
908
                "is already in the remote and it matched the rule {$rule} in ".
 
909
                "your {$source} 'base' configuration");
 
910
              return trim($bookmark_base);
 
911
            }
 
912
            break;
 
913
          case 'this':
 
914
            $this->setBaseCommitExplanation(
 
915
              "you specified '{$rule}' in your {$source} 'base' ".
 
916
              "configuration.");
 
917
            return $this->getCanonicalRevisionName('.^');
 
918
          default:
 
919
            if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) {
 
920
              list($results) = $this->execxLocal(
 
921
                'log --template %s --rev %s',
 
922
                "{node}\1{desc}\2",
 
923
                sprintf('ancestor(.,%s)::.^', $matches[1]));
 
924
              $results = array_reverse(explode("\2", trim($results)));
 
925
 
 
926
              foreach ($results as $result) {
 
927
                if (empty($result)) {
 
928
                  continue;
 
929
                }
 
930
 
 
931
                list($node, $desc) = explode("\1", $result, 2);
 
932
 
 
933
                $message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
 
934
                  $desc);
 
935
                if ($message->getRevisionID()) {
 
936
                  $this->setBaseCommitExplanation(
 
937
                    "it is the first ancestor of . that has a diff ".
 
938
                    "and is the gca or a descendant of the gca with ".
 
939
                    "'{$matches[1]}', specified by '{$rule}' in your ".
 
940
                    "{$source} 'base' configuration.");
 
941
                  return $node;
 
942
                }
 
943
              }
 
944
            }
 
945
            break;
 
946
          }
 
947
        break;
 
948
      default:
 
949
        return null;
 
950
    }
 
951
 
 
952
    return null;
 
953
 
 
954
  }
 
955
 
 
956
  public function isHgSubversionRepo() {
 
957
    return file_exists($this->getPath('.hg/svn/rev_map'));
 
958
  }
 
959
 
 
960
  public function getSubversionInfo() {
 
961
    $info = array();
 
962
    $base_path = null;
 
963
    $revision = null;
 
964
    list($err, $raw_info) = $this->execManualLocal('svn info');
 
965
    if (!$err) {
 
966
      foreach (explode("\n", trim($raw_info)) as $line) {
 
967
        list($key, $value) = explode(': ', $line, 2);
 
968
        switch ($key) {
 
969
          case 'URL':
 
970
            $info['base_path'] = $value;
 
971
            $base_path = $value;
 
972
            break;
 
973
          case 'Repository UUID':
 
974
            $info['uuid'] = $value;
 
975
            break;
 
976
          case 'Revision':
 
977
            $revision = $value;
 
978
            break;
 
979
          default:
 
980
            break;
 
981
        }
 
982
      }
 
983
      if ($base_path && $revision) {
 
984
        $info['base_revision'] = $base_path.'@'.$revision;
 
985
      }
 
986
    }
 
987
    return $info;
 
988
  }
 
989
 
 
990
  public function getActiveBookmark() {
 
991
    $bookmarks = $this->getBookmarks();
 
992
    foreach ($bookmarks as $bookmark) {
 
993
      if ($bookmark['is_active']) {
 
994
        return $bookmark['name'];
 
995
      }
 
996
    }
 
997
 
 
998
    return null;
 
999
  }
 
1000
 
 
1001
  public function isBookmark($name) {
 
1002
    $bookmarks = $this->getBookmarks();
 
1003
    foreach ($bookmarks as $bookmark) {
 
1004
      if ($bookmark['name'] === $name) {
 
1005
        return true;
 
1006
      }
 
1007
    }
 
1008
 
 
1009
    return false;
 
1010
  }
 
1011
 
 
1012
  public function isBranch($name) {
 
1013
    $branches = $this->getBranches();
 
1014
    foreach ($branches as $branch) {
 
1015
      if ($branch['name'] === $name) {
 
1016
        return true;
 
1017
      }
 
1018
    }
 
1019
 
 
1020
    return false;
 
1021
  }
 
1022
 
 
1023
  public function getBranches() {
 
1024
    list($stdout) = $this->execxLocal('--debug branches');
 
1025
    $lines = ArcanistMercurialParser::parseMercurialBranches($stdout);
 
1026
 
 
1027
    $branches = array();
 
1028
    foreach ($lines as $name => $spec) {
 
1029
      $branches[] = array(
 
1030
        'name' => $name,
 
1031
        'revision' => $spec['rev'],
 
1032
      );
 
1033
    }
 
1034
 
 
1035
    return $branches;
 
1036
  }
 
1037
 
 
1038
  public function getBookmarks() {
 
1039
    $bookmarks = array();
 
1040
 
 
1041
    list($raw_output) = $this->execxLocal('bookmarks');
 
1042
    $raw_output = trim($raw_output);
 
1043
    if ($raw_output !== 'no bookmarks set') {
 
1044
      foreach (explode("\n", $raw_output) as $line) {
 
1045
        // example line:  * mybook               2:6b274d49be97
 
1046
        list($name, $revision) = $this->splitBranchOrBookmarkLine($line);
 
1047
 
 
1048
        $is_active = false;
 
1049
        if ('*' === $name[0]) {
 
1050
          $is_active = true;
 
1051
          $name = substr($name, 2);
 
1052
        }
 
1053
 
 
1054
        $bookmarks[] = array(
 
1055
          'is_active' => $is_active,
 
1056
          'name' => $name,
 
1057
          'revision' => $revision,
 
1058
        );
 
1059
      }
 
1060
    }
 
1061
 
 
1062
    return $bookmarks;
 
1063
  }
 
1064
 
 
1065
  private function splitBranchOrBookmarkLine($line) {
 
1066
    // branches and bookmarks are printed in the format:
 
1067
    // default                 0:a5ead76cdf85 (inactive)
 
1068
    // * mybook               2:6b274d49be97
 
1069
    // this code divides the name half from the revision half
 
1070
    // it does not parse the * and (inactive) bits
 
1071
    $colon_index = strrpos($line, ':');
 
1072
    $before_colon = substr($line, 0, $colon_index);
 
1073
    $start_rev_index = strrpos($before_colon, ' ');
 
1074
    $name = substr($line, 0, $start_rev_index);
 
1075
    $rev = substr($line, $start_rev_index);
 
1076
 
 
1077
    return array(trim($name), trim($rev));
 
1078
  }
 
1079
 
 
1080
  public function getRemoteURI() {
 
1081
    list($stdout) = $this->execxLocal('paths default');
 
1082
 
 
1083
    $stdout = trim($stdout);
 
1084
    if (strlen($stdout)) {
 
1085
      return $stdout;
 
1086
    }
 
1087
 
 
1088
    return null;
 
1089
  }
 
1090
 
 
1091
}