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

« back to all changes in this revision

Viewing changes to src/workflow/ArcanistWorkflow.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
 * Implements a runnable command, like "arc diff" or "arc help".
 
5
 *
 
6
 * = Managing Conduit =
 
7
 *
 
8
 * Workflows have the builtin ability to open a Conduit connection to a
 
9
 * Phabricator installation, so methods can be invoked over the API. Workflows
 
10
 * may either not need this (e.g., "help"), or may need a Conduit but not
 
11
 * authentication (e.g., calling only public APIs), or may need a Conduit and
 
12
 * authentication (e.g., "arc diff").
 
13
 *
 
14
 * To specify that you need an //unauthenticated// conduit, override
 
15
 * @{method:requiresConduit} to return ##true##. To specify that you need an
 
16
 * //authenticated// conduit, override @{method:requiresAuthentication} to
 
17
 * return ##true##. You can also manually invoke @{method:establishConduit}
 
18
 * and/or @{method:authenticateConduit} later in a workflow to upgrade it.
 
19
 * Once a conduit is open, you can access the client by calling
 
20
 * @{method:getConduit}, which allows you to invoke methods. You can get
 
21
 * verified information about the user identity by calling @{method:getUserPHID}
 
22
 * or @{method:getUserName} after authentication occurs.
 
23
 *
 
24
 * = Scratch Files =
 
25
 *
 
26
 * Arcanist workflows can read and write 'scratch files', which are temporary
 
27
 * files stored in the project that persist across commands. They can be useful
 
28
 * if you want to save some state, or keep a copy of a long message the user
 
29
 * entered if something goes wrong.
 
30
 *
 
31
 *
 
32
 * @task  conduit   Conduit
 
33
 * @task  scratch   Scratch Files
 
34
 * @task  phabrep   Phabricator Repositories
 
35
 *
 
36
 * @stable
 
37
 */
 
38
abstract class ArcanistWorkflow extends Phobject {
 
39
 
 
40
  const COMMIT_DISABLE = 0;
 
41
  const COMMIT_ALLOW = 1;
 
42
  const COMMIT_ENABLE = 2;
 
43
 
 
44
  const AUTO_COMMIT_TITLE = 'Automatic commit by arc';
 
45
 
 
46
  private $commitMode = self::COMMIT_DISABLE;
 
47
 
 
48
  private $conduit;
 
49
  private $conduitURI;
 
50
  private $conduitCredentials;
 
51
  private $conduitAuthenticated;
 
52
  private $forcedConduitVersion;
 
53
  private $conduitTimeout;
 
54
 
 
55
  private $userPHID;
 
56
  private $userName;
 
57
  private $repositoryAPI;
 
58
  private $configurationManager;
 
59
  private $workingCopy;
 
60
  private $arguments;
 
61
  private $passedArguments;
 
62
  private $command;
 
63
 
 
64
  private $stashed;
 
65
  private $shouldAmend;
 
66
 
 
67
  private $projectInfo;
 
68
  private $repositoryInfo;
 
69
  private $repositoryReasons;
 
70
 
 
71
  private $arcanistConfiguration;
 
72
  private $parentWorkflow;
 
73
  private $workingDirectory;
 
74
  private $repositoryVersion;
 
75
 
 
76
  private $changeCache = array();
 
77
 
 
78
 
 
79
  public function __construct() {}
 
80
 
 
81
 
 
82
  abstract public function run();
 
83
 
 
84
  /**
 
85
   * Finalizes any cleanup operations that need to occur regardless of
 
86
   * whether the command succeeded or failed.
 
87
   */
 
88
  public function finalize() {
 
89
    // TODO: Remove this once ArcanistBaseWorkflow is gone.
 
90
    if ($this instanceof ArcanistBaseWorkflow) {
 
91
      phutil_deprecated(
 
92
        'ArcanistBaseWorkflow',
 
93
        'You should extend from `ArcanistWorkflow` instead.');
 
94
    }
 
95
 
 
96
    $this->finalizeWorkingCopy();
 
97
  }
 
98
 
 
99
  /**
 
100
   * Return the command used to invoke this workflow from the command like,
 
101
   * e.g. "help" for @{class:ArcanistHelpWorkflow}.
 
102
   *
 
103
   * @return string   The command a user types to invoke this workflow.
 
104
   */
 
105
  abstract public function getWorkflowName();
 
106
 
 
107
  /**
 
108
   * Return console formatted string with all command synopses.
 
109
   *
 
110
   * @return string  6-space indented list of available command synopses.
 
111
   */
 
112
  abstract public function getCommandSynopses();
 
113
 
 
114
  /**
 
115
   * Return console formatted string with command help printed in `arc help`.
 
116
   *
 
117
   * @return string  10-space indented help to use the command.
 
118
   */
 
119
  abstract public function getCommandHelp();
 
120
 
 
121
 
 
122
/* -(  Conduit  )------------------------------------------------------------ */
 
123
 
 
124
 
 
125
  /**
 
126
   * Set the URI which the workflow will open a conduit connection to when
 
127
   * @{method:establishConduit} is called. Arcanist makes an effort to set
 
128
   * this by default for all workflows (by reading ##.arcconfig## and/or the
 
129
   * value of ##--conduit-uri##) even if they don't need Conduit, so a workflow
 
130
   * can generally upgrade into a conduit workflow later by just calling
 
131
   * @{method:establishConduit}.
 
132
   *
 
133
   * You generally should not need to call this method unless you are
 
134
   * specifically overriding the default URI. It is normally sufficient to
 
135
   * just invoke @{method:establishConduit}.
 
136
   *
 
137
   * NOTE: You can not call this after a conduit has been established.
 
138
   *
 
139
   * @param string  The URI to open a conduit to when @{method:establishConduit}
 
140
   *                is called.
 
141
   * @return this
 
142
   * @task conduit
 
143
   */
 
144
  final public function setConduitURI($conduit_uri) {
 
145
    if ($this->conduit) {
 
146
      throw new Exception(
 
147
        'You can not change the Conduit URI after a conduit is already open.');
 
148
    }
 
149
    $this->conduitURI = $conduit_uri;
 
150
    return $this;
 
151
  }
 
152
 
 
153
  /**
 
154
   * Returns the URI the conduit connection within the workflow uses.
 
155
   *
 
156
   * @return string
 
157
   * @task conduit
 
158
   */
 
159
  final public function getConduitURI() {
 
160
    return $this->conduitURI;
 
161
  }
 
162
 
 
163
  /**
 
164
   * Open a conduit channel to the server which was previously configured by
 
165
   * calling @{method:setConduitURI}. Arcanist will do this automatically if
 
166
   * the workflow returns ##true## from @{method:requiresConduit}, or you can
 
167
   * later upgrade a workflow and build a conduit by invoking it manually.
 
168
   *
 
169
   * You must establish a conduit before you can make conduit calls.
 
170
   *
 
171
   * NOTE: You must call @{method:setConduitURI} before you can call this
 
172
   * method.
 
173
   *
 
174
   * @return this
 
175
   * @task conduit
 
176
   */
 
177
  final public function establishConduit() {
 
178
    if ($this->conduit) {
 
179
      return $this;
 
180
    }
 
181
 
 
182
    if (!$this->conduitURI) {
 
183
      throw new Exception(
 
184
        'You must specify a Conduit URI with setConduitURI() before you can '.
 
185
        'establish a conduit.');
 
186
    }
 
187
 
 
188
    $this->conduit = new ConduitClient($this->conduitURI);
 
189
 
 
190
    if ($this->conduitTimeout) {
 
191
      $this->conduit->setTimeout($this->conduitTimeout);
 
192
    }
 
193
 
 
194
    $user = $this->getConfigFromAnySource('http.basicauth.user');
 
195
    $pass = $this->getConfigFromAnySource('http.basicauth.pass');
 
196
    if ($user !== null && $pass !== null) {
 
197
      $this->conduit->setBasicAuthCredentials($user, $pass);
 
198
    }
 
199
 
 
200
    return $this;
 
201
  }
 
202
 
 
203
  final public function getConfigFromAnySource($key) {
 
204
    return $this->configurationManager->getConfigFromAnySource($key);
 
205
  }
 
206
 
 
207
 
 
208
  /**
 
209
   * Set credentials which will be used to authenticate against Conduit. These
 
210
   * credentials can then be used to establish an authenticated connection to
 
211
   * conduit by calling @{method:authenticateConduit}. Arcanist sets some
 
212
   * defaults for all workflows regardless of whether or not they return true
 
213
   * from @{method:requireAuthentication}, based on the ##~/.arcrc## and
 
214
   * ##.arcconf## files if they are present. Thus, you can generally upgrade a
 
215
   * workflow which does not require authentication into an authenticated
 
216
   * workflow by later invoking @{method:requireAuthentication}. You should not
 
217
   * normally need to call this method unless you are specifically overriding
 
218
   * the defaults.
 
219
   *
 
220
   * NOTE: You can not call this method after calling
 
221
   * @{method:authenticateConduit}.
 
222
   *
 
223
   * @param dict  A credential dictionary, see @{method:authenticateConduit}.
 
224
   * @return this
 
225
   * @task conduit
 
226
   */
 
227
  final public function setConduitCredentials(array $credentials) {
 
228
    if ($this->isConduitAuthenticated()) {
 
229
      throw new Exception(
 
230
        'You may not set new credentials after authenticating conduit.');
 
231
    }
 
232
 
 
233
    $this->conduitCredentials = $credentials;
 
234
    return $this;
 
235
  }
 
236
 
 
237
 
 
238
  /**
 
239
   * Force arc to identify with a specific Conduit version during the
 
240
   * protocol handshake. This is primarily useful for development (especially
 
241
   * for sending diffs which bump the client Conduit version), since the client
 
242
   * still actually speaks the builtin version of the protocol.
 
243
   *
 
244
   * Controlled by the --conduit-version flag.
 
245
   *
 
246
   * @param int Version the client should pretend to be.
 
247
   * @return this
 
248
   * @task conduit
 
249
   */
 
250
  final public function forceConduitVersion($version) {
 
251
    $this->forcedConduitVersion = $version;
 
252
    return $this;
 
253
  }
 
254
 
 
255
 
 
256
  /**
 
257
   * Get the protocol version the client should identify with.
 
258
   *
 
259
   * @return int Version the client should claim to be.
 
260
   * @task conduit
 
261
   */
 
262
  final public function getConduitVersion() {
 
263
    return nonempty($this->forcedConduitVersion, 6);
 
264
  }
 
265
 
 
266
 
 
267
  /**
 
268
   * Override the default timeout for Conduit.
 
269
   *
 
270
   * Controlled by the --conduit-timeout flag.
 
271
   *
 
272
   * @param float Timeout, in seconds.
 
273
   * @return this
 
274
   * @task conduit
 
275
   */
 
276
  final public function setConduitTimeout($timeout) {
 
277
    $this->conduitTimeout = $timeout;
 
278
    if ($this->conduit) {
 
279
      $this->conduit->setConduitTimeout($timeout);
 
280
    }
 
281
    return $this;
 
282
  }
 
283
 
 
284
 
 
285
  /**
 
286
   * Open and authenticate a conduit connection to a Phabricator server using
 
287
   * provided credentials. Normally, Arcanist does this for you automatically
 
288
   * when you return true from @{method:requiresAuthentication}, but you can
 
289
   * also upgrade an existing workflow to one with an authenticated conduit
 
290
   * by invoking this method manually.
 
291
   *
 
292
   * You must authenticate the conduit before you can make authenticated conduit
 
293
   * calls (almost all calls require authentication).
 
294
   *
 
295
   * This method uses credentials provided via @{method:setConduitCredentials}
 
296
   * to authenticate to the server:
 
297
   *
 
298
   *    - ##user## (required) The username to authenticate with.
 
299
   *    - ##certificate## (required) The Conduit certificate to use.
 
300
   *    - ##description## (optional) Description of the invoking command.
 
301
   *
 
302
   * Successful authentication allows you to call @{method:getUserPHID} and
 
303
   * @{method:getUserName}, as well as use the client you access with
 
304
   * @{method:getConduit} to make authenticated calls.
 
305
   *
 
306
   * NOTE: You must call @{method:setConduitURI} and
 
307
   * @{method:setConduitCredentials} before you invoke this method.
 
308
   *
 
309
   * @return this
 
310
   * @task conduit
 
311
   */
 
312
  final public function authenticateConduit() {
 
313
    if ($this->isConduitAuthenticated()) {
 
314
      return $this;
 
315
    }
 
316
 
 
317
    $this->establishConduit();
 
318
    $credentials = $this->conduitCredentials;
 
319
 
 
320
    try {
 
321
      if (!$credentials) {
 
322
        throw new Exception(
 
323
          'Set conduit credentials with setConduitCredentials() before '.
 
324
          'authenticating conduit!');
 
325
      }
 
326
 
 
327
      if (empty($credentials['user'])) {
 
328
        throw new ConduitClientException(
 
329
          'ERR-INVALID-USER',
 
330
          'Empty user in credentials.');
 
331
      }
 
332
      if (empty($credentials['certificate'])) {
 
333
        throw new ConduitClientException(
 
334
          'ERR-NO-CERTIFICATE',
 
335
          'Empty certificate in credentials.');
 
336
      }
 
337
 
 
338
      $description = idx($credentials, 'description', '');
 
339
      $user        = $credentials['user'];
 
340
      $certificate = $credentials['certificate'];
 
341
 
 
342
      $connection = $this->getConduit()->callMethodSynchronous(
 
343
        'conduit.connect',
 
344
        array(
 
345
          'client'              => 'arc',
 
346
          'clientVersion'       => $this->getConduitVersion(),
 
347
          'clientDescription'   => php_uname('n').':'.$description,
 
348
          'user'                => $user,
 
349
          'certificate'         => $certificate,
 
350
          'host'                => $this->conduitURI,
 
351
        ));
 
352
    } catch (ConduitClientException $ex) {
 
353
      if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' ||
 
354
          $ex->getErrorCode() == 'ERR-INVALID-USER') {
 
355
        $conduit_uri = $this->conduitURI;
 
356
        $message =
 
357
          "\n".
 
358
          phutil_console_format(
 
359
            'YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR').
 
360
          "\n\n".
 
361
          phutil_console_format(
 
362
            '    To do this, run: **arc install-certificate**').
 
363
          "\n\n".
 
364
          "The server '{$conduit_uri}' rejected your request:".
 
365
          "\n".
 
366
          $ex->getMessage();
 
367
        throw new ArcanistUsageException($message);
 
368
      } else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') {
 
369
 
 
370
        // Cleverly disguise this as being AWESOME!!!
 
371
 
 
372
        echo phutil_console_format("**New Version Available!**\n\n");
 
373
        echo phutil_console_wrap($ex->getMessage());
 
374
        echo "\n\n";
 
375
        echo "In most cases, arc can be upgraded automatically.\n";
 
376
 
 
377
        $ok = phutil_console_confirm(
 
378
          'Upgrade arc now?',
 
379
          $default_no = false);
 
380
        if (!$ok) {
 
381
          throw $ex;
 
382
        }
 
383
 
 
384
        $root = dirname(phutil_get_library_root('arcanist'));
 
385
 
 
386
        chdir($root);
 
387
        $err = phutil_passthru('%s upgrade', $root.'/bin/arc');
 
388
        if (!$err) {
 
389
          echo "\nTry running your arc command again.\n";
 
390
        }
 
391
        exit(1);
 
392
      } else {
 
393
        throw $ex;
 
394
      }
 
395
    }
 
