From 165bb729061a8903f4cf429fc964ffddcb4e7a1f Mon Sep 17 00:00:00 2001 From: Marcel Reuss Date: Wed, 19 Aug 2026 21:05:27 +0200 Subject: [PATCH] feat(init): ignore generated files and make the example visible Three things a fresh `sputnik init` got wrong. The scaffold never mentioned .gitignore, so the compiled container Nette writes into .sputnik/cache on the first run was staged for the user's first commit, along with .sputnik.neon - the local override of the committed dist file. init now writes those two entries, creating .gitignore if the project has none and otherwise appending only what is missing. An existing file is never rewritten. The example task called $ctx->info() twice, which goes to the log and is only shown with -v: of the three lines it appeared to print, a user saw one. Those are writeln() now, and one info() stays with a comment saying what it does, because the distinction is worth learning at that point rather than looking broken. The scaffolded config had no pointer to either 0.2 feature. It now carries commented examples for variables.secrets and environment.executor, so the two things a project most often needs next are named in the file the user is told to edit. --- src/Console/Command/InitCommand.php | 78 +++++++++++++++++++- tests/Functional/Command/InitCommandTest.php | 39 ++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) 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');