Skip to content

Commit c1093fc

Browse files
committed
[sync] Add preview and check modes to sync (#62)
1 parent 839026b commit c1093fc

28 files changed

Lines changed: 916 additions & 49 deletions

docs/commands/copy-resource.rst

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ Usage
2222
.. code-block:: bash
2323
2424
composer copy-resource --source <path> --target <path>
25-
composer dev-tools copy-resource -- --source <path> --target <path> [--overwrite]
26-
vendor/bin/dev-tools copy-resource --source <path> --target <path> [--overwrite]
25+
composer dev-tools copy-resource -- --source <path> --target <path> [--overwrite] [--dry-run] [--check] [--interactive]
26+
vendor/bin/dev-tools copy-resource --source <path> --target <path> [--overwrite] [--dry-run] [--check] [--interactive]
2727
2828
Options
2929
-------
@@ -40,6 +40,16 @@ Options
4040
before copying. Unchanged targets are reported as skipped, and binary or
4141
unreadable files fall back to a clear non-diff message.
4242

43+
``--dry-run``
44+
Preview drift for existing managed resources without writing files.
45+
46+
``--check``
47+
Exit with code ``1`` when an existing managed resource differs from the
48+
packaged source.
49+
50+
``--interactive``
51+
Prompt before replacing a drifted resource.
52+
4353
Examples
4454
--------
4555

docs/commands/git-hooks.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ Options
3434
``--no-overwrite``
3535
Do not overwrite existing hook files.
3636

37+
``--dry-run``
38+
Preview managed Git hook drift without copying files.
39+
40+
``--check``
41+
Exit with code ``1`` when packaged Git hooks differ from the installed
42+
versions.
43+
44+
``--interactive``
45+
Prompt before replacing a drifted Git hook.
46+
3747
Examples
3848
--------
3949

docs/commands/gitattributes.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ Usage
2121
Options
2222
-------
2323

24-
This command does not accept additional options.
24+
``--dry-run``
25+
Preview managed ``.gitattributes`` drift without writing the file.
26+
27+
``--check``
28+
Exit with code ``1`` when ``.gitattributes`` needs an update.
29+
30+
``--interactive``
31+
Prompt before updating ``.gitattributes``.
2532

2633
Examples
2734
--------
@@ -57,3 +64,5 @@ Behavior
5764
- Deduplicates equivalent entries and sorts them (directories before files, then alphabetically).
5865
- Uses CandidateProvider, ExistenceChecker, ExportIgnoreFilter, Merger,
5966
Reader, and Writer components from the GitAttributes namespace.
67+
- ``--dry-run`` and ``--check`` render a diff against the normalized managed
68+
result before deciding whether to write.

docs/commands/gitignore.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ Options
2828
``--target, -t`` (optional)
2929
Path to the target .gitignore file (project). Default: project root .gitignore.
3030

31+
``--dry-run``
32+
Preview managed ``.gitignore`` drift without writing the file.
33+
34+
``--check``
35+
Exit with code ``1`` when ``.gitignore`` needs an update.
36+
37+
``--interactive``
38+
Prompt before updating ``.gitignore``.
39+
3140
Examples
3241
--------
3342

@@ -63,3 +72,5 @@ Behavior
6372
- By default, the source is the packaged .gitignore and the target is the project's root.
6473
- Duplicates are removed and entries are sorted alphabetically.
6574
- Uses the Reader, Merger, and Writer components from the GitIgnore namespace.
75+
- ``--dry-run`` and ``--check`` render a diff against the normalized managed
76+
result before deciding whether to write.

docs/commands/license.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ Options
2525
``--target`` (optional)
2626
Path to the target LICENSE file. Default: ``LICENSE``.
2727

28+
``--dry-run``
29+
Preview managed ``LICENSE`` drift without writing the file.
30+
31+
``--check``
32+
Exit with code ``1`` when ``LICENSE`` needs an update.
33+
34+
``--interactive``
35+
Prompt before writing the generated ``LICENSE`` content.
36+
2837
Examples
2938
--------
3039

@@ -60,4 +69,5 @@ Behavior
6069
- Supports common open-source licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause,
6170
GPL-3.0, LGPL-3.0, ISC, MPL-2.0.
6271
- Resolves placeholders using information from ``composer.json``.
63-
- Skips generation if a LICENSE file already exists.
72+
- Compares the generated content with the existing file before deciding whether
73+
to write.

docs/commands/sync.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ Options
3636
``copy-resource`` show a readable diff in the sync output before they are
3737
replaced.
3838

39+
``--dry-run``
40+
Preview managed-file drift without writing changes.
41+
42+
``--check``
43+
Report managed-file drift and exit with code ``1`` when updates are
44+
required.
45+
46+
``--interactive``
47+
Prompt before replacing drifted managed files.
48+
3949
Examples
4050
--------
4151

@@ -51,6 +61,18 @@ Sync with overwrite:
5161
5262
composer dev-tools:sync --overwrite
5363
64+
Preview managed-file drift:
65+
66+
.. code-block:: bash
67+
68+
composer dev-tools:sync --dry-run
69+
70+
Fail in CI when managed files drift:
71+
72+
.. code-block:: bash
73+
74+
composer dev-tools:sync --check
75+
5476
Exit Codes
5577
---------
5678

@@ -71,5 +93,12 @@ Behavior
7193
- Copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``.
7294
- When ``--overwrite`` is enabled, replaced text resources emit a unified diff
7395
so terminal sessions and CI logs show what changed.
96+
- ``--dry-run`` and ``--check`` verify managed-file drift for ``composer.json``,
97+
copied resources, ``.gitignore``, ``.gitattributes``, ``LICENSE``, and Git
98+
hooks.
99+
- ``--interactive`` prompts before replacing drifted managed files when the
100+
command is running in an interactive terminal.
74101
- Creates ``.github/wiki`` as a git submodule when missing.
75102
- Calls other commands in sequence.
103+
- ``wiki`` and ``skills`` are skipped in preview/check modes until they expose a
104+
non-destructive verification path.

docs/commands/update-composer-json.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ Options
2828
``--file, -f`` (optional)
2929
Path to the composer.json file to update. Default: ``composer.json``.
3030

31+
``--dry-run``
32+
Preview managed ``composer.json`` drift without writing the file.
33+
34+
``--check``
35+
Exit with code ``1`` when ``composer.json`` needs an update.
36+
37+
``--interactive``
38+
Prompt before updating ``composer.json``.
39+
3140
Examples
3241
--------
3342

@@ -49,6 +58,8 @@ Behavior
4958
- If the target composer.json does not exist, the command exits silently with code 0.
5059
- Existing scripts with the same name are overwritten.
5160
- The GrumPHP extra configuration is merged with existing configuration.
61+
- ``--dry-run`` and ``--check`` render a diff against the managed
62+
``composer.json`` result before deciding whether to write.
5263

5364
Exit Codes
5465
---------

src/Console/Command/CopyResourceCommand.php

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\Component\Console\Input\InputInterface;
2929
use Symfony\Component\Console\Input\InputOption;
3030
use Symfony\Component\Console\Output\OutputInterface;
31+
use Symfony\Component\Console\Question\ConfirmationQuestion;
3132
use Symfony\Component\Filesystem\Path;
3233

3334
/**
@@ -80,6 +81,21 @@ protected function configure(): void
8081
shortcut: 'o',
8182
mode: InputOption::VALUE_NONE,
8283
description: 'Overwrite existing target files.',
84+
)
85+
->addOption(
86+
name: 'dry-run',
87+
mode: InputOption::VALUE_NONE,
88+
description: 'Preview copied resources without writing files.',
89+
)
90+
->addOption(
91+
name: 'check',
92+
mode: InputOption::VALUE_NONE,
93+
description: 'Report copied-resource drift and exit non-zero when changes are required.',
94+
)
95+
->addOption(
96+
name: 'interactive',
97+
mode: InputOption::VALUE_NONE,
98+
description: 'Prompt before replacing drifted resources.',
8399
);
84100
}
85101

@@ -96,6 +112,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
96112
$source = (string) $input->getOption('source');
97113
$target = (string) $input->getOption('target');
98114
$overwrite = (bool) $input->getOption('overwrite');
115+
$dryRun = (bool) $input->getOption('dry-run');
116+
$check = (bool) $input->getOption('check');
117+
$interactive = (bool) $input->getOption('interactive');
99118

100119
if ('' === $source || '' === $target) {
101120
$output->writeln('<error>The --source and --target options are required.</error>');
@@ -107,10 +126,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
107126
$targetPath = (string) $this->filesystem->getAbsolutePath($target);
108127

109128
if (is_dir($sourcePath)) {
110-
return $this->copyDirectory($sourcePath, $targetPath, $overwrite, $output);
129+
return $this->copyDirectory($sourcePath, $targetPath, $overwrite, $dryRun, $check, $interactive, $input, $output);
111130
}
112131

113-
return $this->copyFile($sourcePath, $targetPath, $overwrite, $output);
132+
return $this->copyFile($sourcePath, $targetPath, $overwrite, $dryRun, $check, $interactive, $input, $output);
114133
}
115134

116135
/**
@@ -127,19 +146,28 @@ private function copyDirectory(
127146
string $sourcePath,
128147
string $targetPath,
129148
bool $overwrite,
149+
bool $dryRun,
150+
bool $check,
151+
bool $interactive,
152+
InputInterface $input,
130153
OutputInterface $output
131154
): int {
132155
$files = $this->finderFactory
133156
->create()
134157
->files()
135158
->in($sourcePath);
136159

160+
$status = self::SUCCESS;
161+
137162
foreach ($files as $file) {
138163
$destination = Path::join($targetPath, $file->getRelativePathname());
139-
$this->copyFile($file->getRealPath(), $destination, $overwrite, $output);
164+
$status = max(
165+
$status,
166+
$this->copyFile($file->getRealPath(), $destination, $overwrite, $dryRun, $check, $interactive, $input, $output),
167+
);
140168
}
141169

142-
return self::SUCCESS;
170+
return $status;
143171
}
144172

145173
/**
@@ -152,15 +180,24 @@ private function copyDirectory(
152180
*
153181
* @return int the command status code
154182
*/
155-
private function copyFile(string $sourcePath, string $targetPath, bool $overwrite, OutputInterface $output): int
183+
private function copyFile(
184+
string $sourcePath,
185+
string $targetPath,
186+
bool $overwrite,
187+
bool $dryRun,
188+
bool $check,
189+
bool $interactive,
190+
InputInterface $input,
191+
OutputInterface $output,
192+
): int
156193
{
157-
if (! $overwrite && $this->filesystem->exists($targetPath)) {
194+
if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($targetPath)) {
158195
$output->writeln(\sprintf('<comment>Skipped existing resource %s.</comment>', $targetPath));
159196

160197
return self::SUCCESS;
161198
}
162199

163-
if ($overwrite && $this->filesystem->exists($targetPath)) {
200+
if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($targetPath)) {
164201
$comparison = $this->overwriteDiffRenderer->render($sourcePath, $targetPath);
165202

166203
$output->writeln(\sprintf('<comment>%s</comment>', $comparison->summary()));
@@ -172,11 +209,41 @@ private function copyFile(string $sourcePath, string $targetPath, bool $overwrit
172209
if ($comparison->isUnchanged()) {
173210
return self::SUCCESS;
174211
}
212+
213+
if ($check) {
214+
return self::FAILURE;
215+
}
216+
217+
if ($dryRun) {
218+
return self::SUCCESS;
219+
}
220+
221+
if ($interactive && $input->isInteractive() && ! $this->shouldReplaceResource($input, $output, $targetPath)) {
222+
$output->writeln(\sprintf('<comment>Skipped replacing %s.</comment>', $targetPath));
223+
224+
return self::SUCCESS;
225+
}
175226
}
176227

177-
$this->filesystem->copy($sourcePath, $targetPath, $overwrite);
228+
$this->filesystem->copy($sourcePath, $targetPath, $overwrite || $interactive);
178229
$output->writeln(\sprintf('<info>Copied resource %s.</info>', $targetPath));
179230

180231
return self::SUCCESS;
181232
}
233+
234+
/**
235+
* Prompts whether a drifted resource should be replaced.
236+
*
237+
* @param InputInterface $input the command input
238+
* @param OutputInterface $output the command output
239+
* @param string $targetPath the resource path that would be replaced
240+
*
241+
* @return bool true when the replacement SHOULD proceed
242+
*/
243+
private function shouldReplaceResource(InputInterface $input, OutputInterface $output, string $targetPath): bool
244+
{
245+
$question = new ConfirmationQuestion(\sprintf('Replace drifted resource %s? [y/N] ', $targetPath), false);
246+
247+
return (bool) $this->getHelper('question')->ask($input, $output, $question);
248+
}
182249
}

0 commit comments

Comments
 (0)