1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-12-01 19:22:41 +01:00

[Wilds] Continue toward a generalized "arc alias" workflow

Summary:
Ref T13098. This leaves a lot of rough edges but nothing is overtly broken so here's where we're at so far:

Config sources get "scopes", like user configuration vs system configuration. The major reason for this is so that `arc set-config x y` can know where it's supposed to write. This is generalized enough that we can implement `arc set-config --system ...` and `arc alias --local ...` and so on relatively easily later, although scopes themselves are not modular (a third-party can't add a new type of config scope). Maybe we'll modularize this some day but it felt like that's probably YAGNI/overboard since we have no current use cases. For now, a source does not need to belong to any particular scope.

Config may be writable (like user config in `~/.arcrc`) or nonwritable (like `--config` flags). Writable config can now specify how to write to disk. Config files can actually write to disk now, although the only pathway for doing this that exists is via `arc alias`.

Aliases now parse properly and can write to disk. `arc alias` now lets you define aliases, and writes them to disk. **The first time you do this, your `~/.arcrc` file will be rewritten into a format which old `arc` can not read!** It's relatively easily to unmangle/repair these files so I'm planning to just let this happen.

When a toolset is invoked, it now reads and evaluates aliases. Aliases have a lot of new guard rails like suggesting the user try `arc draft` if they type `phage draft`, allowing alias chains, detecting cycles, and limiting chain length.

Workflows can provide help and argument lists in a more structured way. I've moved this to sub-objects: help is now on `WorkflowInformation` (instead of a bunch of different `getHelp()`, `getSynopsis()` methods) and arguments now have a `WorkflowArgument` object instead of a dictionary. I think this pattern is generally better for extending: it lets us add and change stuff with less impact (and greater explicitness) down the road.

`arc alias` now has reasonable help text and argument documentation. The `arc alias` (list) and `arc alias x` (details/remove) flows don't work yet but `arc alias x y` does.

`arc liberate` now uses the new help/argument stuff, although the help needs more beef eventually. I pruned a bunch of long-obsolete or questionable flags and renamed `--all` to `--clean` since `--all` sounds like "liberate all libraries", which is now the default behavior of `arc liberate`.

Test Plan:
You can now define chains of aliases. Finally!

```
$ arc draft4
 WARNING  Ignoring unrecognized configuration option ("hosts") from source: User Config File (/Users/epriestley/.arcrc).
 WARNING  Ignoring unrecognized configuration option ("load") from source: Project Config File (/Users/epriestley/dev/core/.arcconfig).
 ALIAS  arc draft4 -> arc draft3
 ALIAS  arc draft3 -> arc draft2
 ALIAS  arc draft2 -> arc diff
Usage Exception: Unrecognized argument '--draft'.
```

This also works now:

```
$ phage alias deploy-secure -- deploy --hosts secure001-4 --limit 1
```

General!

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13098

Differential Revision: https://secure.phabricator.com/D19697
This commit is contained in:
epriestley 2018-09-14 13:45:26 -07:00
parent 5ef6599239
commit 5e70719306
20 changed files with 1029 additions and 355 deletions

View file

