diff --git a/src/Console/Command/InitCommand.php b/src/Console/Command/InitCommand.php index 611c9e7..dfd71fe 100644 --- a/src/Console/Command/InitCommand.php +++ b/src/Console/Command/InitCommand.php @@ -21,6 +21,15 @@ final class InitCommand extends Command private const TASKS_DIR = 'sputnik'; + private const IGNORE_FILE = '.gitignore'; + + /** + * Sputnik writes these itself: the compiled container and the persisted + * context under .sputnik/, and .sputnik.neon as the local override of the + * committed .sputnik.dist.neon. + */ + private const array IGNORED_PATHS = ['/.sputnik/', '/.sputnik.neon']; + public function __construct(private readonly string $targetDir) { parent::__construct(); @@ -96,6 +105,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $skipped[] = self::TASKS_DIR . '/ExampleTask.php'; } + $ignorePath = $this->targetDir . '/' . self::IGNORE_FILE; + $hadIgnoreFile = file_exists($ignorePath); + + if (!$this->ignoreGeneratedFiles($ignorePath)) { + $io->error('Could not write ' . $ignorePath); + + return Command::FAILURE; + } + + if (!$hadIgnoreFile) { + $created[] = self::IGNORE_FILE; + } + // Report results if ($created !== []) { $io->success('Created:'); @@ -119,6 +141,41 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + /** + * Add the paths Sputnik generates to .gitignore, creating the file if the + * project has none. An existing file is only ever appended to, and only + * with the entries it does not already have. + */ + private function ignoreGeneratedFiles(string $path): bool + { + if (!file_exists($path)) { + return file_put_contents($path, implode("\n", self::IGNORED_PATHS) . "\n") !== false; + } + + $existing = file_get_contents($path); + + if ($existing === false) { + return false; + } + + $lines = preg_split('/\R/', $existing); + + if ($lines === false) { + return false; + } + + $missing = array_values(array_diff(self::IGNORED_PATHS, array_map(trim(...), $lines))); + + if ($missing === []) { + return true; + } + + $leadingNewline = str_ends_with($existing, "\n") ? '' : "\n"; + $block = $leadingNewline . "\n# Sputnik\n" . implode("\n", $missing) . "\n"; + + return file_put_contents($path, $block, \FILE_APPEND) !== false; + } + private function getConfigTemplate(): string { return <<<'NEON' @@ -151,6 +208,17 @@ private function getConfigTemplate(): string constants: app_name: MyApp + # Values Sputnik must never print. Every occurrence is replaced with *** in + # echoed commands, command output and log lines. + # secrets: + # apiToken: + # type: command + # command: "pass show project/api" + +# Route tasks marked environment: 'container' through a container executor. +# environment: +# executor: [ddev, exec] + defaults: context: local @@ -190,13 +258,17 @@ public function __invoke(TaskContext $ctx): TaskResult $context = $ctx->getContextName(); $ctx->success("Hello, {$name}!"); - $ctx->info("App: {$appName}"); - $ctx->info("Context: {$context}"); + $ctx->writeln("App: {$appName}"); + $ctx->writeln("Context: {$context}"); + + // info(), warning() and error() go to the log, which is only shown + // with -v. Use writeln() or success() for output the user should see. + $ctx->info('This line needs -v to appear'); // Run a program without a shell (uncomment to try). Arguments are // passed through as they are, so nothing needs escaping. // $result = $ctx->exec(['echo', 'Hello from {{ app_name }}']); - // $ctx->info($result->output); + // $ctx->writeln($result->getOutput()); return TaskResult::success("Greeted {$name}"); } diff --git a/tests/Functional/Command/InitCommandTest.php b/tests/Functional/Command/InitCommandTest.php index 2e5211c..89d39ea 100644 --- a/tests/Functional/Command/InitCommandTest.php +++ b/tests/Functional/Command/InitCommandTest.php @@ -60,6 +60,45 @@ public function testInitWritesToItsTargetDirectoryNotTheCurrentOne(): void } } + public function testInitIgnoresTheGeneratedStateDirectory(): void + { + $tester = $this->tester(); + $tester->execute([]); + + // Nette compiles the container into .sputnik/cache on the first run, so + // without this a fresh project commits generated code on its first push. + $this->assertFileExists($this->tempDir . '/.gitignore'); + + $ignored = (string) file_get_contents($this->tempDir . '/.gitignore'); + + $this->assertStringContainsString('/.sputnik/', $ignored); + $this->assertStringContainsString('/.sputnik.neon', $ignored); + } + + public function testInitAppendsToAnExistingGitignoreWithoutTouchingIt(): void + { + file_put_contents($this->tempDir . '/.gitignore', "/vendor/\n"); + + $tester = $this->tester(); + $tester->execute([]); + + $ignored = (string) file_get_contents($this->tempDir . '/.gitignore'); + + $this->assertStringStartsWith("/vendor/\n", $ignored); + $this->assertStringContainsString('/.sputnik/', $ignored); + } + + public function testInitLeavesAGitignoreThatAlreadyCoversSputnikAlone(): void + { + $existing = "/vendor/\n/.sputnik/\n/.sputnik.neon\n"; + file_put_contents($this->tempDir . '/.gitignore', $existing); + + $tester = $this->tester(); + $tester->execute([]); + + $this->assertSame($existing, file_get_contents($this->tempDir . '/.gitignore')); + } + public function testInitSkipsExistingFiles(): void { file_put_contents($this->tempDir . '/.sputnik.dist.neon', 'existing');