396
 
 
397
    $this->userName = $user;
 
398
    $this->userPHID = $connection['userPHID'];
 
399
 
 
400
    $this->conduitAuthenticated = true;
 
401
 
 
402
    return $this;
 
403
  }
 
404
 
 
405
  /**
 
406
   * @return bool True if conduit is authenticated, false otherwise.
 
407
   * @task conduit
 
408
   */
 
409
  final protected function isConduitAuthenticated() {
 
410
    return (bool)$this->conduitAuthenticated;
 
411
  }
 
412
 
 
413
 
 
414
  /**
 
415
   * Override this to return true if your workflow requires a conduit channel.
 
416
   * Arc will build the channel for you before your workflow executes. This
 
417
   * implies that you only need an unauthenticated channel; if you need
 
418
   * authentication, override @{method:requiresAuthentication}.
 
419
   *
 
420
   * @return bool True if arc should build a conduit channel before running
 
421
   *              the workflow.
 
422
   * @task conduit
 
423
   */
 
424
  public function requiresConduit() {
 
425
    return false;
 
426
  }
 
427
 
 
428
 
 
429
  /**
 
430
   * Override this to return true if your workflow requires an authenticated
 
431
   * conduit channel. This implies that it requires a conduit. Arc will build
 
432
   * and authenticate the channel for you before the workflow executes.
 
433
   *
 
434
   * @return bool True if arc should build an authenticated conduit channel
 
435
   *              before running the workflow.
 
436
   * @task conduit
 
437
   */
 
438
  public function requiresAuthentication() {
 
439
    return false;
 
440
  }
 
441
 
 
442
 
 
443
  /**
 
444
   * Returns the PHID for the user once they've authenticated via Conduit.
 
445
   *
 
446
   * @return phid Authenticated user PHID.
 
447
   * @task conduit
 
448
   */
 
449
  final public function getUserPHID() {
 
450
    if (!$this->userPHID) {
 
451
      $workflow = get_class($this);
 
452
      throw new Exception(
 
453
        "This workflow ('{$workflow}') requires authentication, override ".
 
454
        "requiresAuthentication() to return true.");
 
455
    }
 
456
    return $this->userPHID;
 
457
  }
 
458
 
 
459
  /**
 
460
   * Return the username for the user once they've authenticated via Conduit.
 
461
   *
 
462
   * @return string Authenticated username.
 
463
   * @task conduit
 
464
   */
 
465
  final public function getUserName() {
 
466
    return $this->userName;
 
467
  }
 
468
 
 
469
 
 
470
  /**
 
471
   * Get the established @{class@libphutil:ConduitClient} in order to make
 
472
   * Conduit method calls. Before the client is available it must be connected,
 
473
   * either implicitly by making @{method:requireConduit} or
 
474
   * @{method:requireAuthentication} return true, or explicitly by calling
 
475
   * @{method:establishConduit} or @{method:authenticateConduit}.
 
476
   *
 
477
   * @return @{class@libphutil:ConduitClient} Live conduit client.
 
478
   * @task conduit
 
479
   */
 