@ -49,9 +49,13 @@ phutil_register_library_map(array(
'ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase.php',
'ArcanistAbstractPrivateMethodXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAbstractPrivateMethodXHPASTLinterRule.php',
'ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase.php',
'ArcanistAlias' => 'toolset/ArcanistAlias.php',
'ArcanistAliasEffect' => 'toolset/ArcanistAliasEffect.php',
'ArcanistAliasEngine' => 'toolset/ArcanistAliasEngine.php',
'ArcanistAliasFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAliasFunctionXHPASTLinterRule.php',
'ArcanistAliasFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAliasFunctionXHPASTLinterRuleTestCase.php',
'ArcanistAliasWorkflow' => 'toolset/workflow/ArcanistAliasWorkflow.php',
'ArcanistAliasesConfigOption' => 'config/option/ArcanistAliasesConfigOption.php',
'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php',
'ArcanistAnoidWorkflow' => 'workflow/ArcanistAnoidWorkflow.php',
'ArcanistArcConfigurationEngineExtension' => 'config/arc/ArcanistArcConfigurationEngineExtension.php',
@ -303,6 +307,7 @@ phutil_register_library_map(array(
'ArcanistLintersWorkflow' => 'workflow/ArcanistLintersWorkflow.php',
'ArcanistListAssignmentXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistListAssignmentXHPASTLinterRule.php',
'ArcanistListAssignmentXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistListAssignmentXHPASTLinterRuleTestCase.php',
'ArcanistListConfigOption' => 'config/option/ArcanistListConfigOption.php',
'ArcanistListWorkflow' => 'workflow/ArcanistListWorkflow.php',
'ArcanistLocalConfigurationSource' => 'config/source/ArcanistLocalConfigurationSource.php',
'ArcanistLogicalOperatorsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLogicalOperatorsXHPASTLinterRule.php',
@ -491,6 +496,8 @@ phutil_register_library_map(array(
'ArcanistWhichWorkflow' => 'workflow/ArcanistWhichWorkflow.php',
'ArcanistWildConfigOption' => 'config/option/ArcanistWildConfigOption.php',
'ArcanistWorkflow' => 'toolset/ArcanistWorkflow.php',
'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php',
'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php',
'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php',
'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php',
'ArcanistWorkingCopyStateRef' => 'ref/ArcanistWorkingCopyStateRef.php',
@ -1145,9 +1152,13 @@ phutil_register_library_map(array(
'ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistAbstractPrivateMethodXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistAlias' => 'Phobject',
'ArcanistAliasEffect' => 'Phobject',
'ArcanistAliasEngine' => 'Phobject',
'ArcanistAliasFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistAliasFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistAliasWorkflow' => 'ArcanistWorkflow',
'ArcanistAliasesConfigOption' => 'ArcanistListConfigOption',
'ArcanistAmendWorkflow' => 'ArcanistWorkflow',
'ArcanistAnoidWorkflow' => 'ArcanistWorkflow',
'ArcanistArcConfigurationEngineExtension' => 'ArcanistConfigurationEngineExtension',
@ -1399,6 +1410,7 @@ phutil_register_library_map(array(
'ArcanistLintersWorkflow' => 'ArcanistWorkflow',
'ArcanistListAssignmentXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
'ArcanistListAssignmentXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase',
'ArcanistListConfigOption' => 'ArcanistConfigOption',
'ArcanistListWorkflow' => 'ArcanistWorkflow',
'ArcanistLocalConfigurationSource' => 'ArcanistWorkingCopyConfigurationSource',
'ArcanistLogicalOperatorsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule',
@ -1587,6 +1599,8 @@ phutil_register_library_map(array(
'ArcanistWhichWorkflow' => 'ArcanistWorkflow',
'ArcanistWildConfigOption' => 'ArcanistConfigOption',
'ArcanistWorkflow' => 'Phobject',
'ArcanistWorkflowArgument' => 'Phobject',
'ArcanistWorkflowInformation' => 'Phobject',
'ArcanistWorkingCopy' => 'Phobject',
'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource',
'ArcanistWorkingCopyStateRef' => 'ArcanistRef',

View file

@ -15,16 +15,80 @@ final class ArcanistConfigurationSourceList
return $this->sources;
}
private function getSourcesWithScopes($scopes) {
if ($scopes !== null) {
$scopes = array_fuse($scopes);
}
$results = array();
foreach ($this->getSources() as $source) {
if ($scopes !== null) {
$scope = $source->getConfigurationSourceScope();
if ($scope === null) {
continue;
}
if (!isset($scopes[$scope])) {
continue;
}
}
$results[] = $source;
}
return $results;
}
public function getWritableSourceFromScope($scope) {
$sources = $this->getSourcesWithScopes(array($scope));
$writable = array();
foreach ($sources as $source) {
if (!$source->isWritableConfigurationSource()) {
continue;
}
$writable[] = $source;
}
if (!$writable) {
throw new Exception(
pht(
'Unable to write configuration: there is no writable configuration '.
'source in the "%s" scope.',
$scope));
}
if (count($writable) > 1) {
throw new Exception(
pht(
'Unable to write configuration: more than one writable source '.
'exists in the "%s" scope.',
$scope));
}
return head($writable);
}
public function getConfig($key) {
$option = $this->getConfigOption($key);
$values = $this->getStorageValueList($key);
return $option->getValueFromStorageValueList($values);
}
public function getConfigFromScopes($key, array $scopes) {
$option = $this->getConfigOption($key);
$values = $this->getStorageValueListFromScopes($key, $scopes);
return $option->getValueFromStorageValueList($values);
}
public function getStorageValueList($key) {
return $this->getStorageValueListFromScopes($key, null);
}
private function getStorageValueListFromScopes($key, $scopes) {
$values = array();
foreach ($this->getSources() as $source) {
foreach ($this->getSourcesWithScopes($scopes) as $source) {
if ($source->hasValueForKey($key)) {
$value = $source->getValueForKey($key);
$values[] = new ArcanistConfigurationSourceValue(
@ -113,7 +177,13 @@ final class ArcanistConfigurationSourceList
$source,
$raw_value);
} catch (Exception $ex) {
throw $ex;
throw new PhutilProxyException(
pht(
'Configuration value ("%s") defined in source "%s" is not '.
'valid.',
$key,
$source->getSourceDisplayName()),
$ex);
}
}
}

View file

@ -5,6 +5,8 @@ final class ArcanistArcConfigurationEngineExtension
const EXTENSIONKEY = 'arc';
const KEY_ALIASES = 'aliases';
public function newConfigurationOptions() {
// TOOLSETS: Restore "load", and maybe this other stuff.
@ -49,12 +51,6 @@ final class ArcanistArcConfigurationEngineExtension
'example' => 'false',
),
'aliases' => array(
'type' => 'aliases',
'help' => pht(
'Configured command aliases. Use "arc alias" to define aliases.'),
),
'history.immutable' => array(
'type' => 'bool',
'legacy' => 'immutable_history',
@ -160,6 +156,14 @@ final class ArcanistArcConfigurationEngineExtension
array(
'https://phabricator.mycompany.com/',
)),
id(new ArcanistAliasesConfigOption())
->setKey(self::KEY_ALIASES)
->setDefaultValue(array())
->setSummary(pht('List of command aliases.'))
->setHelp(
pht(
'Configured command aliases. Use the "alias" workflow to define '.
'aliases.')),
);
}

View file

@ -0,0 +1,36 @@
<?php
final class ArcanistAliasesConfigOption
extends ArcanistListConfigOption {
public function getType() {
return 'list<alias>';
}
public function getValueFromStorageValue($value) {
if (!is_array($value)) {
throw new Exception(pht('Expected a list or dictionary!'));
}
$aliases = array();
foreach ($value as $key => $spec) {
$aliases[] = ArcanistAlias::newFromConfig($key, $spec);
}
return $aliases;
}
protected function didReadStorageValueList(array $list) {
assert_instances_of($list, 'ArcanistConfigurationSourceValue');
return mpull($list, 'getValue');
}
public function getDisplayValueFromValue($value) {
return pht('Use the "alias" workflow to review aliases.');
}
public function getStorageValueFromValue($value) {
return mpull($value, 'getStorageDictionary');
}
}

View file

@ -67,9 +67,17 @@ abstract class ArcanistConfigOption
abstract public function getType();
abstract public function getValueFromStorageValueList(array $list);
abstract public function getStorageValueFromStringValue($value);
abstract public function getValueFromStorageValue($value);
abstract public function getDisplayValueFromValue($value);
abstract public function getStorageValueFromValue($value);
public function getStorageValueFromStringValue($value) {
throw new Exception(
pht(
'This configuration option ("%s") does not support runtime definition '.
'with "--config".',
$this->getKey()));
}
protected function getStorageValueFromSourceValue(
ArcanistConfigurationSourceValue $source_value) {
@ -84,5 +92,9 @@ abstract class ArcanistConfigOption
return $value;
}
public function writeValue(ArcanistConfigurationSource $source, $value) {
$value = $this->getStorageValueFromValue($value);
$source->setStorageValueForKey($this->getKey(), $value);
}
}

View file

@ -0,0 +1,32 @@
<?php
abstract class ArcanistListConfigOption
extends ArcanistConfigOption {
public function getValueFromStorageValueList(array $list) {
assert_instances_of($list, 'ArcanistConfigurationSourceValue');
$result_list = array();
foreach ($list as $source_value) {
$source = $source_value->getConfigurationSource();
$storage_value = $this->getStorageValueFromSourceValue($source_value);
$items = $this->getValueFromStorageValue($storage_value);
foreach ($items as $item) {
$result_list[] = new ArcanistConfigurationSourceValue(
$source,
$item);
}
}
$result_list = $this->didReadStorageValueList($result_list);
return $result_list;
}
protected function didReadStorageValueList(array $list) {
assert_instances_of($list, 'ArcanistConfigurationSourceValue');
return mpull($list, 'getValue');
}
}

View file

@ -15,4 +15,8 @@ final class ArcanistStringConfigOption
return $value;
}
public function getStorageValueFromValue($value) {
return $value;
}
}

View file

@ -3,15 +3,25 @@
abstract class ArcanistConfigurationSource
extends Phobject {
const SCOPE_USER = 'user';
abstract public function getSourceDisplayName();
abstract public function getAllKeys();
abstract public function hasValueForKey($key);
abstract public function getValueForKey($key);
public function getConfigurationSourceScope() {
return null;
}
public function isStringSource() {
return false;
}
public function isWritableConfigurationSource() {
return false;
}
public function didReadUnknownOption($key) {
// TOOLSETS: Standardize this kind of messaging? On ArcanistRuntime?

View file

@ -29,4 +29,16 @@ abstract class ArcanistDictionaryConfigurationSource
return $this->values[$key];
}
public function setStorageValueForKey($key, $value) {
$this->values[$key] = $value;
$this->writeToStorage($this->values);
return $this;
}
protected function writeToStorage($values) {
throw new PhutilMethodNotImplementedException();
}
}

View file

@ -35,4 +35,11 @@ abstract class ArcanistFilesystemConfigurationSource
return $values;
}
protected function writeToStorage($values) {
$content = id(new PhutilJSON())
->encodeFormatted($values);
Filesystem::writeFile($this->path, $content);
}
}

View file

@ -7,6 +7,14 @@ final class ArcanistUserConfigurationSource
return pht('User Config File');
}
public function isWritableConfigurationSource() {
return true;
}
public function getConfigurationSourceScope() {
return ArcanistConfigurationSource::SCOPE_USER;
}
public function didReadFilesystemValues(array $values) {
// Before toolsets, the "~/.arcrc" file had separate top-level keys for
// "config", "hosts", and "aliases". Transform this older file format into

View file

@ -0,0 +1,145 @@
<?php
final class ArcanistAlias extends Phobject {
private $toolset;
private $trigger;
private $command;
private $exception;
private $configurationSource;
public static function newFromConfig($key, $value) {
$alias = new self();
// Parse older style aliases which were always for the "arc" toolset.
// When we next write these back into the config file, we'll update them
// to the modern format.
// The old format looked like this:
//
// {
// "draft": ["diff", "--draft"]
// }
//
// The new format looks like this:
//
// {
// [
// "toolset": "arc",
// "trigger": "draft",
// "command": ["diff", "--draft"]
// ]
// }
//
// For now, we parse the older format and fill in the toolset as "arc".
$is_list = false;
$is_dict = false;
if ($value && is_array($value)) {
if (array_keys($value) === range(0, count($value) - 1)) {
$is_list = true;
} else {
$is_dict = true;
}
}
if ($is_list) {
$alias->trigger = $key;
$alias->toolset = 'arc';
$alias->command = $value;
} else if ($is_dict) {
try {
PhutilTypeSpec::checkMap(
$value,
array(
'trigger' => 'string',
'toolset' => 'string',
'command' => 'list<string>',
));
$alias->trigger = idx($value, 'trigger');
$alias->toolset = idx($value, 'toolset');
$alias->command = idx($value, 'command');
} catch (PhutilTypeCheckException $ex) {
$alias->exception = new PhutilProxyException(
pht(
'Found invalid alias definition (with key "%s").',
$key),
$ex);
}
} else {
$alias->exception = new Exception(
pht(
'Expected alias definition (with key "%s") to be a dictionary.',
$key));
}
return $alias;
}
public function setToolset($toolset) {
$this->toolset = $toolset;
return $this;
}
public function getToolset() {
return $this->toolset;
}
public function setTrigger($trigger) {
$this->trigger = $trigger;
return $this;
}
public function getTrigger() {
return $this->trigger;
}
public function setCommand(array $command) {
$this->command = $command;
return $this;
}
public function getCommand() {
return $this->command;
}
public function setException(Exception $exception) {
$this->exception = $exception;
return $this;
}
public function getException() {
return $this->exception;
}
public function isShellCommandAlias() {
$command = $this->getCommand();
if (!$command) {
return false;
}
$head = head($command);
return preg_match('/^!/', $head);
}
public function getStorageDictionary() {
return array(
'trigger' => $this->getTrigger(),
'toolset' => $this->getToolset(),
'command' => $this->getCommand(),
);
}
public function setConfigurationSource(
ArcanistConfigurationSource $configuration_source) {
$this->configurationSource = $configuration_source;
return $this;
}
public function getConfigurationSource() {
return $this->configurationSource;
}
}

View file

@ -0,0 +1,57 @@
<?php
final class ArcanistAliasEffect
extends Phobject {
private $type;
private $command;
private $arguments;
private $message;
const EFFECT_MISCONFIGURATION = 'misconfiguration';
const EFFECT_SHELL = 'shell';
const EFFECT_RESOLUTION = 'resolution';
const EFFECT_SUGGEST = 'suggest';
const EFFECT_OVERRIDDE = 'override';
const EFFECT_ALIAS = 'alias';
const EFFECT_NOTFOUND = 'not-found';
const EFFECT_CYCLE = 'cycle';
const EFFECT_STACK = 'stack';
public function setType($type) {
$this->type = $type;
return $this;
}
public function getType() {
return $this->type;
}
public function setCommand($command) {
$this->command = $command;
return $this;
}
public function getCommand() {
return $this->command;
}
public function setArguments(array $arguments) {
$this->arguments = $arguments;
return $this;
}
public function getArguments() {
return $this->arguments;
}
public function setMessage($message) {
$this->message = $message;
return $this;
}
public function getMessage() {
return $this->message;
}
}

View file

@ -0,0 +1,255 @@
<?php
final class ArcanistAliasEngine
extends Phobject {
private $runtime;
private $toolset;
private $workflows;
private $configurationSourceList;
public function setRuntime(ArcanistRuntime $runtime) {
$this->runtime = $runtime;
return $this;
}
public function getRuntime() {
return $this->runtime;
}
public function setToolset(ArcanistToolset $toolset) {
$this->toolset = $toolset;
return $this;
}
public function getToolset() {
return $this->toolset;
}
public function setWorkflows(array $workflows) {
assert_instances_of($workflows, 'ArcanistWorkflow');
$this->workflows = $workflows;
return $this;
}
public function getWorkflows() {
return $this->workflows;
}
public function setConfigurationSourceList(
ArcanistConfigurationSourceList $config) {
$this->configurationSourceList = $config;
return $this;
}
public function getConfigurationSourceList() {
return $this->configurationSourceList;
}
public function resolveAliases(array $argv) {
$aliases_key = ArcanistArcConfigurationEngineExtension::KEY_ALIASES;
$source_list = $this->getConfigurationSourceList();
$aliases = $source_list->getConfig($aliases_key);
$results = array();
// Identify aliases which had some kind of format or specification issue
// when loading config. We could possibly do this earlier, but it's nice
// to handle all the alias stuff in one place.
foreach ($aliases as $key => $alias) {
$exception = $alias->getException();
if (!$exception) {
continue;
}
// This alias is not defined properly, so we're going to ignore it.
unset($aliases[$key]);
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_CONFIGURATION)
->setMessage(
pht(
'Configuration source ("%s") defines an invalid alias, which '.
'will be ignored: %s',
$alias->getConfigurationSource()->getSourceDisplayName()),
$exception->getMessage());
}
$command = array_shift($argv);
$stack = array();
return $this->resolveAliasesForCommand(
$aliases,
$command,
$argv,
$results,
$stack);
}
private function resolveAliasesForCommand(
array $aliases,
$command,
array $argv,
array $results,
array $stack) {
$toolset = $this->getToolset();
$toolset_key = $toolset->getToolsetKey();
// If we have a command which resolves to a real workflow, match it and
// finish resolution. You can not overwrite a real workflow with an alias.
$workflows = $this->getWorkflows();
if (isset($workflows[$command])) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_RESOLUTION)
->setCommand($command)
->setArguments($argv);
return $results;
}
// Find all the aliases which match whatever the user typed, like "draft".
// We look for aliases in other toolsets, too, so we can provide the user
// a hint when they type "phage draft" and mean "arc draft".
$matches = array();
$toolset_matches = array();
foreach ($aliases as $alias) {
if ($alias->getTrigger() === $command) {
$matches[] = $alias;
if ($alias->getToolset() == $toolset_key) {
$toolset_matches[] = $alias;
}
}
}
if (!$toolset_matches) {
// If the user typed "phage draft" and meant "arc draft", give them a
// hint that the alias exists somewhere else and they may have specified
// the wrong toolset.
foreach ($matches as $match) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_SUGGEST)
->setMessage(
pht(
'No "%s %s" alias is defined, did you mean "%s %s"?',
$toolset_key,
$command,
$match->getToolset(),
$command));
}
// If the user misspells a command (like "arc hlep") and it doesn't match
// anything (no alias or workflow), we want to pass it through unmodified
// and let the parser try to correct the spelling into a real workflow
// later on.
// However, if the user correctly types a command (like "arc draft") that
// resolves at least once (so it hits a valid alias) but does not
// ultimately resolve into a valid workflow, we want to treat this as a
// hard failure.
// This could happen if you manually defined a bad alias, or a workflow
// you'd previously aliased to was removed, or you stacked aliases and
// then deleted one.
if ($stack) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_NOTFOUND)
->setMessage(
pht(
'Alias resolved to "%s", but this is not a valid workflow or '.
'alias name. This alias or workflow might have previously '.
'existed and been removed.',
$command));
} else {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_RESOLUTION)
->setCommand($command)
->setArguments($argv);
}
return $results;
}
$alias = array_pop($toolset_matches);
foreach ($toolset_matches as $ignored_match) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_IGNORED)
->setMessage(
pht(
'Multiple configuration sources define an alias for "%s %s". '.
'The definition in "%s" will be ignored.',
$toolset_key,
$command,
$ignored_match->getConfigurationSource()->getSourceDisplayName()));
}
if ($alias->isShellCommandAlias()) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_SHELL)
->setMessage(
pht(
'%s %s -> $ %s',
$toolset_key,
$command,
$alias->getShellCommand()))
->setCommand($command)
->setArgv($argv);
return $results;
}
$alias_argv = $alias->getCommand();
$alias_command = array_shift($alias_argv);
if (isset($stack[$alias_command])) {
$cycle = array_keys($stack);
$cycle[] = $alias_command;
$cycle = implode(' -> ', $cycle);
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_CYCLE)
->setMessage(
pht(
'Alias definitions form a cycle which can not be resolved: %s.',
$cycle));
return $results;
}
$stack[$alias_command] = true;
$stack_limit = 16;
if (count($stack) >= $stack_limit) {
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_STACK)
->setMessage(
pht(
'Alias definitions form an unreasonably deep stack. A chain of '.
'aliases may not resolve more than %s times.',
new PhutilNumber($stack_limit)));
return $results;
}
$results[] = $this->newEffect(ArcanistAliasEffect::EFFECT_ALIAS)
->setMessage(
pht(
'%s %s -> %s %s',
$toolset_key,
$command,
$toolset_key,
$alias_command));
$argv = array_merge($alias_argv, $argv);
return $this->resolveAliasesForCommand(
$aliases,
$alias_command,
$argv,
$results,
$stack);
}
protected function newEffect($effect_type) {
return id(new ArcanistAliasEffect())
->setType($effect_type);
}
}

