Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.20% covered (success)
99.20%
124 / 125
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncCommand
99.20% covered (success)
99.20%
124 / 125
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%
86 / 86
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(['wiki', '--init'], true, $jsonOutput, $prettyJsonOutput);
168            $this->queueDevToolsCommand(['skills'], true, $jsonOutput, $prettyJsonOutput);
169            $this->queueDevToolsCommand(['agents'], true, $jsonOutput, $prettyJsonOutput);
170        }
171
172        $this->queueDevToolsCommand(['gitignore', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
173        $this->queueDevToolsCommand(
174            ['gitattributes', ...$modeArguments],
175            $allowDetached,
176            $jsonOutput,
177            $prettyJsonOutput
178        );
179        $this->queueDevToolsCommand(['license', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
180        $this->queueDevToolsCommand(['git-hooks', ...$modeArguments], $allowDetached, $jsonOutput, $prettyJsonOutput);
181
182        $result = $this->processQueue->run($processOutput);
183
184        if (self::SUCCESS === $result) {
185            return $this->success('Dev-tools synchronization completed successfully.', $input, [
186                'output' => $processOutput,
187                'skipped_destructive_syncs' => $dryRun || $check || $interactive,
188            ]);
189        }
190
191        return $this->failure('Dev-tools synchronization failed.', $input, [
192            'output' => $processOutput,
193            'skipped_destructive_syncs' => $dryRun || $check || $interactive,
194        ]);
195    }
196
197    /**
198     * @param list<string|null> $arguments
199     * @param bool $detached
200     * @param bool $jsonOutput
201     * @param bool $prettyJsonOutput
202     */
203    private function queueDevToolsCommand(
204        array $arguments,
205        bool $detached = false,
206        bool $jsonOutput = false,
207        bool $prettyJsonOutput = false,
208    ): void {
209        $processBuilder = $this->processBuilder;
210        $arguments = array_filter($arguments, static fn(?string $arg): bool => null !== $arg);
211
212        if ($jsonOutput && ! \in_array('--json', $arguments, true)) {
213            $arguments[] = '--json';
214        }
215
216        if ($prettyJsonOutput && ! \in_array('--pretty-json', $arguments, true)) {
217            $arguments[] = '--pretty-json';
218        }
219
220        if (! $jsonOutput && ! $prettyJsonOutput && ! \in_array('--ansi', $arguments, true)) {
221            $arguments[] = '--ansi';
222        }
223
224        foreach ($arguments as $argument) {
225            $processBuilder = $processBuilder->withArgument($argument);
226        }
227
228        $process = $processBuilder->build([DevToolsPathResolver::getBinaryPath()]);
229
230        $this->processQueue->add(process: $process, detached: $detached, label: 'Running DevTools Sync Hook');
231    }
232}