480
  final public function getConduit() {
 
481
    if (!$this->conduit) {
 
482
      $workflow = get_class($this);
 
483
      throw new Exception(
 
484
        "This workflow ('{$workflow}') requires a Conduit, override ".
 
485
        "requiresConduit() to return true.");
 
486
    }
 
487
    return $this->conduit;
 
488
  }
 
489
 
 
490
 
 
491
  final public function setArcanistConfiguration(
 
492
    ArcanistConfiguration $arcanist_configuration) {
 
493
 
 
494
    $this->arcanistConfiguration = $arcanist_configuration;
 
495
    return $this;
 
496
  }
 
497
 
 
498
  final public function getArcanistConfiguration() {
 
499
    return $this->arcanistConfiguration;
 
500
  }
 
501
 
 
502
  final public function setConfigurationManager(
 
503
    ArcanistConfigurationManager $arcanist_configuration_manager) {
 
504
 
 
505
    $this->configurationManager = $arcanist_configuration_manager;
 
506
    return $this;
 
507
  }
 
508
 
 
509
  final public function getConfigurationManager() {
 
510
    return $this->configurationManager;
 
511
  }
 
512
 
 
513
  public function requiresWorkingCopy() {
 
514
    return false;
 
515
  }
 
516
 
 
517
  public function desiresWorkingCopy() {
 
518
    return false;
 
519
  }
 
520
 
 
521
  public function requiresRepositoryAPI() {
 
522
    return false;
 
523
  }
 
524
 
 
525
  public function desiresRepositoryAPI() {
 
526
    return false;
 
527
  }
 
528
 
 
529
  final public function setCommand($command) {
 
530
    $this->command = $command;
 
531
    return $this;
 
532
  }
 
533
 
 
534
  final public function getCommand() {
 
535
    return $this->command;
 
536
  }
 
537
 
 
538
  public function getArguments() {
 
539
    return array();
 
540
  }
 
541
 
 
542
  final public function setWorkingDirectory($working_directory) {
 
543
    $this->workingDirectory = $working_directory;
 
544
    return $this;
 
545
  }
 
546
 
 
547
  final public function getWorkingDirectory() {
 
548
    return $this->workingDirectory;
 
549
  }
 
550
 
 
551
  final private function setParentWorkflow($parent_workflow) {
 
552
    $this->parentWorkflow = $parent_workflow;
 
553
    return $this;
 
554
  }
 
555
 
 
556
  final protected function getParentWorkflow() {
 
557
    return $this->parentWorkflow;
 
558
  }
 
559
 
 
560
  final public function buildChildWorkflow($command, array $argv) {
 
561
    $arc_config = $this->getArcanistConfiguration();
 
562
    $workflow = $arc_config->buildWorkflow($command);
 
563
    $workflow->setParentWorkflow($this);
 
564
    $workflow->setCommand($command);
 
565
    $workflow->setConfigurationManager($this->getConfigurationManager());
 
566
 
 
567
    if ($this->repositoryAPI) {
 
568
      $workflow->setRepositoryAPI($this->repositoryAPI);
 
569
    }
 
570
 
 
571
    if ($this->userPHID) {
 
572
      $workflow->userPHID = $this->getUserPHID();
 
573
      $workflow->userName = $this->getUserName();
 
574
    }
 
575
 
 
576
    if ($this->conduit) {
 
577
      $workflow->conduit = $this->conduit;
 
578
      $workflow->setConduitCredentials($this->conduitCredentials);
 
579
      $workflow->conduitAuthenticated = $this->conduitAuthenticated;
 
580
    }
 
581
 
 
582
    if ($this->workingCopy) {
 
583
      $workflow->setWorkingCopy($this->workingCopy);
 
584
    }
 
585
 
 
586
    $workflow->setArcanistConfiguration($arc_config);
 
587
 
 
588
    $workflow->parseArguments(array_values($argv));
 
589
 
 
590
    return $workflow;
 
591
  }
 
592
 
 
593
  final public function getArgument($key, $default = null) {
 
594
    return idx($this->arguments, $key, $default);
 
595
  }
 
596
 
 
597
  final public function getPassedArguments() {
 
598
    return $this->passedArguments;
 
599
  }
 
600
 
 
601
  final public function getCompleteArgumentSpecification() {
 
602
    $spec = $this->getArguments();
 
603
    $arc_config = $this->getArcanistConfiguration();
 
604
    $command = $this->getCommand();
 
605
    $spec += $arc_config->getCustomArgumentsForCommand($command);
 
606
 
 
607
    return $spec;
 
608
  }
 
609
 
 
610
  final public function parseArguments(array $args) {
 
611
    $this->passedArguments = $args;
 
612
 
 
613
    $spec = $this->getCompleteArgumentSpecification();
 
614
 
 
615
    $dict = array();
 
616
 
 
617
    $more_key = null;
 
618
    if (!empty($spec['*'])) {
 
619
      $more_key = $spec['*'];
 
620
      unset($spec['*']);
 
621
      $dict[$more_key] = array();
 
622
    }
 
623
 
 
624
    $short_to_long_map = array();
 
625
    foreach ($spec as $long => $options) {
 
626
      if (!empty($options['short'])) {
 
627
        $short_to_long_map[$options['short']] = $long;
 
628
      }
 
629
    }
 
630
 
 
631
    foreach ($spec as $long => $options) {
 
632
      if (!empty($options['repeat'])) {
 
633
        $dict[$long] = array();
 
634
      }
 
635
    }
 
636
 
 
637
    $more = array();
 
638
    for ($ii = 0; $ii < count($args); $ii++) {
 
639
      $arg = $args[$ii];
 
640
      $arg_name = null;
 
641
      $arg_key = null;
 
642
      if ($arg == '--') {
 
643
        $more = array_merge(
 
644
          $more,
 
645
          array_slice($args, $ii + 1));
 
646
        break;
 
647
      } else if (!strncmp($arg, '--', 2)) {
 
648
        $arg_key = substr($arg, 2);
 
649
        if (!array_key_exists($arg_key, $spec)) {
 
650
          $corrected = ArcanistConfiguration::correctArgumentSpelling(
 
651
            $arg_key,
 
652
            array_keys($spec));
 
653
          if (count($corrected) == 1) {
 
654
            PhutilConsole::getConsole()->writeErr(
 
655
              pht(
 
656
                "(Assuming '%s' is the British spelling of '%s'.)",
 
657
                '--'.$arg_key,
 
658
                '--'.head($corrected))."\n");
 
659
            $arg_key = head($corrected);
 
660
          } else {
 
661
            throw new ArcanistUsageException(pht(
 
662
              "Unknown argument '%s'. Try 'arc help'.",
 
663
              $arg_key));
 
664
          }
 
665
        }
 
666
      } else if (!strncmp($arg, '-', 1)) {
 
667
        $arg_key = substr($arg, 1);
 
668
        if (empty($short_to_long_map[$arg_key])) {
 
669
          throw new ArcanistUsageException(pht(
 
670
            "Unknown argument '%s'. Try 'arc help'.",
 
671
            $arg_key));
 
672
        }
 
673
        $arg_key = $short_to_long_map[$arg_key];
 
674
      } else {
 
675
        $more[] = $arg;
 
676
        continue;
 
677
      }
 
678
 
 
679
      $options = $spec[$arg_key];
 
680
      if (empty($options['param'])) {
 
681
        $dict[$arg_key] = true;
 
682
      } else {
 
683
        if ($ii == count($args) - 1) {
 
684
          throw new ArcanistUsageException(pht(
 
685
            "Option '%s' requires a parameter.",
 
686
            $arg));
 
687
        }
 
688
        if (!empty($options['repeat'])) {
 
689
          $dict[$arg_key][] = $args[$ii + 1];
 
690
        } else {
 
691
          $dict[$arg_key] = $args[$ii + 1];
 
692
        }
 
693
        $ii++;
 
694
      }
 
695
    }
 
696
 
 
697
    if ($more) {
 
698
      if ($more_key) {
 
699
        $dict[$more_key] = $more;
 
700
      } else {
 
701
        $example = reset($more);
 
702
        throw new ArcanistUsageException(pht(
 
703
          "Unrecognized argument '%s'. Try 'arc help'.",
 
704
          $example));
 
705
      }
 
706
    }
 
707
 
 
708
    foreach ($dict as $key => $value) {
 
709
      if (empty($spec[$key]['conflicts'])) {
 
710
        continue;
 
711
      }
 
712
      foreach ($spec[$key]['conflicts'] as $conflict => $more) {
 
713
        if (isset($dict[$conflict])) {
 
714
          if ($more) {
 
715
            $more = ': '.$more;
 
716
          } else {
 
717
            $more = '.';
 
718
          }
 
719
          // TODO: We'll always display these as long-form, when the user might
 
720
          // have typed them as short form.
 
721
          throw new ArcanistUsageException(
 
722
            "Arguments '--{$key}' and '--{$conflict}' are mutually exclusive".
 
723
            $more);
 
724
        }
 
725
      }
 
726
    }
 
727
 
 
728
    $this->arguments = $dict;
 
729
 
 
730
    $this->didParseArguments();
 
731
 
 
732
    return $this;
 
733
  }
 
