Skip to content

Commit 3f26ecb

Browse files
authored
Merge pull request #2 from JoshuaEstes/codex/review-and-enhance-chorale-codebase
feat(chorale): add run and apply workflow
2 parents 20fbd3e + 4f77c1f commit 3f26ecb

24 files changed

Lines changed: 848 additions & 5 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ results.sarif
1717
infection.log
1818
.churn.cache
1919
tools/chorale/composer.lock
20+
tools/chorale/.phpunit.cache/

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ This repository contains multiple projects and tools that are maintained here.
55
- Use clear variable names and keep code well documented.
66
- Run tests relevant to the areas you change.
77
- For changes under `tools/chorale`, run `composer install` and `./vendor/bin/phpunit` in that directory before committing.
8+
- Chorale is the monorepo management CLI using a plan/apply workflow; see `tools/chorale/AGENTS.md` for its roadmap and guidelines.
89

docs/tools/chorale.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
11
# Chorale
22

3-
Chorale is a CLI tool for managing PHP monorepos.
3+
Chorale is a CLI tool for managing PHP monorepos. It uses a plan/apply workflow to keep package metadata and the root package in sync.
44

5-
## Getting started
5+
## Installation
66

77
```bash
88
cd tools/chorale
99
composer install
10-
php bin/chorale
1110
```
1211

12+
## Usage
13+
14+
Run the commands from the project root:
15+
16+
```bash
17+
# create chorale.yaml by scanning packages
18+
php bin/chorale setup
19+
20+
# preview changes without modifying files
21+
php bin/chorale plan --json > plan.json
22+
23+
# apply an exported plan
24+
php bin/chorale apply --file plan.json
25+
26+
# build and apply a plan in one go
27+
php bin/chorale run
28+
```
29+
30+
Chorale automatically merges all package `composer.json` files into the root `composer.json` so the monorepo can be installed as a single package. Any dependency conflicts are recorded under the `extra.chorale.dependency-conflicts` section for review.
31+
1332
## Commands
1433

1534
- `setup` – generate configuration and validate required files.
16-
- `plan` – build a plan for splitting packages from the monorepo.
17-
35+
- `plan` – build a plan for splitting packages and root updates.
36+
- `run` – build and immediately apply a plan.
37+
- `apply` – execute steps from a JSON plan file.

tools/chorale/AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ Chorale is a CLI tool maintained in this repository.
66
- Add unit tests for new features in `src/Tests`.
77
- Run `composer install` and `./vendor/bin/phpunit` in this directory before committing changes.
88

9+
## Roadmap
10+
11+
- Implement executors for remaining plan steps such as composer root rebuild and metadata sync.
12+
- Improve conflict resolution strategies for dependency merges.
13+
- Enhance documentation with more real-world examples as features grow.
14+

tools/chorale/bin/chorale

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ use Chorale\State\FilesystemStateStore;
3333
use Chorale\Util\DiffUtil;
3434
use Chorale\Plan\PlanBuilder;
3535
use Chorale\Console\PlanCommand;
36+
use Chorale\Console\ApplyCommand;
37+
use Chorale\Console\RunCommand;
38+
use Chorale\Run\Runner;
39+
use Chorale\Run\StepExecutorRegistry;
40+
use Chorale\Run\PackageVersionUpdateExecutor;
41+
use Chorale\Run\RootDependencyMergeExecutor;
42+
use Chorale\Run\ComposerRootUpdateExecutor;
3643

3744
$paths = new PathUtils();
3845
$renderer = new TemplateRenderer();
@@ -73,6 +80,17 @@ $planner = new PlanBuilder(
7380
splitDecider: $splitDecider,
7481
diffs: $diffs,
7582
);
83+
$executors = new StepExecutorRegistry([
84+
new PackageVersionUpdateExecutor(),
85+
new RootDependencyMergeExecutor(),
86+
new ComposerRootUpdateExecutor(),
87+
]);
88+
$runner = new Runner(
89+
configLoader: $loader,
90+
planner: $planner,
91+
executors: $executors,
92+
);
93+
7694

7795
// -----------------------------------------------------------------------------
7896
$app = new Application('Chorale', '0.1.0');
@@ -105,6 +123,16 @@ $app->add(new PlanCommand(
105123
planner: $planner,
106124
));
107125
// -----------------------------------------------------------------------------
126+
$app->add(new ApplyCommand(
127+
styleFactory: new ConsoleStyleFactory(),
128+
runner: $runner,
129+
));
130+
// -----------------------------------------------------------------------------
131+
$app->add(new RunCommand(
132+
styleFactory: new ConsoleStyleFactory(),
133+
runner: $runner,
134+
));
135+
// -----------------------------------------------------------------------------
108136

