Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/read-only-missing-sync-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Keep resource and Git hook synchronization dry-run/check modes read-only when destinations are missing, reporting planned creations and failing checks without creating directories, copying files, rendering installed hooks or changing permissions.
9 changes: 9 additions & 0 deletions docs/commands/copy-resource.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,12 @@ Exit Codes
- Success. All resources were copied or skipped.
* - 1
- Failure. Missing required options or copy error.

Preview safety
--------------

Missing targets are reported as planned creations. ``--dry-run`` returns success
and ``--check`` returns failure without creating files or parent directories,
including when overwrite or interactive options are also supplied. When both
preview flags are supplied, ``--check`` determines the failure status. Existing
targets are compared without replacement or permission changes.
9 changes: 9 additions & 0 deletions docs/commands/git-hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,12 @@ Exit Codes
* - 1
- Failure. Drift detected in ``--check`` mode or one or more hooks could
not be rewritten automatically.

Preview safety
--------------

Missing targets are reported as planned creations. ``--dry-run`` returns success
and ``--check`` returns failure without creating files or parent directories,
including when overwrite or interactive options are also supplied. When both
preview flags are supplied, ``--check`` determines the failure status. Existing
targets are compared without replacement or permission changes.
14 changes: 14 additions & 0 deletions src/Console/Command/CopyResourceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,20 @@ private function copyFile(
InputInterface $input,
OutputInterface $output,
): int {
if (($dryRun || $check) && ! $this->filesystem->exists($targetPath)) {
$this->log(
'Resource {target_path} is missing and would be created.',
$input,
[
'source_path' => $sourcePath,
'target_path' => $targetPath,
],
LogLevel::NOTICE,
);

return $check ? self::FAILURE : self::SUCCESS;
}

if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($targetPath)) {
return $this->success(
'Skipped existing resource {target_path}.',
Expand Down
15 changes: 15 additions & 0 deletions src/Console/Command/GitHooksCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents, $projectPath);
$hookPath = Path::join($targetPath, $file->getRelativePathname());

if (($dryRun || $check) && ! $this->filesystem->exists($hookPath)) {
$this->log(
'Git hook {hook_path} is missing and would be installed.',
$input,
[
'hook_name' => $file->getFilename(),
'hook_path' => $hookPath,
],
LogLevel::NOTICE,
);
$checkFailure = $checkFailure || $check;

continue;
}

if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
$this->log(
'Skipped existing {hook_name} hook.',
Expand Down
69 changes: 66 additions & 3 deletions tests/Console/Command/CopyResourceCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use FastForward\DevTools\Path\DevToolsPathResolver;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
Expand Down Expand Up @@ -468,9 +469,71 @@ public function executeWillSkipReplacingDriftedFileWhenInteractiveConfirmationIs
self::assertSame(CopyResourceCommand::SUCCESS, $this->executeCommand());
}

/**
* @return int
*/
#[Test]
#[DataProvider('missingTargetPreviewModes')]
public function executeWillNotCreateMissingTargetsInPreviewModes(bool $directory, bool $dryRun, bool $check): void
{
$source = $directory ? $this->sourceDirectory : $this->sourceDirectory . '/nested/example.yml';
$target = $this->sourceDirectory . '/missing/output';
$destination = $directory ? $target . '/nested/example.yml' : $target;
$this->input->getOption('source')->willReturn($source);
$this->input->getOption('target')->willReturn($target);
$this->input->getOption('overwrite')->willReturn(true);
$this->input->getOption('interactive')->willReturn(true);
$this->input->getOption('dry-run')->willReturn($dryRun);
$this->input->getOption('check')->willReturn($check);
$this->fileLocator->locate($source)->willReturn($source);
$this->filesystem->getAbsolutePath($target)->willReturn($target);
$this->filesystem->exists($destination)->willReturn(false);
$this->finderFactory->create()->willReturn(new Finder());
$this->filesystem->copy(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->mkdir(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->dumpFile(Argument::cetera())->shouldNotBeCalled();
$this->io->askQuestion(Argument::cetera())->shouldNotBeCalled();
$this->logger->log('notice', 'Resource {target_path} is missing and would be created.', Argument::type('array'))
->shouldBeCalledOnce();

self::assertSame($check ? CopyResourceCommand::FAILURE : CopyResourceCommand::SUCCESS, $this->executeCommand());
self::assertDirectoryDoesNotExist($this->sourceDirectory . '/missing');
}

#[Test]
public function executeWillCheckAllFilesWithoutWritingWhenDirectoryContainsExistingAndMissingTargets(): void
{
$extraSource = $this->sourceDirectory . '/nested/second.yml';
file_put_contents($extraSource, 'second');
try {
$target = $this->sourceDirectory . '/missing';
$this->input->getOption('source')->willReturn($this->sourceDirectory);
$this->input->getOption('target')->willReturn($target);
$this->input->getOption('overwrite')->willReturn(false);
$this->input->getOption('check')->willReturn(true);
$this->fileLocator->locate($this->sourceDirectory)->willReturn($this->sourceDirectory);
$this->filesystem->getAbsolutePath($target)->willReturn($target);
$this->filesystem->exists($target . '/nested/example.yml')->willReturn(true);
$this->filesystem->exists($target . '/nested/second.yml')->willReturn(false);
$this->finderFactory->create()->willReturn(new Finder());
$this->fileDiffer->diff($this->sourceDirectory . '/nested/example.yml', $target . '/nested/example.yml')
->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Unchanged'))->shouldBeCalledOnce();
$this->filesystem->copy(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->mkdir(Argument::cetera())->shouldNotBeCalled();

self::assertSame(CopyResourceCommand::FAILURE, $this->executeCommand());
self::assertDirectoryDoesNotExist($target);
} finally {
unlink($extraSource);
}
}

public static function missingTargetPreviewModes(): iterable
{
foreach ([false, true] as $directory) {
yield [$directory, true, false];
yield [$directory, false, true];
yield [$directory, true, true];
}
}

private function executeCommand(): int
{
$reflectionMethod = new ReflectionMethod($this->command, 'execute');
Expand Down
43 changes: 40 additions & 3 deletions tests/Console/Command/GitHooksCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use FastForward\DevTools\Environment\RuntimeEnvironment;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\Attributes\UsesTrait;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -510,9 +511,45 @@ public function executeWillReportInstallFailureWhenReplacementStillCannotBeWritt
self::assertSame(GitHooksCommand::FAILURE, $this->executeCommand());
}

/**
* @return int
*/
#[Test]
#[DataProvider('missingHookPreviewModes')]
public function executeWillNotInstallMissingHooksInPreviewModes(bool $rendered, bool $dryRun, bool $check): void
{
$target = $this->sourceDirectory . '/missing/hooks';
$this->input->getOption('source')->willReturn($this->sourceDirectory);
$this->input->getOption('target')->willReturn($target);
$this->input->getOption('no-overwrite')->willReturn(false);
$this->input->getOption('interactive')->willReturn(true);
$this->input->getOption('dry-run')->willReturn($dryRun);
$this->input->getOption('check')->willReturn($check);
$this->fileLocator->locate($this->sourceDirectory)->willReturn($this->sourceDirectory);
$this->filesystem->getAbsolutePath($target)->willReturn($target);
$this->filesystem->exists($target . '/post-merge')->willReturn(false);
$this->filesystem->readFile($this->sourceDirectory . '/post-merge')
->willReturn($rendered ? '#!/bin/sh\n' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER : '#!/bin/sh');
$this->finderFactory->create()->willReturn(new Finder());
$this->filesystem->copy(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->dumpFile(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->chmod(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->remove(Argument::cetera())->shouldNotBeCalled();
$this->filesystem->mkdir(Argument::cetera())->shouldNotBeCalled();
$this->io->askQuestion(Argument::cetera())->shouldNotBeCalled();
$this->logger->log('notice', 'Git hook {hook_path} is missing and would be installed.', Argument::type('array'))
->shouldBeCalledOnce();

self::assertSame($check ? GitHooksCommand::FAILURE : GitHooksCommand::SUCCESS, $this->executeCommand());
self::assertDirectoryDoesNotExist($this->sourceDirectory . '/missing');
}

public static function missingHookPreviewModes(): iterable
{
foreach ([false, true] as $rendered) {
yield [$rendered, true, false];
yield [$rendered, false, true];
yield [$rendered, true, true];
}
}

private function executeCommand(): int
{
$reflectionMethod = new ReflectionMethod($this->command, 'execute');
Expand Down
34 changes: 31 additions & 3 deletions tests/Console/Command/SyncCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use FastForward\DevTools\Environment\RuntimeEnvironment;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\Attributes\UsesTrait;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -192,9 +193,36 @@ public function executeWillPropagateJsonOutputFormatToSubCommands(): void
self::assertSame(SyncCommand::SUCCESS, $this->executeCommand());
}

/**
* @return int
*/
#[Test]
#[DataProvider('readOnlyModes')]
public function executeWillForwardReadOnlyModeToEveryQueuedCommand(string $mode, int $status): void
{
$this->input->getOption($mode)->willReturn(true);
$commandLines = [];
$this->processQueue->add(Argument::type(Process::class), false, false, Argument::cetera())
->will(static function (array $arguments) use (&$commandLines): void {
$commandLines[] = $arguments[0]->getCommandLine();
})->shouldBeCalledTimes(10);
$this->processQueue->add(Argument::type(Process::class), false, true, Argument::cetera())->shouldNotBeCalled();
$this->processQueue->run($this->output->reveal())->willReturn($status);

self::assertSame($status, $this->executeCommand());
foreach ($commandLines as $commandLine) {
self::assertStringContainsString('--' . $mode, $commandLine);
self::assertStringNotContainsString("'wiki'", $commandLine);
self::assertStringNotContainsString("'skills'", $commandLine);
self::assertStringNotContainsString("'agents'", $commandLine);
}
self::assertCount(3, array_filter($commandLines, static fn(string $line): bool => str_contains($line, "'copy-resource'")));
self::assertCount(1, array_filter($commandLines, static fn(string $line): bool => str_contains($line, "'git-hooks'")));
}

public static function readOnlyModes(): iterable
{
yield 'dry-run' => ['dry-run', SyncCommand::SUCCESS];
yield 'check' => ['check', SyncCommand::FAILURE];
}

private function executeCommand(): int
{
return (new ReflectionMethod($this->command, 'execute'))
Expand Down
Loading