734
 
 
735
  protected function didParseArguments() {
 
736
    // Override this to customize workflow argument behavior.
 
737
  }
 
738
 
 
739
  final public function getWorkingCopy() {
 
740
    $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity();
 
741
    if (!$working_copy) {
 
742
      $workflow = get_class($this);
 
743
      throw new Exception(
 
744
        "This workflow ('{$workflow}') requires a working copy, override ".
 
745
        "requiresWorkingCopy() to return true.");
 
746
    }
 
747
    return $working_copy;
 
748
  }
 
749
 
 
750
  final public function setWorkingCopy(
 
751
    ArcanistWorkingCopyIdentity $working_copy) {
 
752
    $this->workingCopy = $working_copy;
 
753
    return $this;
 
754
  }
 
755
 
 
756
  final public function setRepositoryAPI($api) {
 
757
    $this->repositoryAPI = $api;
 
758
    return $this;
 
759
  }
 
760
 
 
761
  final public function hasRepositoryAPI() {
 
762
    try {
 
763
      return (bool)$this->getRepositoryAPI();
 
764
    } catch (Exception $ex) {
 
765
      return false;
 
766
    }
 
767
  }
 
768
 
 
769
  final public function getRepositoryAPI() {
 
770
    if (!$this->repositoryAPI) {
 
771
      $workflow = get_class($this);
 
772
      throw new Exception(
 
773
        "This workflow ('{$workflow}') requires a Repository API, override ".
 
774
        "requiresRepositoryAPI() to return true.");
 
775
    }
 
776
    return $this->repositoryAPI;
 
777
  }
 
778
 
 
779
  final protected function shouldRequireCleanUntrackedFiles() {
 
780
    return empty($this->arguments['allow-untracked']);
 
781
  }
 
782
 
 
783
  final public function setCommitMode($mode) {
 
784
    $this->commitMode = $mode;
 
785
    return $this;
 
786
  }
 
787
 
 
788
  final public function finalizeWorkingCopy() {
 
789
    if ($this->stashed) {
 
790
      $api = $this->getRepositoryAPI();
 
791
      $api->unstashChanges();
 
792
      echo pht('Restored stashed changes to the working directory.')."\n";
 
793
    }
 
794
  }
 
795
 
 
796
  final public function requireCleanWorkingCopy() {
 
797
    $api = $this->getRepositoryAPI();
 
798
 
 
799
    $must_commit = array();
 
800
 
 
801
    $working_copy_desc = phutil_console_format(
 
802
      "  Working copy: __%s__\n\n",
 
803
      $api->getPath());
 
804
 
 
805
    $untracked = $api->getUntrackedChanges();
 
806
    if ($this->shouldRequireCleanUntrackedFiles()) {
 
807
 
 
808
      if (!empty($untracked)) {
 
809
        echo "You have untracked files in this working copy.\n\n".
 
810
             $working_copy_desc.
 
811
             "  Untracked files in working copy:\n".
 
812
             "    ".implode("\n    ", $untracked)."\n\n";
 
813
 
 
814
        if ($api instanceof ArcanistGitAPI) {
 
815
          echo phutil_console_wrap(
 
816
            "Since you don't have '.gitignore' rules for these files and have ".
 
817
            "not listed them in '.git/info/exclude', you may have forgotten ".
 
818
            "to 'git add' them to your commit.\n");
 
819
        } else if ($api instanceof ArcanistSubversionAPI) {
 
820
          echo phutil_console_wrap(
 
821
            "Since you don't have 'svn:ignore' rules for these files, you may ".
 
822
            "have forgotten to 'svn add' them.\n");
 
823
        } else if ($api instanceof ArcanistMercurialAPI) {
 
824
          echo phutil_console_wrap(
 
825
            "Since you don't have '.hgignore' rules for these files, you ".
 
826
            "may have forgotten to 'hg add' them to your commit.\n");
 
827
        }
 
828
 
 
829
        if ($this->askForAdd($untracked)) {
 
830
          $api->addToCommit($untracked);
 
831
          $must_commit += array_flip($untracked);
 
832
        } else if ($this->commitMode == self::COMMIT_DISABLE) {
 
833
          $prompt = $this->getAskForAddPrompt($untracked);
 
834
          if (phutil_console_confirm($prompt)) {
 
835
            throw new ArcanistUsageException(pht(
 
836
              "Add these files and then run 'arc %s' again.",
 
837
              $this->getWorkflowName()));
 
838
          }
 
839
        }
 
840
 
 
841
      }
 
842
    }
 
843
 
 
844
    // NOTE: this is a subversion-only concept.
 
845
    $incomplete = $api->getIncompleteChanges();
 
846
    if ($incomplete) {
 
847
      throw new ArcanistUsageException(
 
848
        "You have incompletely checked out directories in this working copy. ".
 
849
        "Fix them before proceeding.\n\n".
 
850
        $working_copy_desc.
 
851
        "  Incomplete directories in working copy:\n".
 
852
        "    ".implode("\n    ", $incomplete)."\n\n".
 
853
        "You can fix these paths by running 'svn update' on them.");
 
854
    }
 
855
 
 
856
    $conflicts = $api->getMergeConflicts();
 
857
    if ($conflicts) {
 
858
      throw new ArcanistUsageException(
 
859
        "You have merge conflicts in this working copy. Resolve merge ".
 
860
        "conflicts before proceeding.\n\n".
 
861
        $working_copy_desc.
 
862
        "  Conflicts in working copy:\n".
 
863
        "    ".implode("\n    ", $conflicts)."\n");
 
864
    }
 
865
 
 
866
    $missing = $api->getMissingChanges();
 
867
    if ($missing) {
 
868
      throw new ArcanistUsageException(
 
869
        pht(
 
870
          "You have missing files in this working copy. Revert or formally ".
 
871
          "remove them (with `svn rm`) before proceeding.\n\n".
 
872
          "%s".
 
873
          "  Missing files in working copy:\n%s\n",
 
874
          $working_copy_desc,
 
875
          "    ".implode("\n    ", $missing)));
 
876
    }
 
877
 
 
878
    $unstaged = $api->getUnstagedChanges();
 
879
    if ($unstaged) {
 
880
      echo "You have unstaged changes in this working copy.\n\n".
 
881
        $working_copy_desc.
 
882
        "  Unstaged changes in working copy:\n".
 
883
        "    ".implode("\n    ", $unstaged)."\n";
 
884
      if ($this->askForAdd($unstaged)) {
 
885
        $api->addToCommit($unstaged);
 
886
        $must_commit += array_flip($unstaged);
 
887
      } else {
 
888
        $permit_autostash = $this->getConfigFromAnySource(
 
889
          'arc.autostash',
 
890
          false);
 
891
        if ($permit_autostash && $api->canStashChanges()) {
 
892
          echo "Stashing uncommitted changes. (You can restore them with ".
 
893
               "`git stash pop`.)\n";
 
894
          $api->stashChanges();
 
895
          $this->stashed = true;
 
896
        } else {
 
897
          throw new ArcanistUsageException(
 
898
            'Stage and commit (or revert) them before proceeding.');
 
899
        }
 
900
      }
 
901
    }
 
902
 
 
903
    $uncommitted = $api->getUncommittedChanges();
 
904
    foreach ($uncommitted as $key => $path) {
 
905
      if (array_key_exists($path, $must_commit)) {
 
906
        unset($uncommitted[$key]);
 
907
      }
 
908
    }
 
909
    if ($uncommitted) {
 
910
      echo "You have uncommitted changes in this working copy.\n\n".
 
911
        $working_copy_desc.
 
912
        "  Uncommitted changes in working copy:\n".
 
913
        "    ".implode("\n    ", $uncommitted)."\n";
 
914
      if ($this->askForAdd($uncommitted)) {
 
915
        $must_commit += array_flip($uncommitted);
 
916
      } else {
 
917
        throw new ArcanistUncommittedChangesException(
 
918
          'Commit (or revert) them before proceeding.');
 
919
      }
 
920
    }
 
921
 
 
922
    if ($must_commit) {
 
923
      if ($this->getShouldAmend()) {
 
924
        $commit = head($api->getLocalCommitInformation());
 
925
        $api->amendCommit($commit['message']);
 
926
      } else if ($api->supportsLocalCommits()) {
 
927
        $commit_message = phutil_console_prompt('Enter commit message:');
 
928
        if ($commit_message == '') {
 
929
          $commit_message = self::AUTO_COMMIT_TITLE;
 
930
        }
 
931
        $api->doCommit($commit_message);
 
932
      }
 
933
    }
 
934
  }
 
935
 
 
936
  private function getShouldAmend() {
 
937
    if ($this->shouldAmend === null) {
 
938
      $this->shouldAmend = $this->calculateShouldAmend();
 
939
    }
 
940
    return $this->shouldAmend;
 
941
  }
 
942
 
 
943
  private function calculateShouldAmend() {
 
944
    $api = $this->getRepositoryAPI();
 
945
 
 
946
    if ($this->isHistoryImmutable() || !$api->supportsAmend()) {
 
947
      return false;
 
948
    }
 
949
 
 
950
    $commits = $api->getLocalCommitInformation();
 
951
    if (!$commits) {
 
952
      return false;
 
953
    }
 
954
 
 
955
    $commit = reset($commits);
 
956
    $message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
 
957
      $commit['message']);
 
