mirror of
https://we.phorge.it/source/arcanist.git
synced 2024-11-10 08:52:39 +01:00
Add a DSL for selecting base commits
Summary: New optional mode. If you set 'base' in local, project or global config or pass '--base' to 'arc diff' or 'arc which', it switches to DSL mode. In DSL mode, lists of rules from args, local, project and global config are resolved, in that order. Rules can manipulate the rule machine or resolve into actual commits. Provides support for some 'arc' rules (mostly machine manipulation) and 'git' rules (symbolic ref and merge-base). Test Plan: Ran unit tests. Also: ```$ arc which --show-base --base 'arc:prompt' Against which commit? HEAD HEAD $ arc which --show-base --base 'git:HEAD' HEAD $ arc which --show-base --base 'git:fake' Usage Exception: None of the rules in your 'base' configuration matched a valid commit. Adjust rules or specify which commit you want to use explicitly. $ arc which --show-base --base 'git:origin/master' origin/master $ arc which --show-base --base 'git:upstream' Usage Exception: None of the rules in your 'base' configuration matched a valid commit. Adjust rules or specify which commit you want to use explicitly. $ arc which --show-base --base 'literal:derp' derp $ arc which --show-base --base 'arc:halt' Usage Exception: None of the rules in your 'base' configuration matched a valid commit. Adjust rules or specify which commit you want to use explicitly. $ arc set-config --local base git:origin/master Set key 'base' = 'git:origin/master' in local config. $ arc which --show-base origin/master $ arc which --show-base --base 'git:HEAD^' HEAD^ $ arc which --show-base --base 'arc:yield, git:HEAD^' origin/master $ arc which --show-base --base 'arc:global, git:HEAD^' HEAD^ $ arc which --show-base --base 'arc:global, git:merge-base(origin/master)' 3f4f8992fba8d1f142974da36a82bae900e247c0``` Reviewers: dschleimer, vrana Reviewed By: dschleimer CC: aran Maniphest Tasks: T1233 Differential Revision: https://secure.phabricator.com/D2748
This commit is contained in:
parent
57499106ec
commit
f2220e74fc
7 changed files with 489 additions and 9 deletions
|
@ -14,6 +14,8 @@ phutil_register_library_map(array(
|
|||
'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php',
|
||||
'ArcanistApacheLicenseLinter' => 'lint/linter/ArcanistApacheLicenseLinter.php',
|
||||
'ArcanistApacheLicenseLinterTestCase' => 'lint/linter/__tests__/ArcanistApacheLicenseLinterTestCase.php',
|
||||
'ArcanistBaseCommitParser' => 'parser/ArcanistBaseCommitParser.php',
|
||||
'ArcanistBaseCommitParserTestCase' => 'parser/__tests__/ArcanistBaseCommitParserTestCase.php',
|
||||
'ArcanistBaseUnitTestEngine' => 'unit/engine/ArcanistBaseUnitTestEngine.php',
|
||||
'ArcanistBaseWorkflow' => 'workflow/ArcanistBaseWorkflow.php',
|
||||
'ArcanistBranchWorkflow' => 'workflow/ArcanistBranchWorkflow.php',
|
||||
|
@ -144,6 +146,7 @@ phutil_register_library_map(array(
|
|||
'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow',
|
||||
'ArcanistApacheLicenseLinter' => 'ArcanistLicenseLinter',
|
||||
'ArcanistApacheLicenseLinterTestCase' => 'ArcanistLinterTestCase',
|
||||
'ArcanistBaseCommitParserTestCase' => 'ArcanistPhutilTestCase',
|
||||
'ArcanistBranchWorkflow' => 'ArcanistBaseWorkflow',
|
||||
'ArcanistBundleTestCase' => 'ArcanistPhutilTestCase',
|
||||
'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow',
|
||||
|
|
173
src/parser/ArcanistBaseCommitParser.php
Normal file
173
src/parser/ArcanistBaseCommitParser.php
Normal file
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright 2012 Facebook, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
final class ArcanistBaseCommitParser {
|
||||
|
||||
private $api;
|
||||
private $try;
|
||||
private $verbose = false;
|
||||
|
||||
public function __construct(ArcanistRepositoryAPI $api) {
|
||||
$this->api = $api;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function tokenizeBaseCommitSpecification($raw_spec) {
|
||||
if (!$raw_spec) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$spec = preg_split('/[ ,]/', $raw_spec);
|
||||
$spec = array_filter($spec);
|
||||
|
||||
foreach ($spec as $rule) {
|
||||
if (strpos($rule, ':') === false) {
|
||||
throw new ArcanistUsageException(
|
||||
"Rule '{$rule}' is invalid, it must have a type and name like ".
|
||||
"'arc:upstream'.");
|
||||
}
|
||||
}
|
||||
|
||||
return $spec;
|
||||
}
|
||||
|
||||
private function log($message) {
|
||||
if ($this->verbose) {
|
||||
file_put_contents('php://stderr', $message."\n");
|
||||
}
|
||||
}
|
||||
|
||||
public function resolveBaseCommit(array $specs) {
|
||||
$specs += array(
|
||||
'args' => '',
|
||||
'local' => '',
|
||||
'project' => '',
|
||||
'global' => '',
|
||||
);
|
||||
|
||||
foreach ($specs as $source => $spec) {
|
||||
$specs[$source] = self::tokenizeBaseCommitSpecification($spec);
|
||||
}
|
||||
|
||||
$this->try = array(
|
||||
'args',
|
||||
'local',
|
||||
'project',
|
||||
'global',
|
||||
);
|
||||
|
||||
while ($this->try) {
|
||||
$source = head($this->try);
|
||||
|
||||
if (!idx($specs, $source)) {
|
||||
$this->log("No rules left from source '{$source}'.");
|
||||
array_shift($this->try);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->log("Trying rules from source '{$source}'.");
|
||||
|
||||
$rules = &$specs[$source];
|
||||
while ($rule = array_shift($rules)) {
|
||||
$this->log("Trying rule '{$rule}'.");
|
||||
|
||||
$commit = $this->resolveRule($rule, $source);
|
||||
|
||||
if ($commit === false) {
|
||||
// If a rule returns false, it means to go to the next ruleset.
|
||||
break;
|
||||
} else if ($commit !== null) {
|
||||
$this->log("Resolved commit '{$commit}' from rule '{$rule}'.");
|
||||
return $commit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resolving individual rules.
|
||||
*/
|
||||
private function resolveRule($rule, $source) {
|
||||
|
||||
// NOTE: Returning `null` from this method means "no match".
|
||||
// Returning `false` from this method means "stop current ruleset".
|
||||
|
||||
list($type, $name) = explode(':', $rule, 2);
|
||||
switch ($type) {
|
||||
case 'literal':
|
||||
return $name;
|
||||
case 'git':
|
||||
case 'hg':
|
||||
return $this->api->resolveBaseCommitRule($rule, $source);
|
||||
case 'arc':
|
||||
return $this->resolveArcRule($rule, $name, $source);
|
||||
default:
|
||||
throw new ArcanistUsageException(
|
||||
"Base commit rule '{$rule}' (from source '{$source}') ".
|
||||
"is not a recognized rule.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle resolving "arc:*" rules.
|
||||
*/
|
||||
private function resolveArcRule($rule, $name, $source) {
|
||||
switch ($name) {
|
||||
case 'verbose':
|
||||
$this->verbose = true;
|
||||
$this->log("Enabled verbose mode.");
|
||||
break;
|
||||
case 'prompt':
|
||||
$reason = "it is what you typed when prompted.";
|
||||
$this->api->setBaseCommitExplanation($reason);
|
||||
return phutil_console_prompt('Against which commit?');
|
||||
case 'local':
|
||||
case 'global':
|
||||
case 'project':
|
||||
case 'args':
|
||||
// Push the other source on top of the list.
|
||||
array_unshift($this->try, $name);
|
||||
$this->log("Switching to source '{$name}'.");
|
||||
return false;
|
||||
case 'yield':
|
||||
// Cycle this source to the end of the list.
|
||||
$this->try[] = array_shift($this->try);
|
||||
$this->log("Yielding processing of rules from '{$source}'.");
|
||||
return false;
|
||||
case 'halt':
|
||||
// Dump the whole stack.
|
||||
$this->try = array();
|
||||
$this->log("Halting all rule processing.");
|
||||
return false;
|
||||
case 'skip':
|
||||
return null;
|
||||
case 'empty':
|
||||
case 'upstream':
|
||||
case 'outgoing':
|
||||
return $this->api->resolveBaseCommitRule($rule, $source);
|
||||
default:
|
||||
throw new ArcanistUsageException(
|
||||
"Base commit rule '{$rule}' (from source '{$source}') ".
|
||||
"is not a recognized rule.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
165
src/parser/__tests__/ArcanistBaseCommitParserTestCase.php
Normal file
165
src/parser/__tests__/ArcanistBaseCommitParserTestCase.php
Normal file
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright 2012 Facebook, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
final class ArcanistBaseCommitParserTestCase extends ArcanistPhutilTestCase {
|
||||
|
||||
public function testBasics() {
|
||||
|
||||
// Verify that the very basics of base commit resolution work.
|
||||
|
||||
$this->assertCommit(
|
||||
'Empty Rules',
|
||||
null,
|
||||
array(
|
||||
));
|
||||
|
||||
$this->assertCommit(
|
||||
'Literal',
|
||||
'xyz',
|
||||
array(
|
||||
'args' => 'literal:xyz',
|
||||
));
|
||||
}
|
||||
|
||||
public function testResolutionOrder() {
|
||||
|
||||
// Rules should be resolved in order: args, local, project, global. These
|
||||
// test cases intentionally scramble argument order to test that resolution
|
||||
// order is independent of argument order.
|
||||
|
||||
$this->assertCommit(
|
||||
'Order: Args',
|
||||
'y',
|
||||
array(
|
||||
'local' => 'literal:n',
|
||||
'project' => 'literal:n',
|
||||
'args' => 'literal:y',
|
||||
'global' => 'literal:n',
|
||||
));
|
||||
|
||||
$this->assertCommit(
|
||||
'Order: Local',
|
||||
'y',
|
||||
array(
|
||||
'project' => 'literal:n',
|
||||
'local' => 'literal:y',
|
||||
'global' => 'literal:n',
|
||||
));
|
||||
|
||||
$this->assertCommit(
|
||||
'Order: Project',
|
||||
'y',
|
||||
array(
|
||||
'project' => 'literal:y',
|
||||
'global' => 'literal:n',
|
||||
));
|
||||
|
||||
$this->assertCommit(
|
||||
'Order: Global',
|
||||
'y',
|
||||
array(
|
||||
'global' => 'literal:y',
|
||||
));
|
||||
}
|
||||
|
||||
public function testHalt() {
|
||||
|
||||
// 'arc:halt' should halt all processing.
|
||||
|
||||
$this->assertCommit(
|
||||
'Halt',
|
||||
null,
|
||||
array(
|
||||
'args' => 'arc:halt',
|
||||
'local' => 'literal:xyz',
|
||||
));
|
||||
}
|
||||
|
||||
public function testYield() {
|
||||
|
||||
// 'arc:yield' should yield to other rulesets.
|
||||
|
||||
$this->assertCommit(
|
||||
'Yield',
|
||||
'xyz',
|
||||
array(
|
||||
'args' => 'arc:yield, literal:abc',
|
||||
'local' => 'literal:xyz',
|
||||
));
|
||||
|
||||
// This one should return to 'args' after exhausting 'local'.
|
||||
|
||||
$this->assertCommit(
|
||||
'Yield + Return',
|
||||
'abc',
|
||||
array(
|
||||
'args' => 'arc:yield, literal:abc',
|
||||
'local' => 'arc:skip',
|
||||
));
|
||||
}
|
||||
|
||||
public function testJump() {
|
||||
|
||||
// This should resolve to 'abc' without hitting any of the halts.
|
||||
|
||||
$this->assertCommit(
|
||||
'Jump',
|
||||
'abc',
|
||||
array(
|
||||
'args' => 'arc:project, arc:halt',
|
||||
'local' => 'literal:abc',
|
||||
'project' => 'arc:global, arc:halt',
|
||||
'global' => 'arc:local, arc:halt',
|
||||
));
|
||||
}
|
||||
|
||||
public function testJumpReturn() {
|
||||
|
||||
// After jumping to project, we should return to 'args'.
|
||||
|
||||
$this->assertCommit(
|
||||
'Jump Return',
|
||||
'xyz',
|
||||
array(
|
||||
'args' => 'arc:project, literal:xyz',
|
||||
'local' => 'arc:halt',
|
||||
'project' => '',
|
||||
'global' => 'arc:halt',
|
||||
));
|
||||
}
|
||||
|
||||
private function assertCommit($desc, $commit, $rules) {
|
||||
$parser = $this->buildParser();
|
||||
$result = $parser->resolveBaseCommit($rules);
|
||||
$this->assertEqual($commit, $result, $desc);
|
||||
}
|
||||
|
||||
|
||||
private function buildParser() {
|
||||
// TODO: This is a little hacky beacuse we're using the Arcanist repository
|
||||
// itself to execute tests with, but it should be OK until we get proper
|
||||
// isolation for repository-oriented test cases.
|
||||
|
||||
$root = dirname(phutil_get_library_root('arcanist'));
|
||||
$copy = ArcanistWorkingCopyIdentity::newFromPath($root);
|
||||
$repo = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity($copy);
|
||||
|
||||
return new ArcanistBaseCommitParser($repo);
|
||||
}
|
||||
|
||||
}
|
|
@ -167,6 +167,19 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI {
|
|||
return $this->relativeCommit;
|
||||
}
|
||||
|
||||
if ($this->getBaseCommitArgumentRules() ||
|
||||
$this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) {
|
||||
$base = $this->resolveBaseCommit();
|
||||
if (!$base) {
|
||||
throw new ArcanistUsageException(
|
||||
"None of the rules in your 'base' configuration matched a valid ".
|
||||
"commit. Adjust rules or specify which commit you want to use ".
|
||||
"explicitly.");
|
||||
}
|
||||
$this->relativeCommit = $base;
|
||||
return $this->relativeCommit;
|
||||
}
|
||||
|
||||
$do_write = false;
|
||||
$default_relative = null;
|
||||
$working_copy = $this->getWorkingCopyIdentity();
|
||||
|
@ -855,4 +868,62 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI {
|
|||
return trim($summary);
|
||||
}
|
||||
|
||||
public function resolveBaseCommitRule($rule, $source) {
|
||||
list($type, $name) = explode(':', $rule, 2);
|
||||
|
||||
switch ($type) {
|
||||
case 'git':
|
||||
$matches = null;
|
||||
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
|
||||
list($err, $merge_base) = $this->execManualLocal(
|
||||
'merge-base %s HEAD',
|
||||
$matches[1]);
|
||||
if (!$err) {
|
||||
$this->setBaseCommitExplanation(
|
||||
"it is the merge-base of '{$matches[1]}' and HEAD, as ".
|
||||
"specified by '{$rule}' in your {$source} 'base' ".
|
||||
"configuration.");
|
||||
return trim($merge_base);
|
||||
}
|
||||
} else {
|
||||
list($err) = $this->execManualLocal(
|
||||
'cat-file -t %s',
|
||||
$name);
|
||||
if (!$err) {
|
||||
$this->setBaseCommitExplanation(
|
||||
"it is specified by '{$rule}' in your {$source} 'base' ".
|
||||
"configuration.");
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'arc':
|
||||
switch ($name) {
|
||||
case 'empty':
|
||||
$this->setBaseCommitExplanation(
|
||||
"you specified '{$rule}' in your {$source} 'base' ".
|
||||
"configuration.");
|
||||
return self::GIT_MAGIC_ROOT_COMMIT;
|
||||
case 'upstream':
|
||||
list($err, $upstream) = $this->execManualLocal(
|
||||
"rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'");
|
||||
if (!$err) {
|
||||
list($upstream_merge_base) = $this->execxLocal(
|
||||
'merge-base %s HEAD',
|
||||
$upstream);
|
||||
$this->setBaseCommitExplanation(
|
||||
"it is the merge-base of the upstream of the current branch ".
|
||||
"and HEAD, and matched the rule '{$rule}' in your {$source} ".
|
||||
"'base' configuration.");
|
||||
return $upstream_merge_base;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ abstract class ArcanistRepositoryAPI {
|
|||
protected $diffLinesOfContext = 0x7FFF;
|
||||
private $baseCommitExplanation = '???';
|
||||
private $workingCopyIdentity;
|
||||
private $baseCommitArgumentRules;
|
||||
|
||||
abstract public function getSourceControlSystemName();
|
||||
|
||||
|
@ -202,15 +203,6 @@ abstract class ArcanistRepositoryAPI {
|
|||
throw new ArcanistCapabilityNotSupportedException($this);
|
||||
}
|
||||
|
||||
public function getBaseCommitExplanation() {
|
||||
return $this->baseCommitExplanation;
|
||||
}
|
||||
|
||||
public function setBaseCommitExplanation($explanation) {
|
||||
$this->baseCommitExplanation = $explanation;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCommitSummary($commit) {
|
||||
throw new ArcanistCapabilityNotSupportedException($this);
|
||||
}
|
||||
|
@ -388,4 +380,46 @@ abstract class ArcanistRepositoryAPI {
|
|||
return Filesystem::resolvePath($path, $new_scratch_path);
|
||||
}
|
||||
|
||||
|
||||
/* -( Base Commits )------------------------------------------------------- */
|
||||
|
||||
|
||||
public function getBaseCommitExplanation() {
|
||||
return $this->baseCommitExplanation;
|
||||
}
|
||||
|
||||
public function setBaseCommitExplanation($explanation) {
|
||||
$this->baseCommitExplanation = $explanation;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolveBaseCommitRule($rule, $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setBaseCommitArgumentRules($base_commit_argument_rules) {
|
||||
$this->baseCommitArgumentRules = $base_commit_argument_rules;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBaseCommitArgumentRules() {
|
||||
return $this->baseCommitArgumentRules;
|
||||
}
|
||||
|
||||
public function resolveBaseCommit() {
|
||||
$working_copy = $this->getWorkingCopyIdentity();
|
||||
$global_config = ArcanistBaseWorkflow::readGlobalArcConfig();
|
||||
|
||||
$parser = new ArcanistBaseCommitParser($this);
|
||||
$commit = $parser->resolveBaseCommit(
|
||||
array(
|
||||
'args' => $this->getBaseCommitArgumentRules(),
|
||||
'local' => $working_copy->getLocalConfig('base', ''),
|
||||
'project' => $working_copy->getConfig('base', ''),
|
||||
'global' => idx($global_config, 'base', ''),
|
||||
));
|
||||
|
||||
return $commit;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -339,6 +339,14 @@ EOTEXT
|
|||
'skip-binaries' => array(
|
||||
'help' => 'Do not upload binaries (like images).',
|
||||
),
|
||||
'base' => array(
|
||||
'param' => 'rules',
|
||||
'help' => 'Additional rules for determining base revision.',
|
||||
'nosupport' => array(
|
||||
'svn' => 'Subversion does not use base commits.',
|
||||
),
|
||||
'supports' => array('git', 'hg'),
|
||||
),
|
||||
'*' => 'paths',
|
||||
);
|
||||
}
|
||||
|
@ -515,6 +523,9 @@ EOTEXT
|
|||
$repository_api->setDiffLinesOfContext(3);
|
||||
}
|
||||
|
||||
$repository_api->setBaseCommitArgumentRules(
|
||||
$this->getArgument('base', ''));
|
||||
|
||||
if ($repository_api->supportsRelativeLocalCommits()) {
|
||||
|
||||
// Parse the relative commit as soon as we can, to avoid generating
|
||||
|
|
|
@ -60,6 +60,21 @@ EOTEXT
|
|||
'any-status' => array(
|
||||
'help' => "Show committed and abandoned revisions.",
|
||||
),
|
||||
'base' => array(
|
||||
'param' => 'rules',
|
||||
'help' => 'Additional rules for determining base revision.',
|
||||
'nosupport' => array(
|
||||
'svn' => 'Subversion does not use base commits.',
|
||||
),
|
||||
'supports' => array('git', 'hg'),
|
||||
),
|
||||
'show-base' => array(
|
||||
'help' => 'Print base commit only and exit.',
|
||||
'nosupport' => array(
|
||||
'svn' => 'Subversion does not use base commits.',
|
||||
),
|
||||
'supports' => array('git', 'hg'),
|
||||
),
|
||||
'*' => 'commit',
|
||||
);
|
||||
}
|
||||
|
@ -79,9 +94,17 @@ EOTEXT
|
|||
}
|
||||
$arg = $arg_commit ? ' '.head($arg_commit) : '';
|
||||
|
||||
$repository_api->setBaseCommitArgumentRules(
|
||||
$this->getArgument('base', ''));
|
||||
|
||||
if ($repository_api->supportsRelativeLocalCommits()) {
|
||||
$relative = $repository_api->getRelativeCommit();
|
||||
|
||||
if ($this->getArgument('show-base')) {
|
||||
echo $relative."\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$info = $repository_api->getLocalCommitInformation();
|
||||
if ($info) {
|
||||
$commits = array();
|
||||
|
|
Loading…
Reference in a new issue