Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.23% covered (success)
99.23%
129 / 130
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncCommand
99.23% covered (success)
99.23%
129 / 130
75.00% covered (warning)
75.00%
3 / 4
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
19
 queueDevToolsCommand
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Console\Input\HasJsonOption;
24use FastForward\DevTools\Path\DevToolsPathResolver;
25use FastForward\DevTools\Process\ProcessBuilderInterface;
26use FastForward\DevTools\Process\ProcessQueueInterface;
27use Psr\Log\LogLevel;
28use Symfony\Component\Console\Attribute\AsCommand;
29use Symfony\Component\Console\Command\Command;
30use Symfony\Component\Console\Input\InputInterface;
31use Symfony\Component\Console\Input\InputOption;
32use Symfony\Component\Console\Output\BufferedOutput;
33use Symfony\Component\Console\Output\OutputInterface;
34
35/**
36 * Orchestrates dev-tools synchronization commands for the consumer repository.
37 */
38#[AsCommand(
39    name: 'dev-tools:sync',
40    description: 'Installs and synchronizes dev-tools scripts, GitHub Actions workflows, CODEOWNERS, .editorconfig, and .gitattributes in the root project.',
41    aliases: ['sync'],
42)]
43 class SyncCommand extends Command
44{
45    use HasJsonOption;
46    use LogsCommandResults;
47
48    /**
49     * @param ProcessBuilderInterface $processBuilder
50     * @param ProcessQueueInterface $processQueue
51     */
52    public function __construct(
53        private  ProcessBuilderInterface $processBuilder,
54        private  ProcessQueueInterface $processQueue,
55    ) {
56        parent::__construct();
57    }
58
59    /**
60     * {@inheritDoc}
61     */
62    protected function configure(): void
63    {
64        $this->setHelp(
65            'This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS,'
66            . ' funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'
67        );
68
69        $this->addJsonOption()
70            ->addOption(
71                name: 'overwrite',
72                shortcut: 'o',
73                mode: InputOption::VALUE_NONE,
74                description: 'Overwrite existing target files.',
75            )
76            ->addOption(
77                name: 'dry-run',
78                mode: InputOption::VALUE_NONE,
79                description: 'Preview managed-file drift without writing changes.',
80            )
81            ->addOption(
82                name: 'check',
83                mode: InputOption::VALUE_NONE,
84                description: 'Exit non-zero when managed-file drift is detected.',
85            )
86            ->addOption(
87                name: 'interactive',
88                mode: InputOption::VALUE_NONE,
89                description: 'Prompt before applying managed-file replacements.',
90            );
91    }
92
93    /**
94     * @param InputInterface $input
95     * @param OutputInterface $output
96     */
97    protected function execute(InputInterface $input, OutputInterface $output): int
98    {
99        $jsonOutput = $this->isJsonOutput($input);
100        $prettyJsonOutput = $this->isPrettyJsonOutput($input);
101        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
102        $overwrite = (bool) $input->getOption('overwrite');
103        $dryRun = (bool) $input->getOption('dry-run');
104        $check = (bool) $input->getOption('check');
105        $interactive = (bool) $input->getOption('interactive');
106        $modeArguments = [
107            $dryRun ? '--dry-run' : null,
108            $check ? '--check' : null,
109            $interactive ? '--interactive' : null,
110        ];
111        $allowDetached = ! $dryRun && ! $check && ! $interactive;
112
113        $this->log('Starting dev-tools synchronization...', $input);
114
115        $this->queueDevToolsCommand(['update-composer-json', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
116        $this->queueDevToolsCommand(['funding', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
117        $this->queueDevToolsCommand(
118            [
119                'copy-resource',
120                '--source=resources/github-actions',
121                '--target=.github/workflows',
122                $overwrite ? '--overwrite' : null,
123                ...$modeArguments,
124            ],
125            $allowDetached,
126            $jsonOutput,
127            $prettyJsonOutput,
128        );
129        $this->queueDevToolsCommand(
130            [
131                'copy-resource',
132                '--source=.editorconfig',
133                '--target=.editorconfig',
134                $overwrite ? '--overwrite' : null,
135                ...$modeArguments,
136            ],
137            $allowDetached,
138            $jsonOutput,
139            $prettyJsonOutput,
140        );
141        $this->queueDevToolsCommand(
142            [
143                'copy-resource',
144                '--source=resources/dependabot.yml',
145                '--target=.github/dependabot.yml',
146                $overwrite ? '--overwrite' : null,
147                ...$modeArguments,
148            ],
149            $allowDetached,
150            $jsonOutput,
151            $prettyJsonOutput,
152        );
153        $this->queueDevToolsCommand(
154            ['codeowners', $overwrite ? '--overwrite' : null, ...$modeArguments],
155            $allowDetached,
156            $jsonOutput,
157            $prettyJsonOutput,
158        );
159
160        if ($dryRun || $check || $interactive) {
161            $this->log(
162                'Skipping wiki, skills, and agents during preview/check modes because they do not yet expose non-destructive verification.',
163                $input,
164                logLevel: LogLevel::WARNING,
165            );
166        } else {
167            $this->queueDevToolsCommand(
168                ['wiki', '--target=.dev-tools/wiki-generated'],
169                true,
170                $jsonOutput,
171                $prettyJsonOutput
172            );
173            $this->queueDevToolsCommand(['skills'], true, $jsonOutput, $prettyJsonOutput);
174            $this->queueDevToolsCommand(['agents'], true, $jsonOutput, $prettyJsonOutput);
175        }
176
177        $this->queueDevToolsCommand(['gitignore', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
178        $this->queueDevToolsCommand(
179            ['gitattributes', ...$modeArguments],
180            $allowDetached,
181            $jsonOutput,
182            $prettyJsonOutput
183        );
184        $this->queueDevToolsCommand(['license', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
185        $this->queueDevToolsCommand(['git-hooks', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
186
187        $result = $this->processQueue->run($processOutput);
188
189        if (self::SUCCESS === $result) {
190            return $this->success('Dev-tools synchronization completed successfully.', $input, [
191                'output' => $processOutput,
192                'skipped_destructive_syncs' => $dryRun || $check || $interactive,
193            ]);
194        }
195
196        return $this->failure('Dev-tools synchronization failed.', $input, [
197            'output' => $processOutput,
198            'skipped_destructive_syncs' => $dryRun || $check || $interactive,
199        ]);
200    }
201
202    /**
203     * @param list<string|null> $arguments
204     * @param bool $detached
205     * @param bool $jsonOutput
206     * @param bool $prettyJsonOutput
207     */
208    private function queueDevToolsCommand(
209        array $arguments,
210        bool $detached = false,
211        bool $jsonOutput = false,
212        bool $prettyJsonOutput = false,
213    ): void {
214        $processBuilder = $this->processBuilder;
215        $arguments = array_filter($arguments, static fn(?string $arg): bool => null !== $arg);
216
217        if ($jsonOutput && ! \in_array('--json', $arguments, true)) {
218            $arguments[] = '--json';
219        }
220
221        if ($prettyJsonOutput && ! \in_array('--pretty-json', $arguments, true)) {
222            $arguments[] = '--pretty-json';
223        }
224
225        if (! $jsonOutput && ! $prettyJsonOutput && ! \in_array('--ansi', $arguments, true)) {
226            $arguments[] = '--ansi';
227        }
228
229        foreach ($arguments as $argument) {
230            $processBuilder = $processBuilder->withArgument($argument);
231        }
232
233        $process = $processBuilder->build([DevToolsPathResolver::getBinaryPath()]);
234
235        $this->processQueue->add(process: $process, detached: $detached, label: 'Running DevTools Sync Hook');
236    }
237}