958
 
 
959
    if ($message->getGitSVNBaseRevision()) {
 
960
      return false;
 
961
    }
 
962
 
 
963
    if ($api->getAuthor() != $commit['author']) {
 
964
      return false;
 
965
    }
 
966
 
 
967
    if ($message->getRevisionID() && $this->getArgument('create')) {
 
968
      return false;
 
969
    }
 
970
 
 
971
    // TODO: Check commits since tracking branch. If empty then return false.
 
972
 
 
973
    $repository = $this->loadProjectRepository();
 
974
    if ($repository) {
 
975
      $callsign = $repository['callsign'];
 
976
      $known_commits = $this->getConduit()->callMethodSynchronous(
 
977
        'diffusion.getcommits',
 
978
        array('commits' => array('r'.$callsign.$commit['commit'])));
 
979
      if (ifilter($known_commits, 'error', $negate = true)) {
 
980
        return false;
 
981
      }
 
982
    }
 
983
 
 
984
    if (!$message->getRevisionID()) {
 
985
      return true;
 
986
    }
 
987
 
 
988
    $in_working_copy = $api->loadWorkingCopyDifferentialRevisions(
 
989
      $this->getConduit(),
 
990
      array(
 
991
        'authors' => array($this->getUserPHID()),
 
992
        'status' => 'status-open',
 
993
      ));
 
994
    if ($in_working_copy) {
 
995
      return true;
 
996
    }
 
997
 
 
998
    return false;
 
999
  }
 
1000
 
 
1001
  private function askForAdd(array $files) {
 
1002
    if ($this->commitMode == self::COMMIT_DISABLE) {
 
1003
      return false;
 
1004
    }
 
1005
    if ($this->commitMode == self::COMMIT_ENABLE) {
 
1006
      return true;
 
1007
    }
 
1008
    $prompt = $this->getAskForAddPrompt($files);
 
1009
    return phutil_console_confirm($prompt);
 
1010
  }
 
1011
 
 
1012
  private function getAskForAddPrompt(array $files) {
 
1013
    if ($this->getShouldAmend()) {
 
1014
      $prompt = pht(
 
1015
        'Do you want to amend these files to the commit?',
 
1016
        count($files));
 
1017
    } else {
 
1018
      $prompt = pht(
 
1019
        'Do you want to add these files to the commit?',
 
1020
        count($files));
 
1021
    }
 
1022
    return $prompt;
 
1023
  }
 
1024
 
 
1025
  final protected function loadDiffBundleFromConduit(
 
1026
    ConduitClient $conduit,
 
1027
    $diff_id) {
 
1028
 
 
1029
    return $this->loadBundleFromConduit(
 
1030
      $conduit,
 
1031
      array(
 
1032
      'diff_id' => $diff_id,
 
1033
    ));
 
1034
  }
 
1035
 
 
1036
  final protected function loadRevisionBundleFromConduit(
 
1037
    ConduitClient $conduit,
 
1038
    $revision_id) {
 
1039
 
 
1040
    return $this->loadBundleFromConduit(
 
1041
      $conduit,
 
1042
      array(
 
1043
      'revision_id' => $revision_id,
 
1044
    ));
 
1045
  }
 
1046
 
 
1047
  final private function loadBundleFromConduit(
 
1048
    ConduitClient $conduit,
 
1049
    $params) {
 
1050
 
 
1051
    $future = $conduit->callMethod('differential.getdiff', $params);
 
1052
    $diff = $future->resolve();
 
1053
 
 
1054
    $changes = array();
 
1055
    foreach ($diff['changes'] as $changedict) {
 
1056
      $changes[] = ArcanistDiffChange::newFromDictionary($changedict);
 
1057
    }
 
1058
    $bundle = ArcanistBundle::newFromChanges($changes);
 
1059
    $bundle->setConduit($conduit);
 
1060
    // since the conduit method has changes, assume that these fields
 
1061
    // could be unset
 
1062
    $bundle->setProjectID(idx($diff, 'projectName'));
 
1063
    $bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision'));
 
1064
    $bundle->setRevisionID(idx($diff, 'revisionID'));
 
1065
    $bundle->setAuthorName(idx($diff, 'authorName'));
 
1066
    $bundle->setAuthorEmail(idx($diff, 'authorEmail'));
 
1067
    return $bundle;
 
1068
  }
 
1069
 
 
1070
  /**
 
1071
   * Return a list of lines changed by the current diff, or ##null## if the
 
1072
   * change list is meaningless (for example, because the path is a directory
 
1073
   * or binary file).
 
1074
   *
 
1075
   * @param string      Path within the repository.
 
1076
   * @param string      Change selection mode (see ArcanistDiffHunk).
 
1077
   * @return list|null  List of changed line numbers, or null to indicate that
 
1078
   *                    the path is not a line-oriented text file.
 
1079
   */
 
1080
  final protected function getChangedLines($path, $mode) {
 
1081
    $repository_api = $this->getRepositoryAPI();
 
1082
    $full_path = $repository_api->getPath($path);
 
1083
    if (is_dir($full_path)) {
 
1084
      return null;
 
1085
    }
 
1086
 
 
1087
    if (!file_exists($full_path)) {
 
1088
      return null;
 
1089
    }
 
1090
 
 
1091
    $change = $this->getChange($path);
 
1092
 
 
1093
    if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) {
 
1094
      return null;
 
1095
    }
 
1096
 
 
1097
    $lines = $change->getChangedLines($mode);
 
1098
    return array_keys($lines);
 
1099
  }
 
1100
 
 
1101
  final protected function getChange($path) {
 
1102
    $repository_api = $this->getRepositoryAPI();
 
1103
 
 
1104
    // TODO: Very gross
 
1105
    $is_git = ($repository_api instanceof ArcanistGitAPI);
 
1106
    $is_hg = ($repository_api instanceof ArcanistMercurialAPI);
 
1107
    $is_svn = ($repository_api instanceof ArcanistSubversionAPI);
 
1108
 
 
1109
    if ($is_svn) {
 
1110
      // NOTE: In SVN, we don't currently support a "get all local changes"
 
1111
      // operation, so special case it.
 
1112
      if (empty($this->changeCache[$path])) {
 
1113
        $diff = $repository_api->getRawDiffText($path);
 
1114
        $parser = $this->newDiffParser();
 
1115
        $changes = $parser->parseDiff($diff);
 
1116
        if (count($changes) != 1) {
 
1117
          throw new Exception('Expected exactly one change.');
 
1118
        }
 
1119
        $this->changeCache[$path] = reset($changes);
 
1120
      }
 
1121
    } else if ($is_git || $is_hg) {
 
1122
      if (empty($this->changeCache)) {
 
1123
        $changes = $repository_api->getAllLocalChanges();
 
1124
        foreach ($changes as $change) {
 
1125
          $this->changeCache[$change->getCurrentPath()] = $change;
 
1126
        }
 
1127
      }
 
1128
    } else {
 
1129
      throw new Exception('Missing VCS support.');
 
1130
    }
 
1131
 
 
1132
    if (empty($this->changeCache[$path])) {
 
1133
      if ($is_git || $is_hg) {
 
1134
        // This can legitimately occur under git/hg if you make a change,
 
1135
        // "git/hg commit" it, and then revert the change in the working copy
 
1136
        // and run "arc lint".
 
1137
        $change = new ArcanistDiffChange();
 
1138
        $change->setCurrentPath($path);
 
1139
        return $change;
 
1140
      } else {
 
1141
        throw new Exception(
 
1142
          "Trying to get change for unchanged path '{$path}'!");
 
1143
      }
 
1144
    }
 
1145
 
 
1146
    return $this->changeCache[$path];
 
1147
  }
 
1148
 
 
1149
  final public function willRunWorkflow() {
 
1150
    $spec = $this->getCompleteArgumentSpecification();
 
1151
    foreach ($this->arguments as $arg => $value) {
 
1152
      if (empty($spec[$arg])) {
 
1153
        continue;
 
1154
      }
 
1155
      $options = $spec[$arg];
 
1156
      if (!empty($options['supports'])) {
 
1157
        $system_name = $this->getRepositoryAPI()->getSourceControlSystemName();
 
1158
        if (!in_array($system_name, $options['supports'])) {
 
1159
          $extended_info = null;
 
1160
          if (!empty($options['nosupport'][$system_name])) {
 
1161
            $extended_info = ' '.$options['nosupport'][$system_name];
 
1162
          }
 
1163
          throw new ArcanistUsageException(
 
1164
            "Option '--{$arg}' is not supported under {$system_name}.".
 
1165
            $extended_info);
 
1166
        }
 
1167
      }
 
1168
    }
 
1169
  }
 
1170
 
 
1171
  final protected function normalizeRevisionID($revision_id) {
 
1172
    return preg_replace('/^D/i', '', $revision_id);
 
1173
  }
 
1174
 
 
1175
  protected function shouldShellComplete() {
 
1176
    return true;
 
1177
  }
 
1178
 
 
1179
  protected function getShellCompletions(array $argv) {
 
1180
    return array();
 
1181
  }
 