109137
// -----------------------------------------------------------------------------
110138
$app->run();
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Console;
6+
7+
use Chorale\Console\Style\ConsoleStyleFactory;
8+
use Chorale\Run\RunnerInterface;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Input\InputInterface;
11+
use Symfony\Component\Console\Input\InputOption;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
14+
final class ApplyCommand extends Command
15+
{
16+
protected static $defaultName = 'apply';
17+
protected static $defaultDescription = 'Apply steps from a JSON plan.';
18+
19+
public function __construct(
20+
private readonly ConsoleStyleFactory $styleFactory,
21+
private readonly RunnerInterface $runner,
22+
) {
23+
parent::__construct();
24+
}
25+
26+
protected function configure(): void
27+
{
28+
$this
29+
->setName('apply')
30+
->setDescription('Apply steps from a JSON plan file.')
31+
->setHelp(<<<'HELP'
32+
Reads a plan exported from `chorale plan --json` and executes each step.
33+
34+
Example:
35+
chorale apply --project-root /path/to/repo --file plan.json
36+
HELP)
37+
->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
38+
->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Path to JSON plan file.', 'plan.json');
39+
}
40+
41+
protected function execute(InputInterface $input, OutputInterface $output): int
42+
{
43+
$io = $this->styleFactory->create($input, $output);
44+
$root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
45+
$file = (string) $input->getOption('file');
46+
47+
if (!is_file($file)) {
48+
$io->error('Plan file not found: ' . $file);
49+
return 2;
50+
}
51+
52+
$json = json_decode((string) file_get_contents($file), true);
53+
if (!is_array($json) || !isset($json['steps']) || !is_array($json['steps'])) {
54+
$io->error('Invalid plan file.');
55+
return 2;
56+
}
57+
58+
/** @var list<array<string,mixed>> $steps */
59+
$steps = $json['steps'];
60+
$this->runner->apply($root, $steps);
61+
$io->success(sprintf('Applied %d step(s).', count($steps)));
62+
return 0;
63+
}
64+
}