View file

@ -2,6 +2,7 @@
abstract class ArcanistWorkflow extends Phobject {
private $runtime;
private $toolset;
private $arguments;
private $configurationEngine;
@ -37,10 +38,48 @@ abstract class ArcanistWorkflow extends Phobject {
return true;
}
protected function getWorkflowArguments() {
// TOOLSETS: Temporary!
return array();
}
protected function getWorkflowInformation() {
// TOOLSETS: Temporary!
return null;
}
public function newPhutilWorkflow() {
return id(new ArcanistPhutilWorkflow())
$arguments = $this->getWorkflowArguments();
assert_instances_of($arguments, 'ArcanistWorkflowArgument');
$specs = mpull($arguments, 'getPhutilSpecification');
$phutil_workflow = id(new ArcanistPhutilWorkflow())
->setName($this->getWorkflowName())
->setWorkflow($this);
->setWorkflow($this)
->setArguments($specs);
$information = $this->getWorkflowInformation();
if ($information) {
$examples = $information->getExamples();
if ($examples) {
$examples = implode("\n", $examples);
$phutil_workflow->setExamples($examples);
}
$help = $information->getHelp();
if (strlen($help)) {
// Unwrap linebreaks in the help text so we don't get weird formatting.
$help = preg_replace("/(?<=\S)\n(?=\S)/", " ", $help);
$phutil_workflow->setHelp($help);
}
}
return $phutil_workflow;
}
final public function getToolset() {
@ -52,6 +91,19 @@ abstract class ArcanistWorkflow extends Phobject {
return $this;
}
final public function setRuntime(ArcanistRuntime $runtime) {
$this->runtime = $runtime;
return $this;
}
final public function getRuntime() {
return $this->runtime;
}
final public function getConfig($key) {
return $this->getConfigurationSourceList()->getConfig($key);
}
final public function setConfigurationSourceList(
ArcanistConfigurationSourceList $config) {
$this->configurationSourceList = $config;
@ -99,11 +151,17 @@ abstract class ArcanistWorkflow extends Phobject {
return $err;
}
final public function getArgument($key, $default = null) {
// TOOLSETS: This is a stub for now.
return $default;
final public function getArgument($key) {
return $this->arguments->getArg($key);
}
return $this->arguments->getArg($key, $default);
final protected function newWorkflowArgument($key) {
return id(new ArcanistWorkflowArgument())
->setKey($key);
}
final protected function newWorkflowInformation() {
return new ArcanistWorkflowInformation();
}
}

View file

@ -0,0 +1,50 @@
<?php
final class ArcanistWorkflowArgument
extends Phobject {
private $key;
private $help;
private $wildcard;
public function setKey($key) {
$this->key = $key;
return $this;
}
public function getKey() {
return $this->key;
}
public function setWildcard($wildcard) {
$this->wildcard = $wildcard;
return $this;
}
public function getWildcard() {
return $this->wildcard;
}
public function getPhutilSpecification() {
$spec = array(
'name' => $this->getKey(),
);
if ($this->getWildcard()) {
$spec['wildcard'] = true;
}
return $spec;
}
public function setHelp($help) {
$this->help = $help;
return $this;
}
public function getHelp() {
return $this->help;
}
}

View file

@ -0,0 +1,28 @@
<?php
final class ArcanistWorkflowInformation
extends Phobject {
private $help;
private $examples = array();
public function setHelp($help) {
$this->help = $help;
return $this;
}
public function getHelp() {
return $this->help;
}
public function addExample($example) {
$this->examples[] = $example;
return $this;
}
public function getExamples() {
return $this->examples;
}
}

View file

@ -13,232 +13,186 @@ final class ArcanistAliasWorkflow extends ArcanistWorkflow {
return true;
}
public function getWorkflowSynopses() {
return array(
pht('**alias**'),
pht('**alias** __command__'),
pht('**alias** __command__ __target__ -- [__options__]'),
);
}
public function getWorkflowHelp() {
return pht(<<<EOTEXT
Supports: cli
public function getWorkflowInformation() {
$help = pht(<<<EOTEXT
Create an alias from __command__ to __target__ (optionally, with __options__).
For example:
%s alias fpatch patch -- --force
Aliases allow you to create shorthands for commands and sets of flags you
commonly use, like defining "arc draft" as a shorthand for "arc diff --draft".
...will create a new 'arc' command, 'arc fpatch', which invokes
'arc patch --force ...' when run. NOTE: use "--" before specifying
options!
**Creating Aliases**
If you start an alias with "!", the remainder of the alias will be
invoked as a shell command. For example, if you want to implement
'arc ls', you can do so like this:
You can define "arc draft" as a shorthand for "arc diff --draft" like this:
%s alias ls '!ls'
$ arc alias draft diff -- --draft
You can now run "arc ls" and it will behave like "ls". Of course, this
example is silly and would make your life worse.
Now, when you run "arc draft", the command will function like
"arc diff --draft".
You can not overwrite builtins, including 'alias' itself. The builtin
will always execute, even if it was added after your alias.
<bg:yellow> NOTE: </bg> Make sure you use "--" before specifying any flags you
want to pass to the command! Otherwise, the flags will be interpreted as flags
to "arc alias".
**Listing Aliases**
Without any arguments, "arc alias" will list aliases.
**Removing Aliases**
To remove an alias, run:
arc alias fpatch
$ arc alias <alias-name>
Without any arguments, 'arc alias' will list aliases.
You will be prompted to remove the alias.
**Shell Commands**
If you begin an alias with "!", the remainder of the alias will be invoked as
a shell command. For example, if you want to implement "arc ls", you can do so
like this:
$ arc alias ls '!ls'
When run, "arc ls" will now behave like "ls".
**Multiple Toolsets**
This workflow supports any toolset, even though the examples in this help text
use "arc". If you are working with another toolset, use the binary for that
toolset define aliases for it:
$ phage alias ...
Aliases are bound to the toolset which was used to define them. If you define
an "arc draft" alias, that does not also define a "phage draft" alias.
**Builtins**
You can not overwrite the behavior of builtin workflows, including "alias"
itself, and if you install a new workflow it will take precedence over any
existing aliases with the same name.
EOTEXT
,
$this->getToolsetName());
);
return $this->newWorkflowInformation()
->addExample(pht('**alias**'))
->addExample(pht('**alias** __command__'))
->addExample(pht('**alias** __command__ __target__ -- [__options__]'))
->setHelp($help);
}
public function getArguments() {
public function getWorkflowArguments() {
return array(
'*' => 'argv',
$this->newWorkflowArgument('json')
->setHelp(pht('Output aliases in JSON format.')),
$this->newWorkflowArgument('argv')
->setWildcard(true),
);
}
public static function getAliases(
ArcanistConfigurationManager $configuration_manager) {
$sources = $configuration_manager->getConfigFromAllSources('aliases');
$aliases = array();
foreach ($sources as $source) {
$aliases += $source;
}
return $aliases;
}
private function writeAliases(array $aliases) {
$config = $this->getConfigurationManager()->readUserConfigurationFile();
$config['aliases'] = $aliases;
$this->getConfigurationManager()->writeUserConfigurationFile($config);
}
public function runWorkflow() {
$aliases = self::getAliases($this->getConfigurationManager());
$argv = $this->getArgument('argv');
if (count($argv) == 0) {
$this->printAliases($aliases);
} else if (count($argv) == 1) {
$this->removeAlias($aliases, $argv[0]);
} else {
$arc_config = $this->getArcanistConfiguration();
$alias = $argv[0];
if ($arc_config->buildWorkflow($alias)) {
throw new ArcanistUsageException(
pht(
'You can not create an alias for "%s" because it is a '.
'builtin command. "%s" can only create new commands.',
"arc {$alias}",
'arc alias'));
$is_list = false;
$is_delete = false;
if (!$argv) {
$is_list = true;
} else if (count($argv) === 1) {
$is_delete = true;
}
$new_alias = array_slice($argv, 1);
$command = implode(' ', $new_alias);
if (self::isShellCommandAlias($command)) {
echo tsprintf(
"%s\n",
$is_json = $this->getArgument('json');
if ($is_json && !$is_list) {
throw new PhutilArgumentUsageException(
pht(
'Aliased "%s" to shell command "%s".',
"arc {$alias}",
substr($command, 1)));
} else {
echo tsprintf(
"%s\n",
pht(
'Aliased "%s" to "%s".',
"arc {$alias}",
"arc {$command}"));
'The "--json" argument may only be used when listing aliases.'));
}
$aliases[$alias] = $new_alias;
if ($is_list) {
return $this->runListAliases();
}
if ($is_delete) {
return $this->runDeleteAlias($argv[1]);
}
return $this->runCreateAlias($argv);
}
private function runListAliases() {
// TOOLSETS: Actually list aliases.
return 1;
}
private function runDeleteAlias($alias) {
// TOOLSETS: Actually delete aliases.
return 1;
}
private function runCreateAlias(array $argv) {
$trigger = array_shift($argv);
$this->validateAliasTrigger($trigger);
$alias = id(new ArcanistAlias())
->setToolset($this->getToolsetKey())
->setTrigger($trigger)
->setCommand($argv);
$aliases = $this->readAliasesForWrite();
// TOOLSETS: Check if the user already has an alias for this trigger, and
// prompt them to overwrite it. Needs prompting to work.
$aliases[] = $alias;
$this->writeAliases($aliases);
}
return 0;
}
public static function isShellCommandAlias($command) {
return preg_match('/^!/', $command);
}
private function validateAliasTrigger($trigger) {
$workflows = $this->getRuntime()->getWorkflows();
public static function resolveAliases(
$command,
ArcanistRuntime $config,
array $argv,
ArcanistConfigurationManager $configuration_manager) {
$aliases = self::getAliases($configuration_manager);
if (!isset($aliases[$command])) {
return array(null, $argv);
}
$new_command = head($aliases[$command]);
if (self::isShellCommandAlias($new_command)) {
return array($new_command, $argv);
}
$workflow = $config->buildWorkflow($new_command);
if (!$workflow) {
return array(null, $argv);
}
$alias_argv = array_slice($aliases[$command], 1);
foreach (array_reverse($alias_argv) as $alias_arg) {
if (!in_array($alias_arg, $argv)) {
array_unshift($argv, $alias_arg);
}
}
return array($new_command, $argv);
}
private function printAliases(array $aliases) {
if (!$aliases) {
echo tsprintf(
"%s\n",
pht('You have not defined any aliases yet.'));
return;
}
$table = id(new PhutilConsoleTable())
->addColumn('input', array('title' => pht('Alias')))
->addColumn('command', array('title' => pht('Command')))
->addColumn('type', array('title' => pht('Type')));
ksort($aliases);
foreach ($aliases as $alias => $binding) {
$command = implode(' ', $binding);
if (self::isShellCommandAlias($command)) {
$command = substr($command, 1);
$type = pht('Shell Command');
} else {
$command = "arc {$command}";
$type = pht('Arcanist Command');
}
$row = array(
'input' => "arc {$alias}",
'type' => $type,
'command' => $command,
);
$table->addRow($row);
}
$table->draw();
}
private function removeAlias(array $aliases, $alias) {
if (empty($aliases[$alias])) {
echo tsprintf(
"%s\n",
pht('No alias "%s" to remove.', $alias));
return;
}
$command = implode(' ', $aliases[$alias]);
if (self::isShellCommandAlias($command)) {
echo tsprintf(
"%s\n",
if (isset($workflows[$trigger])) {
throw new PhutilArgumentUsageException(
pht(
'"%s" is currently aliased to shell command "%s".',
"arc {$alias}",
substr($command, 1)));
} else {
echo tsprintf(
"%s\n",
pht(
'"%s" is currently aliased to "%s".',
"arc {$alias}",
"arc {$command}"));
'You can not define an alias for "%s" because it is a builtin '.
'workflow for the current toolset ("%s"). The "alias" workflow '.
'can only define new commands as aliases; it can not redefine '.
'existing commands to mean something else.',
$trigger,
$this->getToolsetKey()));
}
}
$ok = phutil_console_confirm(pht('Delete this alias?'));
if (!$ok) {
throw new ArcanistUserAbortException();
private function getEditScope() {
return ArcanistConfigurationSource::SCOPE_USER;
}
unset($aliases[$alias]);
$this->writeAliases($aliases);
private function getAliasesConfigKey() {
return ArcanistArcConfigurationEngineExtension::KEY_ALIASES;
}
echo tsprintf(
"%s\n",
pht(
'Removed alias "%s".',
"arc {$alias}"));
private function readAliasesForWrite() {
$key = $this->getAliasesConfigKey();
$scope = $this->getEditScope();
$source_list = $this->getConfigurationSourceList();
return $source_list->getConfigFromScopes($key, array($scope));
}
private function writeAliases(array $aliases) {
assert_instances_of($aliases, 'ArcanistAlias');
$key = $this->getAliasesConfigKey();
$scope = $this->getEditScope();
$source_list = $this->getConfigurationSourceList();
$source = $source_list->getWritableSourceFromScope($scope);
$option = $source_list->getConfigOption($key);
$option->writeValue($source, $aliases);
}
}

View file

@ -1,70 +1,32 @@
<?php
/**
* Create and update libphutil libraries.
*
* This workflow is unusual and involves re-executing 'arc liberate' as a
* subprocess with `--remap` and `--verify`. This is because there is no way
* to unload or reload a library, so every process is stuck with the library
* definition it had when it first loaded. This is normally fine, but
* problematic in this case because `arc liberate` modifies library definitions.
*/
final class ArcanistLiberateWorkflow extends ArcanistWorkflow {
public function getWorkflowName() {
return 'liberate';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**liberate** [__path__]
public function getWorkflowInformation() {
// TOOLSETS: Expand this help.
$help = pht(<<<EOTEXT
Create or update an Arcanist library.
EOTEXT
);
);
return $this->newWorkflowInformation()
->addExample(pht('**liberate**'))
->addExample(pht('**liberate** [__path__]'))
->setHelp($help);
}
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
Supports: libphutil
Create or update a libphutil library, generating required metadata
files like \__init__.php.
EOTEXT
);
}
public function getArguments() {
public function getWorkflowArguments() {
return array(
'all' => array(
'help' => pht(
'Drop the module cache before liberating. This will completely '.
'reanalyze the entire library. Thorough, but slow!'),
),
'force-update' => array(
'help' => pht(
'Force the library map to be updated, even in the presence of '.
'lint errors.'),
),
'library-name' => array(
'param' => 'name',
'help' =>
pht('Use a flag for library name rather than awaiting user input.'),
),
'remap' => array(
'hide' => true,
'help' => pht(
'Internal. Run the remap step of liberation. You do not need to '.
'run this unless you are debugging the workflow.'),
),
'verify' => array(
'hide' => true,
'help' => pht(
'Internal. Run the verify step of liberation. You do not need to '.
'run this unless you are debugging the workflow.'),
),
'upgrade' => array(
'hide' => true,
'help' => pht('Experimental. Upgrade library to v2.'),
),
'*' => 'argv',
$this->newWorkflowArgument('clean')
->setHelp(
pht('Perform a clean rebuild, ignoring caches. Thorough, but slow.')),
$this->newWorkflowArgument('argv')
->setWildcard(true),
);
}
@ -97,9 +59,6 @@ EOTEXT
);
}
$is_remap = $this->getArgument('remap');
$is_verify = $this->getArgument('verify');
foreach ($paths as $path) {
$this->liberatePath($path);
}
@ -122,19 +81,10 @@ EOTEXT
$version = $this->getLibraryFormatVersion($path);
switch ($version) {
case 1:
if ($this->getArgument('upgrade')) {
return $this->upgradeLibrary($path);
}
throw new ArcanistUsageException(
pht(
"This library is using libphutil v1, which is no ".
"longer supported. Run '%s' to upgrade to v2.",
'arc liberate --upgrade'));
'This very old library is no longer supported.'));
case 2:
if ($this->getArgument('upgrade')) {
throw new ArcanistUsageException(
pht("Can't upgrade a v2 library!"));
}
return $this->liberateVersion2($path);
default:
throw new ArcanistUsageException(
@ -165,25 +115,10 @@ EOTEXT
return phutil_passthru(
'php %s %C %s',
$bin,
$this->getArgument('all') ? '--drop-cache' : '',
$this->getArgument('clean') ? '--drop-cache' : '',
$path);
}
private function upgradeLibrary($path) {
$inits = id(new FileFinder($path))
->withPath('*/__init__.php')
->withType('f')
->find();
echo pht('Removing %s files...', '__init__.php')."\n";
foreach ($inits as $init) {
Filesystem::remove($path.'/'.$init);
}
echo pht('Upgrading library to v2...')."\n";
$this->liberateVersion2($path);
}
private function liberateCreateDirectory($path) {
if (Filesystem::pathExists($path)) {
if (!is_dir($path)) {

View file

@ -2,6 +2,8 @@
final class ArcanistRuntime {
private $workflows;
public function execute(array $argv) {
try {
@ -81,12 +83,14 @@ final class ArcanistRuntime {
$args->parsePartial($toolset->getToolsetArguments());
$workflows = $this->newWorkflows($toolset);
$this->workflows = $workflows;
$phutil_workflows = array();
foreach ($workflows as $key => $workflow) {
$phutil_workflows[$key] = $workflow->newPhutilWorkflow();
$workflow
->setRuntime($this)
->setConfigurationEngine($config_engine)
->setConfigurationSourceList($config);
}
@ -104,12 +108,16 @@ final class ArcanistRuntime {
throw new PhutilArgumentUsageException(pht('Choose a workflow!'));
}
$result = $this->resolveAliases($workflows, $unconsumed_argv, $config);
if (is_int($result)) {
return $result;
}
$alias_effects = id(new ArcanistAliasEngine())
->setRuntime($this)
->setToolset($toolset)
->setWorkflows($workflows)
->setConfigurationSourceList($config)
->resolveAliases($unconsumed_argv);
$args->setUnconsumedArgumentVector($result);
$result_argv = $this->applyAliasEffects($alias_effects, $unconsumed_argv);
$args->setUnconsumedArgumentVector($result_argv);
return $args->parseWorkflows($phutil_workflows);
}
@ -468,65 +476,6 @@ final class ArcanistRuntime {
return $map;
}
private function resolveAliases(
array $workflows,
array $argv,
ArcanistConfigurationSourceList $config) {
return $argv;
$command = head($argv);
// If this is a match for a recognized workflow, just return the arguments
// unmodified. You aren't allowed to alias over real workflows.
if (isset($workflows[$command])) {
return $argv;
}
$aliases = ArcanistAliasWorkflow::getAliases($config);
list($new_command, $new_args) = ArcanistAliasWorkflow::resolveAliases(
$command,
$this,
array_slice($argv, 1),
$config);
// You can't alias something to itself, so if the new command isn't new,
// we're all done resolving aliases.
if ($new_command === $command) {
return $argv;
}
$full_alias = idx($aliases, $command, array());
$full_alias = implode(' ', $full_alias);
// Run shell command aliases.
if (ArcanistAliasWorkflow::isShellCommandAlias($new_command)) {
fwrite(
STDERR,
tsprintf(
'**<bg:green> %s </bg>** arc %s -> $ %s',
pht('ALIAS'),
$command,
$shell_cmd));
$shell_cmd = substr($full_alias, 1);
return phutil_passthru('%C %Ls', $shell_cmd, $args);
}
fwrite(
STDERR,
tsprintf(
'**<bg:green> %s </bg>** arc %s -> arc %s',
pht('ALIAS'),
$command,
$new_command));
$new_argv = array_merge(array($new_command), $new_args);
return $this->resolveAliases($workflows, $new_argv, $config);
}
private function logTrace($label, $message) {
echo tsprintf(
"**<bg:magenta> %s </bg>** %s\n",
@ -534,4 +483,38 @@ final class ArcanistRuntime {
$message);
}
public function getWorkflows() {
return $this->workflows;
}
private function applyAliasEffects(array $effects, array $argv) {
assert_instances_of($effects, 'ArcanistAliasEffect');
$command = null;
$arguments = null;
foreach ($effects as $effect) {
$message = $effect->getMessage();
if ($message !== null) {
fprintf(
STDERR,
tsprintf(
"**<bg:yellow> %s </bg>** %s\n",
pht('ALIAS'),
$message));
}
if ($effect->getCommand()) {
$command = $effect->getCommand();
$arguments = $effect->getArguments();
}
}
if ($command !== null) {
$argv = array_merge(array($command), $arguments);
}
return $argv;
}
}