Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.22% covered (success)
99.22%
127 / 128
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncCommand
99.22% covered (success)
99.22%
127 / 128
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%
89 / 89
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\LoggerInterface;
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     * @param LoggerInterface $logger
52     */
53    public function __construct(
54        private  ProcessBuilderInterface $processBuilder,
55        private  ProcessQueueInterface $processQueue,
56        private  LoggerInterface $logger,
57    ) {
58        parent::__construct();
59    }
60
61    /**
62     * {@inheritDoc}
63     */
64    protected function configure(): void
65    {
66        $this->setHelp(
67            'This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS,'
68            . ' funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'
69        );
70
71        $this->addJsonOption()
72            ->addOption(
73                name: 'overwrite',
74                shortcut: 'o',
75                mode: InputOption::VALUE_NONE,
76                description: 'Overwrite existing target files.',
77            )
78            ->addOption(
79                name: 'dry-run',
80                mode: InputOption::VALUE_NONE,
81                description: 'Preview managed-file drift without writing changes.',
82            )
83            ->addOption(
84                name: 'check',
85                mode: InputOption::VALUE_NONE,
86                description: 'Exit non-zero when managed-file drift is detected.',
87            )
88            ->addOption(
89                name: 'interactive',
90                mode: InputOption::VALUE_NONE,
91                description: 'Prompt before applying managed-file replacements.',
92            );
93    }
94
95    /**
96     * @param InputInterface $input
97     * @param OutputInterface $output
98     */
99    protected function execute(InputInterface $input, OutputInterface $output): int
100    {
101        $jsonOutput = $this->isJsonOutput($input);
102        $prettyJsonOutput = $this->isPrettyJsonOutput($input);
103        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
104        $overwrite = (bool) $input->getOption('overwrite');
105        $dryRun = (bool) $input->getOption('dry-run');
106        $check = (bool) $input->getOption('check');
107        $interactive = (bool) $input->getOption('interactive');
108        $modeArguments = [
109            $dryRun ? '--dry-run' : null,
110            $check ? '--check' : null,
111            $interactive ? '--interactive' : null,
112        ];
113        $allowDetached = ! $dryRun && ! $check && ! $interactive;
114
115        $this->logger->info('Starting dev-tools synchronization...', [
116            'input' => $input,
117        ]);
118
119        $this->queueDevToolsCommand(['update-composer-json', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
120        $this->queueDevToolsCommand(['funding', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
121        $this->queueDevToolsCommand(
122            [
123                'copy-resource',
124                '--source=resources/github-actions',
125                '--target=.github/workflows',
126                $overwrite ? '--overwrite' : null,
127                ...$modeArguments,
128            ],
129            $allowDetached,
130            $jsonOutput,
131            $prettyJsonOutput,
132        );
133        $this->queueDevToolsCommand(
134            [
135                'copy-resource',
136                '--source=.editorconfig',
137                '--target=.editorconfig',
138                $overwrite ? '--overwrite' : null,
139                ...$modeArguments,
140            ],
141            $allowDetached,
142            $jsonOutput,
143            $prettyJsonOutput,
144        );
145        $this->queueDevToolsCommand(
146            [
147                'copy-resource',
148                '--source=resources/dependabot.yml',
149                '--target=.github/dependabot.yml',
150                $overwrite ? '--overwrite' : null,
151                ...$modeArguments,
152            ],
153            $allowDetached,
154            $jsonOutput,
155            $prettyJsonOutput,
156        );
157        $this->queueDevToolsCommand(
158            ['codeowners', $overwrite ? '--overwrite' : null, ...$modeArguments],
159            $allowDetached,
160            $jsonOutput,
161            $prettyJsonOutput,
162        );
163
164        if ($dryRun || $check || $interactive) {
165            $this->logger->warning(
166                'Skipping wiki, skills, and agents during preview/check modes because they do not yet expose non-destructive verification.',
167                [
168                    'input' => $input,
169                ],
170            );
171        } else {
172            $this->queueDevToolsCommand(['wiki', '--init'], true, $jsonOutput, $prettyJsonOutput);
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}