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
78 changes: 75 additions & 3 deletions src/Console/Command/InitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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:');
Expand All @@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}");
}
Expand Down
39 changes: 39 additions & 0 deletions tests/Functional/Command/InitCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down