1182
 
 
1183
  protected function getSupportedRevisionControlSystems() {
 
1184
    return array('any');
 
1185
  }
 
1186
 
 
1187
  final protected function getPassthruArgumentsAsMap($command) {
 
1188
    $map = array();
 
1189
    foreach ($this->getCompleteArgumentSpecification() as $key => $spec) {
 
1190
      if (!empty($spec['passthru'][$command])) {
 
1191
        if (isset($this->arguments[$key])) {
 
1192
          $map[$key] = $this->arguments[$key];
 
1193
        }
 
1194
      }
 
1195
    }
 
1196
    return $map;
 
1197
  }
 
1198
 
 
1199
  final protected function getPassthruArgumentsAsArgv($command) {
 
1200
    $spec = $this->getCompleteArgumentSpecification();
 
1201
    $map = $this->getPassthruArgumentsAsMap($command);
 
1202
    $argv = array();
 
1203
    foreach ($map as $key => $value) {
 
1204
      $argv[] = '--'.$key;
 
1205
      if (!empty($spec[$key]['param'])) {
 
1206
        $argv[] = $value;
 
1207
      }
 
1208
    }
 
1209
    return $argv;
 
1210
  }
 
1211
 
 
1212
  /**
 
1213
   * Write a message to stderr so that '--json' flags or stdout which is meant
 
1214
   * to be piped somewhere aren't disrupted.
 
1215
   *
 
1216
   * @param string  Message to write to stderr.
 
1217
   * @return void
 
1218
   */
 
1219
  final protected function writeStatusMessage($msg) {
 
1220
    fwrite(STDERR, $msg);
 
1221
  }
 
1222
 
 
1223
  final protected function isHistoryImmutable() {
 
1224
    $repository_api = $this->getRepositoryAPI();
 
1225
 
 
1226
    $config = $this->getConfigFromAnySource('history.immutable');
 
1227
    if ($config !== null) {
 
1228
      return $config;
 
1229
    }
 
1230
 
 
1231
    return $repository_api->isHistoryDefaultImmutable();
 
1232
  }
 
1233
 
 
1234
  /**
 
1235
   * Workflows like 'lint' and 'unit' operate on a list of working copy paths.
 
1236
   * The user can either specify the paths explicitly ("a.js b.php"), or by
 
1237
   * specifying a revision ("--rev a3f10f1f") to select all paths modified
 
1238
   * since that revision, or by omitting both and letting arc choose the
 
1239
   * default relative revision.
 
1240
   *
 
1241
   * This method takes the user's selections and returns the paths that the
 
1242
   * workflow should act upon.
 
1243
   *
 
1244
   * @param   list          List of explicitly provided paths.
 
1245
   * @param   string|null   Revision name, if provided.
 
1246
   * @param   mask          Mask of ArcanistRepositoryAPI flags to exclude.
 
1247
   *                        Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED.
 
1248
   * @return  list          List of paths the workflow should act on.
 
1249
   */
 
1250
  final protected function selectPathsForWorkflow(
 
1251
    array $paths,
 
1252
    $rev,
 
1253
    $omit_mask = null) {
 
1254
 
 
1255
    if ($omit_mask === null) {
 
1256
      $omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED;
 
1257
    }
 
1258
 
 
1259
    if ($paths) {
 
1260
      $working_copy = $this->getWorkingCopy();
 
1261
      foreach ($paths as $key => $path) {
 
1262
        $full_path = Filesystem::resolvePath($path);
 
1263
        if (!Filesystem::pathExists($full_path)) {
 
1264
          throw new ArcanistUsageException("Path '{$path}' does not exist!");
 
1265
        }
 
1266
        $relative_path = Filesystem::readablePath(
 
1267
          $full_path,
 
1268
          $working_copy->getProjectRoot());
 
1269
        $paths[$key] = $relative_path;
 
1270
      }
 
1271
    } else {
 
1272
      $repository_api = $this->getRepositoryAPI();
 
1273
 
 
1274
      if ($rev) {
 
1275
        $this->parseBaseCommitArgument(array($rev));
 
1276
      }
 
1277
 
 
1278
      $paths = $repository_api->getWorkingCopyStatus();
 
1279
      foreach ($paths as $path => $flags) {
 
1280
        if ($flags & $omit_mask) {
 
1281
          unset($paths[$path]);
 
1282
        }
 
1283
      }
 
1284
      $paths = array_keys($paths);
 
1285
    }
 
1286
 
 
1287
    return array_values($paths);
 
1288
  }
 
1289
 
 
1290
  final protected function renderRevisionList(array $revisions) {
 
1291
    $list = array();
 
1292
    foreach ($revisions as $revision) {
 
1293
      $list[] = '     - D'.$revision['id'].': '.$revision['title']."\n";
 
1294
    }
 
1295
    return implode('', $list);
 
1296
  }
 
1297
 
 
1298
 
 
1299
/* -(  Scratch Files  )------------------------------------------------------ */
 
1300
 
 
1301
 
 
1302
  /**
 
1303
   * Try to read a scratch file, if it exists and is readable.
 
1304
   *
 
1305
   * @param string Scratch file name.
 
1306
   * @return mixed String for file contents, or false for failure.
 
1307
   * @task scratch
 
1308
   */
 
1309
  final protected function readScratchFile($path) {
 
1310
    if (!$this->repositoryAPI) {
 
1311
      return false;
 
1312
    }
 
1313
    return $this->getRepositoryAPI()->readScratchFile($path);
 
1314
  }
 
1315
 
 
1316
 
 
1317
  /**
 
1318
   * Try to read a scratch JSON file, if it exists and is readable.
 
1319
   *
 
1320
   * @param string Scratch file name.
 
1321
   * @return array Empty array for failure.
 
1322
   * @task scratch
 
1323
   */
 
1324
  final protected function readScratchJSONFile($path) {
 
1325
    $file = $this->readScratchFile($path);
 
1326
    if (!$file) {
 
1327
      return array();
 
1328
    }
 
1329
    return json_decode($file, true);
 
1330
  }
 
1331
 
 
1332
 
 
1333
  /**
 
1334
   * Try to write a scratch file, if there's somewhere to put it and we can
 
1335
   * write there.
 
1336
   *
 
1337
   * @param  string Scratch file name to write.
 
1338
   * @param  string Data to write.
 
1339
   * @return bool   True on success, false on failure.
 
1340
   * @task scratch
 
1341
   */
 
1342
  final protected function writeScratchFile($path, $data) {
 
1343
    if (!$this->repositoryAPI) {
 
1344
      return false;
 
1345
    }
 
1346
    return $this->getRepositoryAPI()->writeScratchFile($path, $data);
 
1347
  }
 
1348
 
 
1349
 
 
1350
  /**
 
1351
   * Try to write a scratch JSON file, if there's somewhere to put it and we can
 
1352
   * write there.
 
1353
   *
 
1354
   * @param  string Scratch file name to write.
 
1355
   * @param  array Data to write.
 
1356
   * @return bool   True on success, false on failure.
 
1357
   * @task scratch
 
1358
   */
 
1359
  final protected function writeScratchJSONFile($path, array $data) {
 
1360
    return $this->writeScratchFile($path, json_encode($data));
 
1361
  }
 
1362
 
 
1363
 
 
1364
  /**
 
1365
   * Try to remove a scratch file.
 
1366
   *
 
1367
   * @param   string  Scratch file name to remove.
 
1368
   * @return  bool    True if the file was removed successfully.
 
1369
   * @task scratch
 
1370
   */
 
1371
  final protected function removeScratchFile($path) {
 
1372
    if (!$this->repositoryAPI) {
 
1373
      return false;
 
1374
    }
 
1375
    return $this->getRepositoryAPI()->removeScratchFile($path);
 
1376
  }
 
1377
 
 
1378
 
 
1379
  /**
 
1380
   * Get a human-readable description of the scratch file location.
 
1381
   *
 
1382
   * @param string  Scratch file name.
 
1383
   * @return mixed  String, or false on failure.
 
1384
   * @task scratch
 
1385
   */
 
1386
  final protected function getReadableScratchFilePath($path) {
 
1387
    if (!$this->repositoryAPI) {
 
1388
      return false;
 
1389
    }
 
1390
    return $this->getRepositoryAPI()->getReadableScratchFilePath($path);
 
1391
  }
 
1392
 
 
1393
 
 
1394
  /**
 
1395
   * Get the path to a scratch file, if possible.
 
1396
   *
 
1397
   * @param string  Scratch file name.
 
1398
   * @return mixed  File path, or false on failure.
 
1399
   * @task scratch
 
1400
   */
 
1401
  final protected function getScratchFilePath($path) {
 
1402
    if (!$this->repositoryAPI) {
 
1403
      return false;
 
1404
    }
 
1405
    return $this->getRepositoryAPI()->getScratchFilePath($path);
 
1406
  }
 
1407
 
 
1408
  final protected function getRepositoryEncoding() {
 
1409
    $default = 'UTF-8';
 
1410
    return nonempty(idx($this->getProjectInfo(), 'encoding'), $default);
 
1411
  }
 
