4
* Implements a runnable command, like "arc diff" or "arc help".
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").
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.
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.
32
* @task conduit Conduit
33
* @task scratch Scratch Files
34
* @task phabrep Phabricator Repositories
38
abstract class ArcanistWorkflow extends Phobject {
40
const COMMIT_DISABLE = 0;
41
const COMMIT_ALLOW = 1;
42
const COMMIT_ENABLE = 2;
44
const AUTO_COMMIT_TITLE = 'Automatic commit by arc';
46
private $commitMode = self::COMMIT_DISABLE;
50
private $conduitCredentials;
51
private $conduitAuthenticated;
52
private $forcedConduitVersion;
53
private $conduitTimeout;
57
private $repositoryAPI;
58
private $configurationManager;
61
private $passedArguments;
68
private $repositoryInfo;
69
private $repositoryReasons;
71
private $arcanistConfiguration;
72
private $parentWorkflow;
73
private $workingDirectory;
74
private $repositoryVersion;
76
private $changeCache = array();
79
public function __construct() {}
82
abstract public function run();
85
* Finalizes any cleanup operations that need to occur regardless of
86
* whether the command succeeded or failed.
88
public function finalize() {
89
// TODO: Remove this once ArcanistBaseWorkflow is gone.
90
if ($this instanceof ArcanistBaseWorkflow) {
92
'ArcanistBaseWorkflow',
93
'You should extend from `ArcanistWorkflow` instead.');
96
$this->finalizeWorkingCopy();
100
* Return the command used to invoke this workflow from the command like,
101
* e.g. "help" for @{class:ArcanistHelpWorkflow}.
103
* @return string The command a user types to invoke this workflow.
105
abstract public function getWorkflowName();
108
* Return console formatted string with all command synopses.
110
* @return string 6-space indented list of available command synopses.
112
abstract public function getCommandSynopses();
115
* Return console formatted string with command help printed in `arc help`.
117
* @return string 10-space indented help to use the command.
119
abstract public function getCommandHelp();
122
/* -( Conduit )------------------------------------------------------------ */
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}.
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}.
137
* NOTE: You can not call this after a conduit has been established.
139
* @param string The URI to open a conduit to when @{method:establishConduit}
144
final public function setConduitURI($conduit_uri) {
145
if ($this->conduit) {
147
'You can not change the Conduit URI after a conduit is already open.');
149
$this->conduitURI = $conduit_uri;
154
* Returns the URI the conduit connection within the workflow uses.
159
final public function getConduitURI() {
160
return $this->conduitURI;
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.
169
* You must establish a conduit before you can make conduit calls.
171
* NOTE: You must call @{method:setConduitURI} before you can call this
177
final public function establishConduit() {
178
if ($this->conduit) {
182
if (!$this->conduitURI) {
184
'You must specify a Conduit URI with setConduitURI() before you can '.
185
'establish a conduit.');
188
$this->conduit = new ConduitClient($this->conduitURI);
190
if ($this->conduitTimeout) {
191
$this->conduit->setTimeout($this->conduitTimeout);
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);
203
final public function getConfigFromAnySource($key) {
204
return $this->configurationManager->getConfigFromAnySource($key);
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
220
* NOTE: You can not call this method after calling
221
* @{method:authenticateConduit}.
223
* @param dict A credential dictionary, see @{method:authenticateConduit}.
227
final public function setConduitCredentials(array $credentials) {
228
if ($this->isConduitAuthenticated()) {
230
'You may not set new credentials after authenticating conduit.');
233
$this->conduitCredentials = $credentials;
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.
244
* Controlled by the --conduit-version flag.
246
* @param int Version the client should pretend to be.
250
final public function forceConduitVersion($version) {
251
$this->forcedConduitVersion = $version;
257
* Get the protocol version the client should identify with.
259
* @return int Version the client should claim to be.
262
final public function getConduitVersion() {
263
return nonempty($this->forcedConduitVersion, 6);
268
* Override the default timeout for Conduit.
270
* Controlled by the --conduit-timeout flag.
272
* @param float Timeout, in seconds.
276
final public function setConduitTimeout($timeout) {
277
$this->conduitTimeout = $timeout;
278
if ($this->conduit) {
279
$this->conduit->setConduitTimeout($timeout);
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.
292
* You must authenticate the conduit before you can make authenticated conduit
293
* calls (almost all calls require authentication).
295
* This method uses credentials provided via @{method:setConduitCredentials}
296
* to authenticate to the server:
298
* - ##user## (required) The username to authenticate with.
299
* - ##certificate## (required) The Conduit certificate to use.
300
* - ##description## (optional) Description of the invoking command.
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.
306
* NOTE: You must call @{method:setConduitURI} and
307
* @{method:setConduitCredentials} before you invoke this method.
312
final public function authenticateConduit() {
313
if ($this->isConduitAuthenticated()) {
317
$this->establishConduit();
318
$credentials = $this->conduitCredentials;
323
'Set conduit credentials with setConduitCredentials() before '.
324
'authenticating conduit!');
327
if (empty($credentials['user'])) {
328
throw new ConduitClientException(
330
'Empty user in credentials.');
332
if (empty($credentials['certificate'])) {
333
throw new ConduitClientException(
334
'ERR-NO-CERTIFICATE',
335
'Empty certificate in credentials.');
338
$description = idx($credentials, 'description', '');
339
$user = $credentials['user'];
340
$certificate = $credentials['certificate'];
342
$connection = $this->getConduit()->callMethodSynchronous(
346
'clientVersion' => $this->getConduitVersion(),
347
'clientDescription' => php_uname('n').':'.$description,
349
'certificate' => $certificate,
350
'host' => $this->conduitURI,
352
} catch (ConduitClientException $ex) {
353
if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' ||
354
$ex->getErrorCode() == 'ERR-INVALID-USER') {
355
$conduit_uri = $this->conduitURI;
358
phutil_console_format(
359
'YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR').
361
phutil_console_format(
362
' To do this, run: **arc install-certificate**').
364
"The server '{$conduit_uri}' rejected your request:".
367
throw new ArcanistUsageException($message);
368
} else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') {
370
// Cleverly disguise this as being AWESOME!!!
372
echo phutil_console_format("**New Version Available!**\n\n");
373
echo phutil_console_wrap($ex->getMessage());
375
echo "In most cases, arc can be upgraded automatically.\n";
377
$ok = phutil_console_confirm(
379
$default_no = false);
384
$root = dirname(phutil_get_library_root('arcanist'));
387
$err = phutil_passthru('%s upgrade', $root.'/bin/arc');
389
echo "\nTry running your arc command again.\n";
397
$this->userName = $user;
398
$this->userPHID = $connection['userPHID'];
400
$this->conduitAuthenticated = true;
406
* @return bool True if conduit is authenticated, false otherwise.
409
final protected function isConduitAuthenticated() {
410
return (bool)$this->conduitAuthenticated;
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}.
420
* @return bool True if arc should build a conduit channel before running
424
public function requiresConduit() {
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.
434
* @return bool True if arc should build an authenticated conduit channel
435
* before running the workflow.
438
public function requiresAuthentication() {
444
* Returns the PHID for the user once they've authenticated via Conduit.
446
* @return phid Authenticated user PHID.
449
final public function getUserPHID() {
450
if (!$this->userPHID) {
451
$workflow = get_class($this);
453
"This workflow ('{$workflow}') requires authentication, override ".
454
"requiresAuthentication() to return true.");
456
return $this->userPHID;
460
* Return the username for the user once they've authenticated via Conduit.
462
* @return string Authenticated username.
465
final public function getUserName() {
466
return $this->userName;
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}.
477
* @return @{class@libphutil:ConduitClient} Live conduit client.
480
final public function getConduit() {
481
if (!$this->conduit) {
482
$workflow = get_class($this);
484
"This workflow ('{$workflow}') requires a Conduit, override ".
485
"requiresConduit() to return true.");
487
return $this->conduit;
491
final public function setArcanistConfiguration(
492
ArcanistConfiguration $arcanist_configuration) {
494
$this->arcanistConfiguration = $arcanist_configuration;
498
final public function getArcanistConfiguration() {
499
return $this->arcanistConfiguration;
502
final public function setConfigurationManager(
503
ArcanistConfigurationManager $arcanist_configuration_manager) {
505
$this->configurationManager = $arcanist_configuration_manager;
509
final public function getConfigurationManager() {
510
return $this->configurationManager;
513
public function requiresWorkingCopy() {
517
public function desiresWorkingCopy() {
521
public function requiresRepositoryAPI() {
525
public function desiresRepositoryAPI() {
529
final public function setCommand($command) {
530
$this->command = $command;
534
final public function getCommand() {
535
return $this->command;
538
public function getArguments() {
542
final public function setWorkingDirectory($working_directory) {
543
$this->workingDirectory = $working_directory;
547
final public function getWorkingDirectory() {
548
return $this->workingDirectory;
551
final private function setParentWorkflow($parent_workflow) {
552
$this->parentWorkflow = $parent_workflow;
556
final protected function getParentWorkflow() {
557
return $this->parentWorkflow;
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());
567
if ($this->repositoryAPI) {
568
$workflow->setRepositoryAPI($this->repositoryAPI);
571
if ($this->userPHID) {
572
$workflow->userPHID = $this->getUserPHID();
573
$workflow->userName = $this->getUserName();
576
if ($this->conduit) {
577
$workflow->conduit = $this->conduit;
578
$workflow->setConduitCredentials($this->conduitCredentials);
579
$workflow->conduitAuthenticated = $this->conduitAuthenticated;
582
if ($this->workingCopy) {
583
$workflow->setWorkingCopy($this->workingCopy);
586
$workflow->setArcanistConfiguration($arc_config);
588
$workflow->parseArguments(array_values($argv));
593
final public function getArgument($key, $default = null) {
594
return idx($this->arguments, $key, $default);
597
final public function getPassedArguments() {
598
return $this->passedArguments;
601
final public function getCompleteArgumentSpecification() {
602
$spec = $this->getArguments();
603
$arc_config = $this->getArcanistConfiguration();
604
$command = $this->getCommand();
605
$spec += $arc_config->getCustomArgumentsForCommand($command);
610
final public function parseArguments(array $args) {
611
$this->passedArguments = $args;
613
$spec = $this->getCompleteArgumentSpecification();
618
if (!empty($spec['*'])) {
619
$more_key = $spec['*'];
621
$dict[$more_key] = array();
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;
631
foreach ($spec as $long => $options) {
632
if (!empty($options['repeat'])) {
633
$dict[$long] = array();
638
for ($ii = 0; $ii < count($args); $ii++) {
645
array_slice($args, $ii + 1));
647
} else if (!strncmp($arg, '--', 2)) {
648
$arg_key = substr($arg, 2);
649
if (!array_key_exists($arg_key, $spec)) {
650
$corrected = ArcanistConfiguration::correctArgumentSpelling(
653
if (count($corrected) == 1) {
654
PhutilConsole::getConsole()->writeErr(
656
"(Assuming '%s' is the British spelling of '%s'.)",
658
'--'.head($corrected))."\n");
659
$arg_key = head($corrected);
661
throw new ArcanistUsageException(pht(
662
"Unknown argument '%s'. Try 'arc help'.",
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'.",
673
$arg_key = $short_to_long_map[$arg_key];
679
$options = $spec[$arg_key];
680
if (empty($options['param'])) {
681
$dict[$arg_key] = true;
683
if ($ii == count($args) - 1) {
684
throw new ArcanistUsageException(pht(
685
"Option '%s' requires a parameter.",
688
if (!empty($options['repeat'])) {
689
$dict[$arg_key][] = $args[$ii + 1];
691
$dict[$arg_key] = $args[$ii + 1];
699
$dict[$more_key] = $more;
701
$example = reset($more);
702
throw new ArcanistUsageException(pht(
703
"Unrecognized argument '%s'. Try 'arc help'.",
708
foreach ($dict as $key => $value) {
709
if (empty($spec[$key]['conflicts'])) {
712
foreach ($spec[$key]['conflicts'] as $conflict => $more) {
713
if (isset($dict[$conflict])) {
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".
728
$this->arguments = $dict;
730
$this->didParseArguments();
735
protected function didParseArguments() {
736
// Override this to customize workflow argument behavior.
739
final public function getWorkingCopy() {
740
$working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity();
741
if (!$working_copy) {
742
$workflow = get_class($this);
744
"This workflow ('{$workflow}') requires a working copy, override ".
745
"requiresWorkingCopy() to return true.");
747
return $working_copy;
750
final public function setWorkingCopy(
751
ArcanistWorkingCopyIdentity $working_copy) {
752
$this->workingCopy = $working_copy;
756
final public function setRepositoryAPI($api) {
757
$this->repositoryAPI = $api;
761
final public function hasRepositoryAPI() {
763
return (bool)$this->getRepositoryAPI();
764
} catch (Exception $ex) {
769
final public function getRepositoryAPI() {
770
if (!$this->repositoryAPI) {
771
$workflow = get_class($this);
773
"This workflow ('{$workflow}') requires a Repository API, override ".
774
"requiresRepositoryAPI() to return true.");
776
return $this->repositoryAPI;
779
final protected function shouldRequireCleanUntrackedFiles() {
780
return empty($this->arguments['allow-untracked']);
783
final public function setCommitMode($mode) {
784
$this->commitMode = $mode;
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";
796
final public function requireCleanWorkingCopy() {
797
$api = $this->getRepositoryAPI();
799
$must_commit = array();
801
$working_copy_desc = phutil_console_format(
802
" Working copy: __%s__\n\n",
805
$untracked = $api->getUntrackedChanges();
806
if ($this->shouldRequireCleanUntrackedFiles()) {
808
if (!empty($untracked)) {
809
echo "You have untracked files in this working copy.\n\n".
811
" Untracked files in working copy:\n".
812
" ".implode("\n ", $untracked)."\n\n";
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");
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()));
844
// NOTE: this is a subversion-only concept.
845
$incomplete = $api->getIncompleteChanges();
847
throw new ArcanistUsageException(
848
"You have incompletely checked out directories in this working copy. ".
849
"Fix them before proceeding.\n\n".
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.");
856
$conflicts = $api->getMergeConflicts();
858
throw new ArcanistUsageException(
859
"You have merge conflicts in this working copy. Resolve merge ".
860
"conflicts before proceeding.\n\n".
862
" Conflicts in working copy:\n".
863
" ".implode("\n ", $conflicts)."\n");
866
$missing = $api->getMissingChanges();
868
throw new ArcanistUsageException(
870
"You have missing files in this working copy. Revert or formally ".
871
"remove them (with `svn rm`) before proceeding.\n\n".
873
" Missing files in working copy:\n%s\n",
875
" ".implode("\n ", $missing)));
878
$unstaged = $api->getUnstagedChanges();
880
echo "You have unstaged changes in this working copy.\n\n".
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);
888
$permit_autostash = $this->getConfigFromAnySource(
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;
897
throw new ArcanistUsageException(
898
'Stage and commit (or revert) them before proceeding.');
903
$uncommitted = $api->getUncommittedChanges();
904
foreach ($uncommitted as $key => $path) {
905
if (array_key_exists($path, $must_commit)) {
906
unset($uncommitted[$key]);
910
echo "You have uncommitted changes in this working copy.\n\n".
912
" Uncommitted changes in working copy:\n".
913
" ".implode("\n ", $uncommitted)."\n";
914
if ($this->askForAdd($uncommitted)) {
915
$must_commit += array_flip($uncommitted);
917
throw new ArcanistUncommittedChangesException(
918
'Commit (or revert) them before proceeding.');
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;
931
$api->doCommit($commit_message);
936
private function getShouldAmend() {
937
if ($this->shouldAmend === null) {
938
$this->shouldAmend = $this->calculateShouldAmend();
940
return $this->shouldAmend;
943
private function calculateShouldAmend() {
944
$api = $this->getRepositoryAPI();
946
if ($this->isHistoryImmutable() || !$api->supportsAmend()) {
950
$commits = $api->getLocalCommitInformation();
955
$commit = reset($commits);
956
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
959
if ($message->getGitSVNBaseRevision()) {
963
if ($api->getAuthor() != $commit['author']) {
967
if ($message->getRevisionID() && $this->getArgument('create')) {
971
// TODO: Check commits since tracking branch. If empty then return false.
973
$repository = $this->loadProjectRepository();
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)) {
984
if (!$message->getRevisionID()) {
988
$in_working_copy = $api->loadWorkingCopyDifferentialRevisions(
991
'authors' => array($this->getUserPHID()),
992
'status' => 'status-open',
994
if ($in_working_copy) {
1001
private function askForAdd(array $files) {
1002
if ($this->commitMode == self::COMMIT_DISABLE) {
1005
if ($this->commitMode == self::COMMIT_ENABLE) {
1008
$prompt = $this->getAskForAddPrompt($files);
1009
return phutil_console_confirm($prompt);
1012
private function getAskForAddPrompt(array $files) {
1013
if ($this->getShouldAmend()) {
1015
'Do you want to amend these files to the commit?',
1019
'Do you want to add these files to the commit?',
1025
final protected function loadDiffBundleFromConduit(
1026
ConduitClient $conduit,
1029
return $this->loadBundleFromConduit(
1032
'diff_id' => $diff_id,
1036
final protected function loadRevisionBundleFromConduit(
1037
ConduitClient $conduit,
1040
return $this->loadBundleFromConduit(
1043
'revision_id' => $revision_id,
1047
final private function loadBundleFromConduit(
1048
ConduitClient $conduit,
1051
$future = $conduit->callMethod('differential.getdiff', $params);
1052
$diff = $future->resolve();
1055
foreach ($diff['changes'] as $changedict) {
1056
$changes[] = ArcanistDiffChange::newFromDictionary($changedict);
1058
$bundle = ArcanistBundle::newFromChanges($changes);
1059
$bundle->setConduit($conduit);
1060
// since the conduit method has changes, assume that these fields
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'));
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
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.
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)) {
1087
if (!file_exists($full_path)) {
1091
$change = $this->getChange($path);
1093
if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) {
1097
$lines = $change->getChangedLines($mode);
1098
return array_keys($lines);
1101
final protected function getChange($path) {
1102
$repository_api = $this->getRepositoryAPI();
1105
$is_git = ($repository_api instanceof ArcanistGitAPI);
1106
$is_hg = ($repository_api instanceof ArcanistMercurialAPI);
1107
$is_svn = ($repository_api instanceof ArcanistSubversionAPI);
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.');
1119
$this->changeCache[$path] = reset($changes);
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;
1129
throw new Exception('Missing VCS support.');
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);
1141
throw new Exception(
1142
"Trying to get change for unchanged path '{$path}'!");
1146
return $this->changeCache[$path];
1149
final public function willRunWorkflow() {
1150
$spec = $this->getCompleteArgumentSpecification();
1151
foreach ($this->arguments as $arg => $value) {
1152
if (empty($spec[$arg])) {
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];
1163
throw new ArcanistUsageException(
1164
"Option '--{$arg}' is not supported under {$system_name}.".
1171
final protected function normalizeRevisionID($revision_id) {
1172
return preg_replace('/^D/i', '', $revision_id);
1175
protected function shouldShellComplete() {
1179
protected function getShellCompletions(array $argv) {
1183
protected function getSupportedRevisionControlSystems() {
1184
return array('any');
1187
final protected function getPassthruArgumentsAsMap($command) {
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];
1199
final protected function getPassthruArgumentsAsArgv($command) {
1200
$spec = $this->getCompleteArgumentSpecification();
1201
$map = $this->getPassthruArgumentsAsMap($command);
1203
foreach ($map as $key => $value) {
1204
$argv[] = '--'.$key;
1205
if (!empty($spec[$key]['param'])) {
1213
* Write a message to stderr so that '--json' flags or stdout which is meant
1214
* to be piped somewhere aren't disrupted.
1216
* @param string Message to write to stderr.
1219
final protected function writeStatusMessage($msg) {
1220
fwrite(STDERR, $msg);
1223
final protected function isHistoryImmutable() {
1224
$repository_api = $this->getRepositoryAPI();
1226
$config = $this->getConfigFromAnySource('history.immutable');
1227
if ($config !== null) {
1231
return $repository_api->isHistoryDefaultImmutable();
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.
1241
* This method takes the user's selections and returns the paths that the
1242
* workflow should act upon.
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.
1250
final protected function selectPathsForWorkflow(
1253
$omit_mask = null) {
1255
if ($omit_mask === null) {
1256
$omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED;
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!");
1266
$relative_path = Filesystem::readablePath(
1268
$working_copy->getProjectRoot());
1269
$paths[$key] = $relative_path;
1272
$repository_api = $this->getRepositoryAPI();
1275
$this->parseBaseCommitArgument(array($rev));
1278
$paths = $repository_api->getWorkingCopyStatus();
1279
foreach ($paths as $path => $flags) {
1280
if ($flags & $omit_mask) {
1281
unset($paths[$path]);
1284
$paths = array_keys($paths);
1287
return array_values($paths);
1290
final protected function renderRevisionList(array $revisions) {
1292
foreach ($revisions as $revision) {
1293
$list[] = ' - D'.$revision['id'].': '.$revision['title']."\n";
1295
return implode('', $list);
1299
/* -( Scratch Files )------------------------------------------------------ */
1303
* Try to read a scratch file, if it exists and is readable.
1305
* @param string Scratch file name.
1306
* @return mixed String for file contents, or false for failure.
1309
final protected function readScratchFile($path) {
1310
if (!$this->repositoryAPI) {
1313
return $this->getRepositoryAPI()->readScratchFile($path);
1318
* Try to read a scratch JSON file, if it exists and is readable.
1320
* @param string Scratch file name.
1321
* @return array Empty array for failure.
1324
final protected function readScratchJSONFile($path) {
1325
$file = $this->readScratchFile($path);
1329
return json_decode($file, true);
1334
* Try to write a scratch file, if there's somewhere to put it and we can
1337
* @param string Scratch file name to write.
1338
* @param string Data to write.
1339
* @return bool True on success, false on failure.
1342
final protected function writeScratchFile($path, $data) {
1343
if (!$this->repositoryAPI) {
1346
return $this->getRepositoryAPI()->writeScratchFile($path, $data);
1351
* Try to write a scratch JSON file, if there's somewhere to put it and we can
1354
* @param string Scratch file name to write.
1355
* @param array Data to write.
1356
* @return bool True on success, false on failure.
1359
final protected function writeScratchJSONFile($path, array $data) {
1360
return $this->writeScratchFile($path, json_encode($data));
1365
* Try to remove a scratch file.
1367
* @param string Scratch file name to remove.
1368
* @return bool True if the file was removed successfully.
1371
final protected function removeScratchFile($path) {
1372
if (!$this->repositoryAPI) {
1375
return $this->getRepositoryAPI()->removeScratchFile($path);
1380
* Get a human-readable description of the scratch file location.
1382
* @param string Scratch file name.
1383
* @return mixed String, or false on failure.
1386
final protected function getReadableScratchFilePath($path) {
1387
if (!$this->repositoryAPI) {
1390
return $this->getRepositoryAPI()->getReadableScratchFilePath($path);
1395
* Get the path to a scratch file, if possible.
1397
* @param string Scratch file name.
1398
* @return mixed File path, or false on failure.
1401
final protected function getScratchFilePath($path) {
1402
if (!$this->repositoryAPI) {
1405
return $this->getRepositoryAPI()->getScratchFilePath($path);
1408
final protected function getRepositoryEncoding() {
1410
return nonempty(idx($this->getProjectInfo(), 'encoding'), $default);
1413
final protected function getProjectInfo() {
1414
if ($this->projectInfo === null) {
1415
$project_id = $this->getWorkingCopy()->getProjectID();
1417
$this->projectInfo = array();
1420
$this->projectInfo = $this->getConduit()->callMethodSynchronous(
1421
'arcanist.projectinfo',
1423
'name' => $project_id,
1425
} catch (ConduitClientException $ex) {
1426
if ($ex->getErrorCode() != 'ERR-BAD-ARCANIST-PROJECT') {
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.
1440
return $this->projectInfo;
1443
final protected function loadProjectRepository() {
1444
$project = $this->getProjectInfo();
1445
if (isset($project['repository'])) {
1446
return $project['repository'];
1448
// NOTE: The rest of the code is here for backwards compatibility.
1450
$repository_phid = idx($project, 'repositoryPHID');
1451
if (!$repository_phid) {
1455
$repositories = $this->getConduit()->callMethodSynchronous(
1458
$repositories = ipull($repositories, null, 'phid');
1460
return idx($repositories, $repository_phid, array());
1463
final protected function newInteractiveEditor($text) {
1464
$editor = new PhutilInteractiveEditor($text);
1466
$preferred = $this->getConfigFromAnySource('editor');
1468
$editor->setPreferredEditor($preferred);
1474
final protected function newDiffParser() {
1475
$parser = new ArcanistDiffParser();
1476
if ($this->repositoryAPI) {
1477
$parser->setRepositoryAPI($this->getRepositoryAPI());
1479
$parser->setWriteDiffOnFailure(true);
1483
final protected function resolveCall(ConduitFuture $method, $timeout = null) {
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");
1498
final protected function dispatchEvent($type, array $data) {
1500
'workflow' => $this,
1503
$event = new PhutilEvent($type, $data);
1504
PhutilEventEngine::dispatchEvent($event);
1509
final public function parseBaseCommitArgument(array $argv) {
1510
if (!count($argv)) {
1514
$api = $this->getRepositoryAPI();
1515
if (!$api->supportsCommitRanges()) {
1516
throw new ArcanistUsageException(
1517
'This version control system does not support commit ranges.');
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.');
1526
$api->setBaseCommit(head($argv));
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)
1541
$this->repositoryVersion = md5(json_encode($versions));
1543
return $this->repositoryVersion;
1547
/* -( Phabricator Repositories )------------------------------------------- */
1551
* Get the PHID of the Phabricator repository this working copy corresponds
1552
* to. Returns `null` if no repository can be identified.
1554
* @return phid|null Repository PHID, or null if no repository can be
1559
final protected function getRepositoryPHID() {
1560
return idx($this->getRepositoryInformation(), 'phid');
1565
* Get the callsign of the Phabricator repository this working copy
1566
* corresponds to. Returns `null` if no repository can be identified.
1568
* @return string|null Repository callsign, or null if no repository can be
1573
final protected function getRepositoryCallsign() {
1574
return idx($this->getRepositoryInformation(), 'callsign');
1579
* Get the URI of the Phabricator repository this working copy
1580
* corresponds to. Returns `null` if no repository can be identified.
1582
* @return string|null Repository URI, or null if no repository can be
1587
final protected function getRepositoryURI() {
1588
return idx($this->getRepositoryInformation(), 'uri');
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.
1597
* @return list<string> Human-readable explanation of the repository
1598
* association process.
1602
final protected function getRepositoryReasons() {
1603
$this->getRepositoryInformation();
1604
return $this->repositoryReasons;
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;
1618
return $this->repositoryInfo;
1625
private function loadRepositoryInformation() {
1626
list($query, $reasons) = $this->getRepositoryQuery();
1628
return array(null, $reasons);
1632
$results = $this->getConduit()->callMethodSynchronous(
1635
} catch (ConduitClientException $ex) {
1636
if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') {
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 '.
1643
return array(null, $reasons);
1651
'No repositories matched the query. Check that your configuration '.
1652
'is correct, or use "repository.callsign" to select a repository '.
1654
} else if (count($results) > 1) {
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')));
1660
$result = head($results);
1661
$reasons[] = pht('Found a unique matching repository.');
1664
return array($result, $reasons);
1671
private function getRepositoryQuery() {
1674
$callsign = $this->getConfigFromAnySource('repository.callsign');
1677
'callsigns' => array($callsign),
1680
'Configuration value "repository.callsign" is set to "%s".',
1682
return array($query, $reasons);
1685
'Configuration value "repository.callsign" is empty.');
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'];
1694
'callsigns' => array($callsign),
1697
'Configuration value "project.name" is set to "%s"; this project '.
1698
'is associated with the "%s" repository.',
1701
return array($query, $reasons);
1704
'Configuration value "project.name" is set to "%s", but this '.
1705
'project is not associated with a repository.',
1708
} else if (strlen($project_name)) {
1710
'Configuration value "project.name" is set to "%s", but that '.
1711
'project does not exist.',
1715
'Configuration value "project.name" is empty.');
1718
$uuid = $this->getRepositoryAPI()->getRepositoryUUID();
1719
if ($uuid !== null) {
1721
'uuids' => array($uuid),
1724
'The UUID for this working copy is "%s".',
1726
return array($query, $reasons);
1729
'This repository has no VCS UUID (this is normal for git/hg).');
1732
$remote_uri = $this->getRepositoryAPI()->getRemoteURI();
1733
if ($remote_uri !== null) {
1735
'remoteURIs' => array($remote_uri),
1738
'The remote URI for this working copy is "%s".',
1740
return array($query, $reasons);
1743
'Unable to determine the remote URI for this repository.');
1746
return array(null, $reasons);
1751
* Build a new lint engine for the current working copy.
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.
1757
* @param string Optional explicit engine class name.
1758
* @return ArcanistLintEngine Constructed engine.
1760
protected function newLintEngine($engine_class = null) {
1761
$working_copy = $this->getWorkingCopy();
1762
$config = $this->getConfigurationManager();
1764
if (!$engine_class) {
1765
$engine_class = $config->getConfigFromAnySource('lint.engine');
1768
if (!$engine_class) {
1769
if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) {
1770
$engine_class = 'ArcanistConfigurationDrivenLintEngine';
1774
if (!$engine_class) {
1775
throw new ArcanistNoEngineException(
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'."));
1782
$base_class = 'ArcanistLintEngine';
1783
if (!class_exists($engine_class) ||
1784
!is_subclass_of($engine_class, $base_class)) {
1785
throw new ArcanistUsageException(
1787
'Configured lint engine "%s" is not a subclass of "%s", but must '.
1793
$engine = newv($engine_class, array())
1794
->setWorkingCopy($working_copy)
1795
->setConfigurationManager($config);
1800
protected function openURIsInBrowser(array $uris) {
1801
$browser = $this->getBrowserCommand();
1802
foreach ($uris as $uri) {
1803
$err = phutil_passthru('%s %s', $browser, $uri);
1805
throw new ArcanistUsageException(
1807
"Failed to open '%s' in browser ('%s'). ".
1808
"Check your 'browser' config option.",
1815
private function getBrowserCommand() {
1816
$config = $this->getConfigFromAnySource('browser');
1821
if (phutil_is_windows()) {
1825
$candidates = array('sensible-browser', 'xdg-open', 'open');
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"
1832
foreach ($candidates as $cmd) {
1833
if (Filesystem::binaryExists($cmd)) {
1838
throw new ArcanistUsageException(
1840
"Unable to find a browser command to run. Set 'browser' in your ".
1841
"Arcanist config to specify a command to use."));
1846
* Ask Phabricator to update the current repository as soon as possible.
1848
* Calling this method after pushing commits allows Phabricator to discover
1849
* the commits more quickly, so the system overall is more responsive.
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()) {
1859
$this->getConduit()->callMethodSynchronous(
1860
'diffusion.looksoon',
1862
'callsigns' => array($this->getRepositoryCallsign()),
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