tools/chorale/src/Console/PlanCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ protected function configure(): void
3737
$this
3838
->setName('plan')
3939
->setDescription('Build and print a dry-run plan of actionable steps.')
40+
->setHelp('Generates a plan of changes without modifying files. Use --json to export for apply.')
4041
->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
4142
->addOption('paths', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit to specific package paths', [])
4243
->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON instead of human-readable.')
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Console;
6+
7+
use Chorale\Console\Style\ConsoleStyleFactory;
8+
use Chorale\Run\RunnerInterface;
9+
use Chorale\Plan\PlanStepInterface;
10+
use Symfony\Component\Console\Command\Command;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Input\InputOption;
13+
use Symfony\Component\Console\Output\OutputInterface;
14+
use Symfony\Component\Console\Style\SymfonyStyle;
15+
16+
final class RunCommand extends Command
17+
{
18+
protected static $defaultName = 'run';
19+
protected static $defaultDescription = 'Plan and apply steps.';
20+
21+
public function __construct(
22+
private readonly ConsoleStyleFactory $styleFactory,
23+
private readonly RunnerInterface $runner,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void
29+
{
30+
$this
31+
->setName('run')
32+
->setDescription('Plan and immediately apply steps.')
33+
->setHelp(<<<'HELP'
34+
Builds a plan for the repository and applies it in a single command.
35+
This is equivalent to running `chorale plan` followed by `chorale apply`.
36+
37+
Examples:
38+
chorale run
39+
chorale run --paths packages/acme --strict
40+
HELP)
41+
->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
42+
->addOption('paths', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit to specific package paths', [])
43+
->addOption('force-split', null, InputOption::VALUE_NONE, 'Force split steps even if unchanged.')
44+
->addOption('verify-remote', null, InputOption::VALUE_NONE, 'Verify remote state if lockfile is missing/stale.')
45+
->addOption('strict', null, InputOption::VALUE_NONE, 'Fail on missing root version / unresolved conflicts / remote failures.')
46+
->addOption('show-all', null, InputOption::VALUE_NONE, 'Show no-op summaries.');
47+
}
48+
49+
protected function execute(InputInterface $input, OutputInterface $output): int
50+
{
51+
$io = $this->styleFactory->create($input, $output);
52+
$root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
53+
/** @var list<string> $paths */
54+
$paths = (array) $input->getOption('paths');
55+
$force = (bool) $input->getOption('force-split');
56+
$verify = (bool) $input->getOption('verify-remote');
57+
$strict = (bool) $input->getOption('strict');
58+
$showAll = (bool) $input->getOption('show-all');
59+
60+
try {
61+
$result = $this->runner->run($root, [
62+
'paths' => $paths,
63+
'force_split' => $force,
64+
'verify_remote' => $verify,
65+
'strict' => $strict,
66+
'show_all' => $showAll,
67+
]);
68+
} catch (\RuntimeException $e) {
69+
$io->error($e->getMessage());
70+
return 2;
71+
}
72+
73+
$this->renderHuman($io, $result['steps'], $showAll ? ($result['noop'] ?? []) : []);
74+
$io->success(sprintf('Applied %d step(s).', count($result['steps'])));
75+
return (int) ($result['exit_code'] ?? 0);
76+
}
77+
78+
/** @param list<PlanStepInterface> $steps */
79+
private function renderHuman(SymfonyStyle $io, array $steps, array $noop): void
80+
{
81+
$io->title('Chorale Run');
82+
$byType = [];
83+
foreach ($steps as $s) {
84+
$byType[$s->type()][] = $s;
85+
}
86+
87+
$sections = [
88+
'split' => 'Split steps',
89+
'package-version-update' => 'Package versions',
90+
'package-metadata-sync' => 'Package metadata',
91+
'composer-root-update' => 'Root composer: aggregator',
92+
'composer-root-merge' => 'Root composer: dependency merge',
93+
'composer-root-rebuild' => 'Root composer: maintenance',
94+
];
95+
96+
$any = false;
97+
foreach ($sections as $type => $label) {
98+
if (empty($byType[$type])) {
99+
continue;
100+
}
101+
102+
$any = true;
103+
$io->section($label);
104+
foreach ($byType[$type] as $s) {
105+
$a = $s->toArray();
106+
$io->writeln('' . $this->humanLine($type, $a));
107+
}
108+
}
109+
110+
if (!$any) {
111+
$io->writeln('No steps. Nothing to do.');
112+
}
113+
114+
if ($noop !== []) {
115+
$io->newLine();
116+
$io->section('No-op summary (debug)');
117+
foreach ($noop as $group => $rows) {
118+
$io->writeln(sprintf(' - %s: ', $group) . count($rows));
119+
}
120+
}
121+
}
122+
123+
/** @param array<string,mixed> $a */
124+
private function humanLine(string $type, array $a): string
125+
{
126+
return match ($type) {
127+
'split' => sprintf(
128+
'%s → %s [%s]%s',
129+
$a['path'] ?? '',
130+
$a['repo'] ?? '',
131+
$a['splitter'] ?? '',
132+
empty($a['reasons']) ? '' : ' {' . implode(',', (array) $a['reasons']) . '}'
133+
),
134+
'package-version-update' => sprintf('%s — set version %s', $a['name'] ?? $a['path'] ?? '', $a['version'] ?? ''),
135+
'package-metadata-sync' => sprintf(
136+
'%s — mirror %s',
137+
$a['name'] ?? $a['path'] ?? '',
138+
implode(',', array_keys((array) ($a['apply'] ?? [])))
139+
),
140+
'composer-root-update' => sprintf(
141+
'update %s (version %s, require %d, replace %d)',
142+
$a['root'] ?? '',
143+
$a['root_version'] ?? 'n/a',
144+
isset($a['require']) ? count((array) $a['require']) : 0,
145+
isset($a['replace']) ? count((array) $a['replace']) : 0
146+
),
147+
'composer-root-merge' => sprintf(
148+
'require %d, require-dev %d%s',
149+
isset($a['require']) ? count((array) $a['require']) : 0,
150+
isset($a['require-dev']) ? count((array) $a['require-dev']) : 0,
151+
empty($a['conflicts']) ? '' : ' [conflicts: ' . count((array) $a['conflicts']) . ']'
152+
),
153+
'composer-root-rebuild' => sprintf('actions: %s', implode(',', (array) ($a['actions'] ?? []))),
154+
default => json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $type,
155+
};
156+
}
157+
}

tools/chorale/src/Console/SetupCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ protected function configure(): void
5151
$this
5252
->setName('setup')
5353
->setDescription('Create or update chorale.yaml by scanning src/ and applying defaults.')
54+
->setHelp('Scans packages and writes a chorale.yaml configuration file.')
5455
->addOption('non-interactive', null, InputOption::VALUE_NONE, 'Never prompt.')
5556
->addOption('accept-all', null, InputOption::VALUE_NONE, 'Accept suggested adds/renames.')
5657
->addOption('discover-only', null, InputOption::VALUE_NONE, 'Only scan & print; do not write.')
File renamed without changes.

0 commit comments

Comments
 (0)