1412
 
 
1413
  final protected function getProjectInfo() {
 
1414
    if ($this->projectInfo === null) {
 
1415
      $project_id = $this->getWorkingCopy()->getProjectID();
 
1416
      if (!$project_id) {
 
1417
        $this->projectInfo = array();
 
1418
      } else {
 
1419
        try {
 
1420
          $this->projectInfo = $this->getConduit()->callMethodSynchronous(
 
1421
            'arcanist.projectinfo',
 
1422
            array(
 
1423
              'name' => $project_id,
 
1424
            ));
 
1425
        } catch (ConduitClientException $ex) {
 
1426
          if ($ex->getErrorCode() != 'ERR-BAD-ARCANIST-PROJECT') {
 
1427
            throw $ex;
 
1428
          }
 
1429
 
 
1430
          // TODO: Implement a proper query method that doesn't throw on
 
1431
          // project not found. We just swallow this because some pathways,
 
1432
          // like Git with uncommitted changes in a repository with a new
 
1433
          // project ID, may attempt to access project information before
 
1434
          // the project is created. See T2153.
 
1435
          return array();
 
1436
        }
 
1437
      }
 
1438
    }
 
1439
 
 
1440
    return $this->projectInfo;
 
1441
  }
 
1442
 
 
1443
  final protected function loadProjectRepository() {
 
1444
    $project = $this->getProjectInfo();
 
1445
    if (isset($project['repository'])) {
 
1446
      return $project['repository'];
 
1447
    }
 
1448
    // NOTE: The rest of the code is here for backwards compatibility.
 
1449
 
 
1450
    $repository_phid = idx($project, 'repositoryPHID');
 
1451
    if (!$repository_phid) {
 
1452
      return array();
 
1453
    }
 
1454
 
 
1455
    $repositories = $this->getConduit()->callMethodSynchronous(
 
1456
      'repository.query',
 
1457
      array());
 
1458
    $repositories = ipull($repositories, null, 'phid');
 
1459
 
 
1460
    return idx($repositories, $repository_phid, array());
 
1461
  }
 
1462
 
 
1463
  final protected function newInteractiveEditor($text) {
 
1464
    $editor = new PhutilInteractiveEditor($text);
 
1465
 
 
1466
    $preferred = $this->getConfigFromAnySource('editor');
 
1467
    if ($preferred) {
 
1468
      $editor->setPreferredEditor($preferred);
 
1469
    }
 
1470
 
 
1471
    return $editor;
 
1472
  }
 
1473
 
 
1474
  final protected function newDiffParser() {
 
1475
    $parser = new ArcanistDiffParser();
 
1476
    if ($this->repositoryAPI) {
 
1477
      $parser->setRepositoryAPI($this->getRepositoryAPI());
 
1478
    }
 
1479
    $parser->setWriteDiffOnFailure(true);
 
1480
    return $parser;
 
1481
  }
 
1482
 
 
1483
  final protected function resolveCall(ConduitFuture $method, $timeout = null) {
 
1484
    try {
 
1485
      return $method->resolve($timeout);
 
1486
    } catch (ConduitClientException $ex) {
 
1487
      if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') {
 
1488
        echo phutil_console_wrap(
 
1489
          "This feature requires a newer version of Phabricator. Please ".
 
1490
          "update it using these instructions: ".
 
1491
          "http://www.phabricator.com/docs/phabricator/article/".
 
1492
          "Installation_Guide.html#updating-phabricator\n\n");
 
1493
      }
 
1494
      throw $ex;
 
1495
    }
 
1496
  }
 
1497
 
 
1498
  final protected function dispatchEvent($type, array $data) {
 
1499
    $data += array(
 
1500
      'workflow' => $this,
 
1501
    );
 
1502
 
 
1503
    $event = new PhutilEvent($type, $data);
 
1504
    PhutilEventEngine::dispatchEvent($event);
 
1505
 
 
1506
    return $event;
 
1507
  }
 
1508
 
 
1509
  final public function parseBaseCommitArgument(array $argv) {
 
1510
    if (!count($argv)) {
 
1511
      return;
 
1512
    }
 
1513
 
 
1514
    $api = $this->getRepositoryAPI();
 
1515
    if (!$api->supportsCommitRanges()) {
 
1516
      throw new ArcanistUsageException(
 
1517
        'This version control system does not support commit ranges.');
 
1518
    }
 
1519
 
 
1520
    if (count($argv) > 1) {
 
1521
      throw new ArcanistUsageException(
 
1522
        'Specify exactly one base commit. The end of the commit range is '.
 
1523
        'always the working copy state.');
 
1524
    }
 
1525
 
 
1526
    $api->setBaseCommit(head($argv));
 
1527
 
 
1528
    return $this;
 
1529
  }
 
1530
 
 
1531
  final protected function getRepositoryVersion() {
 
1532
    if (!$this->repositoryVersion) {
 
1533
      $api = $this->getRepositoryAPI();
 
1534
      $commit = $api->getSourceControlBaseRevision();
 
1535
      $versions = array('' => $commit);
 
1536
      foreach ($api->getChangedFiles($commit) as $path => $mask) {
 
1537
        $versions[$path] = (Filesystem::pathExists($path)
 
1538
          ? md5_file($path)
 
1539
          : '');
 
1540
      }
 
1541
      $this->repositoryVersion = md5(json_encode($versions));
 
1542
    }
 
1543
    return $this->repositoryVersion;
 
1544
  }
 
1545
 
 
1546
 
 
1547
/* -(  Phabricator Repositories  )------------------------------------------- */
 
1548
 
 
1549
 
 
1550
  /**
 
1551
   * Get the PHID of the Phabricator repository this working copy corresponds
 
1552
   * to. Returns `null` if no repository can be identified.
 
1553
   *
 
1554
   * @return phid|null  Repository PHID, or null if no repository can be
 
1555
   *                    identified.
 
1556
   *
 
1557
   * @task phabrep
 
1558
   */
 
1559
  final protected function getRepositoryPHID() {
 
1560
    return idx($this->getRepositoryInformation(), 'phid');
 
1561
  }
 
1562
 
 
1563
 
 
1564
  /**
 
1565
   * Get the callsign of the Phabricator repository this working copy
 
1566
   * corresponds to. Returns `null` if no repository can be identified.
 
1567
   *
 
1568
   * @return string|null  Repository callsign, or null if no repository can be
 
1569
   *                      identified.
 
1570
   *
 
1571
   * @task phabrep
 
1572
   */
 
1573
  final protected function getRepositoryCallsign() {
 
1574
    return idx($this->getRepositoryInformation(), 'callsign');
 
1575
  }
 
1576
 
 
1577
 
 
1578
  /**
 
1579
   * Get the URI of the Phabricator repository this working copy
 
1580
   * corresponds to. Returns `null` if no repository can be identified.
 
1581
   *
 
1582
   * @return string|null  Repository URI, or null if no repository can be
 
1583
   *                      identified.
 
1584
   *
 
1585
   * @task phabrep
 
1586
   */
 
1587
  final protected function getRepositoryURI() {
 
1588
    return idx($this->getRepositoryInformation(), 'uri');
 
1589
  }
 
1590
 
 
1591
 
 
1592
  /**
 
1593
   * Get human-readable reasoning explaining how `arc` evaluated which
 
1594
   * Phabricator repository corresponds to this working copy. Used by
 
1595
   * `arc which` to explain the process to users.
 
1596
   *
 
1597
   * @return list<string> Human-readable explanation of the repository
 
1598
   *                      association process.
 
1599
   *
 
1600
   * @task phabrep
 
1601
   */
 
1602
  final protected function getRepositoryReasons() {
 
1603
    $this->getRepositoryInformation();
 
1604
    return $this->repositoryReasons;
 
1605
  }
 
1606
 
 
1607
 
 
1608
  /**
 
1609
   * @task phabrep
 
1610
   */
 
1611
  private function getRepositoryInformation() {
 
1612
    if ($this->repositoryInfo === null) {
 
1613
      list($info, $reasons) = $this->loadRepositoryInformation();
 
1614
      $this->repositoryInfo = nonempty($info, array());
 
1615
      $this->repositoryReasons = $reasons;
 
1616
    }
 
1617
 
 
1618
    return $this->repositoryInfo;
 
1619
  }
 
1620
 
 
1621
 
 
1622
  /**
 
1623
   * @task phabrep
 
1624
   */
 
1625
  private function loadRepositoryInformation() {
 
1626
    list($query, $reasons) = $this->getRepositoryQuery();
 
1627
    if (!$query) {
 
1628
      return array(null, $reasons);
 
1629
    }
 
1630
 
 
1631
    try {
 
1632
      $results = $this->getConduit()->callMethodSynchronous(
 
1633
        'repository.query',
 
1634
        $query);
 
1635
    } catch (ConduitClientException $ex) {
 
1636
      if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') {
 
1637
        $reasons[] = pht(
 
1638
          'This version of Arcanist is more recent than the version of '.
 
1639
          'Phabricator you are connecting to: the Phabricator install is '.
 
1640
          'out of date and does not have support for identifying '.
 
1641
          'repositories by callsign or URI. Update Phabricator to enable '.
 
1642
          'these features.');
 
1643
        return array(null, $reasons);
 
1644
      }
 
1645
      throw $ex;
 
1646
    }
 
1647
 
 
1648
    $result = null;
 
