Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.18% covered (success)
99.18%
121 / 122
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncCommand
99.18% covered (success)
99.18%
121 / 122
75.00% covered (warning)
75.00%
3 / 4
27
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%
22 / 22
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
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
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 Composer\Command\BaseCommand;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Path\DevToolsPathResolver;
26use FastForward\DevTools\Process\ProcessBuilderInterface;
27use FastForward\DevTools\Process\ProcessQueueInterface;
28use Psr\Log\LoggerInterface;
29use Symfony\Component\Console\Attribute\AsCommand;
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    help: 'This command runs the dedicated synchronization commands for composer.json, resources, CODEOWNERS, funding metadata, wiki, git metadata, packaged skills, packaged agents, license, and Git hooks.'
42)]
43 class SyncCommand extends BaseCommand implements LoggerAwareCommandInterface
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->addJsonOption()
67            ->addOption(
68                name: 'overwrite',
69                shortcut: 'o',
70                mode: InputOption::VALUE_NONE,
71                description: 'Overwrite existing target files.',
72            )
73            ->addOption(
74                name: 'dry-run',
75                mode: InputOption::VALUE_NONE,
76                description: 'Preview managed-file drift without writing changes.',
77            )
78            ->addOption(
79                name: 'check',
80                mode: InputOption::VALUE_NONE,
81                description: 'Exit non-zero when managed-file drift is detected.',
82            )
83            ->addOption(
84                name: 'interactive',
85                mode: InputOption::VALUE_NONE,
86                description: 'Prompt before applying managed-file replacements.',
87            );
88    }
89
90    /**
91     * @param InputInterface $input
92     * @param OutputInterface $output
93     */
94    protected function execute(InputInterface $input, OutputInterface $output): int
95    {
96        $jsonOutput = $this->isJsonOutput($input);
97        $prettyJsonOutput = $this->isPrettyJsonOutput($input);
98        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
99        $overwrite = (bool) $input->getOption('overwrite');
100        $dryRun = (bool) $input->getOption('dry-run');
101        $check = (bool) $input->getOption('check');
102        $interactive = (bool) $input->getOption('interactive');
103        $modeArguments = [
104            $dryRun ? '--dry-run' : null,
105            $check ? '--check' : null,
106            $interactive ? '--interactive' : null,
107        ];
108        $allowDetached = ! $dryRun && ! $check && ! $interactive;
109
110        $this->logger->info('Starting dev-tools synchronization...', [
111            'input' => $input,
112        ]);
113
114        $this->queueDevToolsCommand(['update-composer-json', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
115        $this->queueDevToolsCommand(['funding', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput);
116        $this->queueDevToolsCommand(
117            [
118                'copy-resource',
119                '--source=resources/github-actions',
120                '--target=.github/workflows',
121                $overwrite ? '--overwrite' : null,
122                ...$modeArguments,
123            ],
124            $allowDetached,
125            $jsonOutput,
126            $prettyJsonOutput,
127        );
128        $this->queueDevToolsCommand(
129            [
130                'copy-resource',
131                '--source=.editorconfig',
132                '--target=.editorconfig',
133                $overwrite ? '--overwrite' : null,
134                ...$modeArguments,
135            ],
136            $allowDetached,
137            $jsonOutput,
138            $prettyJsonOutput,
139        );
140        $this->queueDevToolsCommand(
141            [
142                'copy-resource',
143                '--source=resources/dependabot.yml',
144                '--target=.github/dependabot.yml',
145                $overwrite ? '--overwrite' : null,
146                ...$modeArguments,
147            ],
148            $allowDetached,
149            $jsonOutput,
150            $prettyJsonOutput,
151        );
152        $this->queueDevToolsCommand(
153            ['codeowners', $overwrite ? '--overwrite' : null, ...$modeArguments],
154            $allowDetached,
155            $jsonOutput,
156            $prettyJsonOutput,
157        );
158
159        if ($dryRun || $check || $interactive) {
160            $this->logger->warning(
161                'Skipping wiki, skills, and agents during preview/check modes because they do not yet expose non-destructive verification.',
162                [
163                    'input' => $input,
164                ],
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        foreach ($arguments as $argument) {
221            $processBuilder = $processBuilder->withArgument($argument);
222        }
223
224        $process = $processBuilder->build(DevToolsPathResolver::getBinaryPath());
225
226        $this->processQueue->add($process, detached: $detached);
227    }
228}