1649
    if (!$results) {
 
1650
      $reasons[] = pht(
 
1651
        'No repositories matched the query. Check that your configuration '.
 
1652
        'is correct, or use "repository.callsign" to select a repository '.
 
1653
        'explicitly.');
 
1654
    } else if (count($results) > 1) {
 
1655
      $reasons[] = pht(
 
1656
        'Multiple repostories (%s) matched the query. You can use the '.
 
1657
        '"repository.callsign" configuration to select the one you want.',
 
1658
        implode(', ', ipull($results, 'callsign')));
 
1659
    } else {
 
1660
      $result = head($results);
 
1661
      $reasons[] = pht('Found a unique matching repository.');
 
1662
    }
 
1663
 
 
1664
    return array($result, $reasons);
 
1665
  }
 
1666
 
 
1667
 
 
1668
  /**
 
1669
   * @task phabrep
 
1670
   */
 
1671
  private function getRepositoryQuery() {
 
1672
    $reasons = array();
 
1673
 
 
1674
    $callsign = $this->getConfigFromAnySource('repository.callsign');
 
1675
    if ($callsign) {
 
1676
      $query = array(
 
1677
        'callsigns' => array($callsign),
 
1678
      );
 
1679
      $reasons[] = pht(
 
1680
        'Configuration value "repository.callsign" is set to "%s".',
 
1681
        $callsign);
 
1682
      return array($query, $reasons);
 
1683
    } else {
 
1684
      $reasons[] = pht(
 
1685
        'Configuration value "repository.callsign" is empty.');
 
1686
    }
 
1687
 
 
1688
    $project_info = $this->getProjectInfo();
 
1689
    $project_name = $this->getWorkingCopy()->getProjectID();
 
1690
    if ($this->getProjectInfo()) {
 
1691
      if (!empty($project_info['repository']['callsign'])) {
 
1692
        $callsign = $project_info['repository']['callsign'];
 
1693
        $query = array(
 
1694
          'callsigns' => array($callsign),
 
1695
        );
 
1696
        $reasons[] = pht(
 
1697
          'Configuration value "project.name" is set to "%s"; this project '.
 
1698
          'is associated with the "%s" repository.',
 
1699
          $project_name,
 
1700
          $callsign);
 
1701
        return array($query, $reasons);
 
1702
      } else {
 
1703
        $reasons[] = pht(
 
1704
          'Configuration value "project.name" is set to "%s", but this '.
 
1705
          'project is not associated with a repository.',
 
1706
          $project_name);
 
1707
      }
 
1708
    } else if (strlen($project_name)) {
 
1709
      $reasons[] = pht(
 
1710
        'Configuration value "project.name" is set to "%s", but that '.
 
1711
        'project does not exist.',
 
1712
        $project_name);
 
1713
    } else {
 
1714
      $reasons[] = pht(
 
1715
        'Configuration value "project.name" is empty.');
 
1716
    }
 
1717
 
 
1718
    $uuid = $this->getRepositoryAPI()->getRepositoryUUID();
 
1719
    if ($uuid !== null) {
 
1720
      $query = array(
 
1721
        'uuids' => array($uuid),
 
1722
      );
 
1723
      $reasons[] = pht(
 
1724
        'The UUID for this working copy is "%s".',
 
1725
        $uuid);
 
1726
      return array($query, $reasons);
 
1727
    } else {
 
1728
      $reasons[] = pht(
 
1729
        'This repository has no VCS UUID (this is normal for git/hg).');
 
1730
    }
 
1731
 
 
1732
    $remote_uri = $this->getRepositoryAPI()->getRemoteURI();
 
1733
    if ($remote_uri !== null) {
 
1734
      $query = array(
 
1735
        'remoteURIs' => array($remote_uri),
 
1736
      );
 
1737
      $reasons[] = pht(
 
1738
        'The remote URI for this working copy is "%s".',
 
1739
        $remote_uri);
 
1740
      return array($query, $reasons);
 
1741
    } else {
 
1742
      $reasons[] = pht(
 
1743
        'Unable to determine the remote URI for this repository.');
 
1744
    }
 
1745
 
 
1746
    return array(null, $reasons);
 
1747
  }
 
1748
 
 
1749
 
 
1750
  /**
 
1751
   * Build a new lint engine for the current working copy.
 
1752
   *
 
1753
   * Optionally, you can pass an explicit engine class name to build an engine
 
1754
   * of a particular class. Normally this is used to implement an `--engine`
 
1755
   * flag from the CLI.
 
1756
   *
 
1757
   * @param string Optional explicit engine class name.
 
1758
   * @return ArcanistLintEngine Constructed engine.
 
1759
   */
 
1760
  protected function newLintEngine($engine_class = null) {
 
1761
    $working_copy = $this->getWorkingCopy();
 
1762
    $config = $this->getConfigurationManager();
 
1763
 
 
1764
    if (!$engine_class) {
 
1765
      $engine_class = $config->getConfigFromAnySource('lint.engine');
 
1766
    }
 
1767
 
 
1768
    if (!$engine_class) {
 
1769
      if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) {
 
1770
        $engine_class = 'ArcanistConfigurationDrivenLintEngine';
 
1771
      }
 
1772
    }
 
1773
 
 
1774
    if (!$engine_class) {
 
1775
      throw new ArcanistNoEngineException(
 
1776
        pht(
 
1777
          "No lint engine is configured for this project. ".
 
1778
          "Create an '.arclint' file, or configure an advanced engine ".
 
1779
          "with 'lint.engine' in '.arcconfig'."));
 
1780
    }
 
1781
 
 
1782
    $base_class = 'ArcanistLintEngine';
 
1783
    if (!class_exists($engine_class) ||
 
1784
        !is_subclass_of($engine_class, $base_class)) {
 
1785
      throw new ArcanistUsageException(
 
1786
        pht(
 
1787
          'Configured lint engine "%s" is not a subclass of "%s", but must '.
 
1788
          'be.',
 
1789
          $engine_class,
 
1790
          $base_class));
 
1791
    }
 
1792
 
 
1793
    $engine = newv($engine_class, array())
 
1794
      ->setWorkingCopy($working_copy)
 
1795
      ->setConfigurationManager($config);
 
1796
 
 
1797
    return $engine;
 
1798
  }
 
1799
 
 
1800
  protected function openURIsInBrowser(array $uris) {
 
1801
    $browser = $this->getBrowserCommand();
 
1802
    foreach ($uris as $uri) {
 
1803
      $err = phutil_passthru('%s %s', $browser, $uri);
 
1804
      if ($err) {
 
1805
        throw new ArcanistUsageException(
 
1806
          pht(
 
1807
            "Failed to open '%s' in browser ('%s'). ".
 
1808
            "Check your 'browser' config option.",
 
1809
            $uri,
 
1810
            $browser));
 
1811
      }
 
1812
    }
 
1813
  }
 
1814
 
 
1815
  private function getBrowserCommand() {
 
1816
    $config = $this->getConfigFromAnySource('browser');
 
1817
    if ($config) {
 
1818
      return $config;
 
1819
    }
 
1820
 
 
1821
    if (phutil_is_windows()) {
 
1822
      return 'start';
 
1823
    }
 
1824
 
 
1825
    $candidates = array('sensible-browser', 'xdg-open', 'open');
 
1826
 
 
1827
    // NOTE: The "open" command works well on OS X, but on many Linuxes "open"
 
1828
    // exists and is not a browser. For now, we're just looking for other
 
1829
    // commands first, but we might want to be smarter about selecting "open"
 
1830
    // only on OS X.
 
1831
 
 
1832
    foreach ($candidates as $cmd) {
 
1833
      if (Filesystem::binaryExists($cmd)) {
 
1834
        return $cmd;
 
1835
      }
 
1836
    }
 
1837
 
 
1838
    throw new ArcanistUsageException(
 
1839
      pht(
 
1840
        "Unable to find a browser command to run. Set 'browser' in your ".
 
1841
        "Arcanist config to specify a command to use."));
 
1842
  }
 
1843
 
 
1844
 
 
1845
  /**
 
1846
   * Ask Phabricator to update the current repository as soon as possible.
 
1847
   *
 
1848
   * Calling this method after pushing commits allows Phabricator to discover
 
1849
   * the commits more quickly, so the system overall is more responsive.
 
1850
   *
 
1851
   * @return void
 
1852
   */
 
1853
  protected function askForRepositoryUpdate() {
 
1854
    // If we know which repository we're in, try to tell Phabricator that we
 
1855
    // pushed commits to it so it can update. This hint can help pull updates
 
1856
    // more quickly, especially in rarely-used repositories.
 
1857
    if ($this->getRepositoryCallsign()) {
 
1858
      try {
 
1859
        $this->getConduit()->callMethodSynchronous(
 
1860
          'diffusion.looksoon',
 
1861
          array(
 
1862
            'callsigns' => array($this->getRepositoryCallsign()),
 
1863
          ));
 
1864
      } catch (ConduitClientException $ex) {
 
1865
        // If we hit an exception, just ignore it. Likely, we are running
 
1866
        // against a Phabricator which is too old to support this method.
 
1867
        // Since this hint is purely advisory, it doesn't matter if it has
 
1868
        // no effect.
 
1869
      }
 
1870
    }
 
1871
  }
 
1872
 
 
1873
 
 
1874
}