diff --git a/bin/sputnik b/bin/sputnik index 025452f..22cc37c 100755 --- a/bin/sputnik +++ b/bin/sputnik @@ -33,43 +33,63 @@ if (\Phar::running() !== '') { use Sputnik\Kernel; use Symfony\Component\Console\Output\ConsoleOutput; -// Determine working directory -$workingDir = getcwd(); +// Determine working directory. Null until --working-dir says otherwise, because +// the default is the project directory, which is not known yet. +$workingDirOption = null; // Check for --working-dir option $args = $_SERVER['argv']; foreach ($args as $i => $arg) { if (str_starts_with($arg, '--working-dir=')) { - $workingDir = substr($arg, 14); + $workingDirOption = substr($arg, 14); unset($args[$i]); $_SERVER['argv'] = array_values($args); break; } if ($arg === '--working-dir' && isset($args[$i + 1])) { - $workingDir = $args[$i + 1]; + $workingDirOption = $args[$i + 1]; unset($args[$i], $args[$i + 1]); $_SERVER['argv'] = array_values($args); break; } } -// Make the project root the working directory of the process, so a task doing -// its own file I/O sees the same place its commands run in. Until now exec() ran -// in the project root while file_exists() in the task resolved against the -// caller's cwd, which is why a wrapper had to cd before calling the binary. +// Two directories, one question each. // -// Resolving to an absolute path first is not cosmetic: a relative -// --working-dir would otherwise be looked up inside itself after the chdir. -// It also keeps the container cache key stable, which includes this path. -if ($workingDir === false || !is_dir($workingDir)) { - fwrite(\STDERR, \sprintf( - "Working directory does not exist: %s\n", - $workingDir === false ? '(could not be determined)' : $workingDir, - )); +// The project directory is where the config lives, and with it the container +// cache and the persisted context. It is searched for upwards, the way git and +// composer find their root, so a call from a subdirectory works and nothing is +// written beside the caller. No config anywhere above means no project - then +// only the built-in init has anything to do. +// +// The working directory is where tasks run: the cwd of exec() and shell(), and +// what relative file access in a task resolves against. It defaults to the +// project directory and only --working-dir moves it. +// +// Resolving to an absolute path before the chdir is not cosmetic: a relative +// --working-dir would otherwise be looked up inside itself, and the container +// cache key contains this path. +$cwd = getcwd(); + +if ($cwd === false) { + fwrite(\STDERR, "Could not determine the current directory\n"); + + exit(1); +} + +if ($workingDirOption !== null && !is_dir($workingDirOption)) { + fwrite(\STDERR, 'Working directory does not exist: ' . $workingDirOption . "\n"); exit(1); } +// If --working-dir points somewhere with no project of its own, the project you +// are standing in is kept: running a task in an unrelated directory must not take +// the tasks away. Where both have one, the explicit argument wins. +$projectDir = $workingDirOption === null + ? Sputnik\Support\ProjectLocator::locate($cwd) + : Sputnik\Support\ProjectLocator::locate($workingDirOption) ?? Sputnik\Support\ProjectLocator::locate($cwd); +$workingDir = $workingDirOption ?? $projectDir ?? $cwd; $resolvedWorkingDir = realpath($workingDir); if ($resolvedWorkingDir === false || !@chdir($resolvedWorkingDir)) { @@ -103,7 +123,7 @@ $output = new ConsoleOutput(); // Bootstrap and run try { - $kernel = new Kernel(workingDir: $workingDir, contextName: $contextOverride); + $kernel = new Kernel(workingDir: $workingDir, contextName: $contextOverride, projectDir: $projectDir); $output = new Sputnik\Secret\RedactingConsoleOutput($output, $kernel->getSecretRedactor()); // Everything that writes shares this destination: tasks, the commands, and diff --git a/docs/cli.md b/docs/cli.md index 5c07d44..fce88da 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,10 +24,29 @@ sputnik --working-dir /var/www/myproject deploy sputnik --working-dir=/var/www/myproject deploy ``` -Sputnik enters that directory, so it is the working directory of the process: -config files, task directories and templates resolve against it, commands run in -it, and a task's own file access -- `file_exists('.ddev/config.yaml')`, -`file_get_contents('dev-ops/config.yaml')` -- sees the same place. +Change where tasks run -- the cwd of `exec()` and `shell()`, and what relative +file access in a task resolves against. Sputnik enters that directory, so +`file_exists('.env')` in a task and the commands it runs see the same place. + +It does **not** move the project by itself. The config, the persisted context and +the container cache stay in the +[project directory](project-structure.md#the-project-directory), which is found +by searching upwards. Without this option, tasks run in the project directory. + +```bash +sputnik --working-dir=frontend npm:ci # runs in frontend/, state stays at the root +``` + +Which project applies is decided by what sits at or above the directory you name: + +| `--working-dir` points at | Project used | +|---|---| +| a subdirectory of your project | yours -- the search walks up into it | +| a directory with no config above it | yours -- running somewhere unrelated does not take your tasks away | +| a different project | **that** one, with its own tasks and its own state | + +The last row is the rule in short: naming a directory behaves as if you had `cd`'d +there, and only a directory with no project of its own leaves you with yours. A relative path is resolved against the directory you called from. If the directory does not exist, Sputnik says so and stops. diff --git a/docs/configuration.md b/docs/configuration.md index 46d5d28..9b8ebbc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,11 @@ Sputnik is configured using [NEON](https://ne-on.org/) files located in the proj Both files are automatically loaded and deep-merged. Nested keys are merged recursively, scalar values are replaced. Either file can exist on its own. +Whichever of them exists marks the **project directory**, which Sputnik looks for +upwards from the current directory. Paths declared in the config -- task +directories, template sources and targets -- resolve against it, not against +wherever you happened to run the command from. + See [Project Structure](project-structure.md) for details on file locations and `.gitignore` recommendations. --- diff --git a/docs/project-structure.md b/docs/project-structure.md index 30417a7..06c63f1 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -31,11 +31,39 @@ Local overrides. Gitignored. Values are deep-merged on top of `.sputnik.dist.neo Either file can exist on its own. If both exist, they are merged. If neither exists, Sputnik starts with an empty configuration (only built-in commands available). +## The Project Directory + +The project is the directory holding `.sputnik.dist.neon` (or `.sputnik.neon`). +Sputnik searches for it **upwards** from where you are, the way `git` and +`composer` find their root, so a command works from anywhere inside the project: + +```bash +cd htdocs/web/sites/default +sputnik deploy # same project, same state, same place the commands run +``` + +Everything project-local lives there and only there: the config, the compiled +container and the persisted context. Outside a project -- no config in any parent +directory -- there is nothing to persist, and Sputnik writes nothing beside you: + +```bash +cd /tmp/somewhere +sputnik --version # leaves the directory exactly as it was +sputnik init # this is what you came for +``` + +Tasks run in the project directory by default. `--working-dir` moves **that** and +nothing else: the project keeps its state where it is. + +```bash +sputnik --working-dir=frontend npm:ci # runs in frontend/, state stays at the root +``` + ## Runtime Directory ### `.sputnik/` -Auto-created on first run. Contains: +Created in the project directory on first run. Contains: - **`state.json`** -- stores the currently active context name. Updated by `context:switch`. - **`cache/`** -- compiled Nette DI container classes. Automatically invalidated when task files change, configuration changes, or the Sputnik version changes. diff --git a/src/Context/ContextManager.php b/src/Context/ContextManager.php index 7f1a2b6..075bb45 100644 --- a/src/Context/ContextManager.php +++ b/src/Context/ContextManager.php @@ -17,7 +17,12 @@ final class ContextManager public function __construct( private readonly Configuration $config, - private readonly string $workingDir, + /** + * The project directory, or null when there is no project - a context + * cannot be remembered for something that does not exist, and writing + * it next to the caller is how stray .sputnik directories appeared. + */ + private readonly ?string $projectDir, ) { } @@ -120,24 +125,26 @@ public function getContextDescription(string $contextName): ?string /** * Get the state directory path. */ - public function getStateDir(): string + public function getStateDir(): ?string { - return $this->workingDir . '/' . self::STATE_DIR; + return $this->projectDir === null ? null : $this->projectDir . '/' . self::STATE_DIR; } /** * Get the state file path. */ - public function getStateFilePath(): string + public function getStateFilePath(): ?string { - return $this->getStateDir() . '/' . self::STATE_FILE; + $dir = $this->getStateDir(); + + return $dir === null ? null : $dir . '/' . self::STATE_FILE; } private function loadPersistedContext(): ?string { $path = $this->getStateFilePath(); - if (file_exists($path)) { + if ($path !== null && file_exists($path)) { $content = file_get_contents($path); if ($content === false) { return null; @@ -156,7 +163,13 @@ private function loadPersistedContext(): ?string private function migrateOldStateFile(): ?string { - $oldPath = $this->getStateDir() . '/context'; + $dir = $this->getStateDir(); + + if ($dir === null) { + return null; + } + + $oldPath = $dir . '/context'; if (!file_exists($oldPath)) { return null; @@ -182,6 +195,10 @@ private function persistContext(string $contextName): void { $dir = $this->getStateDir(); + if ($dir === null) { + return; + } + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { throw new SputnikRuntimeException('Could not create state directory: ' . $dir); } @@ -192,13 +209,14 @@ private function persistContext(string $contextName): void 'version' => 1, ]; + $path = $dir . '/' . self::STATE_FILE; $result = file_put_contents( - $this->getStateFilePath(), + $path, json_encode($state, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . "\n", ); if ($result === false) { - throw new SputnikRuntimeException('Could not write state file: ' . $this->getStateFilePath()); + throw new SputnikRuntimeException('Could not write state file: ' . $path); } } } diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 737be6f..f481382 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -18,6 +18,7 @@ final class ContainerFactory public function __construct( private readonly Configuration $config, + private readonly ?string $projectDir, private readonly string $workingDir, private readonly string $contextName, private readonly bool $debugMode = false, @@ -26,7 +27,7 @@ public function __construct( public function create(): Container { - $cacheDir = $this->workingDir . '/' . self::CACHE_DIR; + $cacheDir = $this->cacheDir(); if (!is_dir($cacheDir) && !mkdir($cacheDir, 0755, true) && !is_dir($cacheDir)) { throw new SputnikRuntimeException('Could not create cache directory: ' . $cacheDir); @@ -43,6 +44,7 @@ function (Compiler $compiler): ?string { [ $this->config->all(), $this->contextName, + $this->projectDir, $this->workingDir, $this->getTaskFilesHash(), Application::VERSION, @@ -53,6 +55,22 @@ function (Compiler $compiler): ?string { return new $containerClass(); } + /** + * The compiled container belongs to the project. Without one there is + * nothing to keep between runs, and writing beside the caller is how a + * .sputnik directory appeared in every directory the binary was invoked + * from - so it goes to the system temp directory instead, keyed like any + * other build of this container. + */ + private function cacheDir(): string + { + if ($this->projectDir !== null) { + return $this->projectDir . '/' . self::CACHE_DIR; + } + + return sys_get_temp_dir() . '/sputnik-cache-' . md5(Application::VERSION); + } + /** * Fingerprint Sputnik's own sources, so any change to the service graph or * to a wired class invalidates the cache even when Application::VERSION is @@ -71,7 +89,7 @@ private function getServiceDefinitionsFingerprint(): string private function getTaskFilesHash(): string { $files = []; - $directories = $this->config->getTaskDirectories($this->workingDir); + $directories = $this->config->getTaskDirectories($this->projectDir ?? $this->workingDir); foreach ($directories as $directory) { if (!is_dir($directory)) { @@ -99,6 +117,7 @@ private function configureCompiler(Compiler $compiler): void // Add parameters $compiler->addConfig([ 'parameters' => [ + 'projectDir' => $this->projectDir, 'workingDir' => $this->workingDir, 'contextName' => $this->contextName, 'debug' => $this->debugMode, @@ -106,6 +125,6 @@ private function configureCompiler(Compiler $compiler): void ]); // Add extensions - $compiler->addExtension('sputnik', new SputnikExtension($this->config, $this->workingDir)); + $compiler->addExtension('sputnik', new SputnikExtension($this->config, $this->projectDir ?? $this->workingDir, $this->workingDir)); } } diff --git a/src/DependencyInjection/SputnikExtension.php b/src/DependencyInjection/SputnikExtension.php index 2c6429e..2308541 100644 --- a/src/DependencyInjection/SputnikExtension.php +++ b/src/DependencyInjection/SputnikExtension.php @@ -41,6 +41,13 @@ final class SputnikExtension extends CompilerExtension public function __construct( private readonly Configuration $sputnikConfig, + /** + * Where config-declared paths resolve: task directories, templates. + */ + private readonly string $projectDir, + /** + * Where tasks run. + */ private readonly string $workingDir, ) { } @@ -82,7 +89,9 @@ public function loadConfiguration(): void $builder->addDefinition($this->prefix('contextManager')) ->setFactory(ContextManager::class, [ 'config' => $this->prefix('@config'), - 'workingDir' => $params['workingDir'], + // The persisted context belongs to the project, not to wherever + // the binary was invoked. + 'projectDir' => $params['projectDir'], ]) ->setAutowired(true); @@ -213,7 +222,7 @@ public function beforeCompile(): void private function getTaskDiscovery(): TaskDiscovery { if (!$this->taskDiscovery instanceof TaskDiscovery) { - $taskDirs = $this->sputnikConfig->getTaskDirectories($this->workingDir); + $taskDirs = $this->sputnikConfig->getTaskDirectories($this->projectDir); $taskClasses = $this->sputnikConfig->getTaskClasses(); $this->taskDiscovery = new TaskDiscovery($taskDirs, $taskClasses); } diff --git a/src/Kernel.php b/src/Kernel.php index 0b822f5..a1cf96f 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -22,6 +22,7 @@ use Sputnik\Exception\RuntimeException as SputnikRuntimeException; use Sputnik\Secret\SecretRedactor; use Sputnik\Secret\SecretRegistry; +use Sputnik\Support\ProjectLocator; use Sputnik\Task\TaskDiscovery; use Sputnik\Task\TaskRunner; use Sputnik\Template\TemplateEngine; @@ -36,17 +37,37 @@ final class Kernel private string $workingDir; + private ?string $projectDir; + private string $contextName; private bool $debugMode; + /** + * @param string|null $workingDir Where tasks run. Defaults to the project + * directory, or to the current directory when + * there is no project. + * @param string|null $projectDir The project directory. Located by searching + * upwards from $workingDir when not given; + * null means there is no project, and nothing + * may be persisted next to the caller. + */ public function __construct( ?string $workingDir = null, ?string $contextName = null, bool $debugMode = false, + ?string $projectDir = null, ) { $cwdResult = getcwd(); - $this->workingDir = $workingDir ?? ($cwdResult !== false ? $cwdResult : throw new SputnikRuntimeException('Could not determine working directory')); + $cwd = $cwdResult !== false ? $cwdResult : throw new SputnikRuntimeException('Could not determine working directory'); + + // A working directory without a project of its own keeps the project of + // the current directory, so a task can run somewhere unrelated without + // losing the tasks. + $this->projectDir = $projectDir + ?? ProjectLocator::locate($workingDir ?? $cwd) + ?? ($workingDir === null ? null : ProjectLocator::locate($cwd)); + $this->workingDir = $workingDir ?? $this->projectDir ?? $cwd; $this->debugMode = $debugMode; $this->loadConfig(); @@ -156,8 +177,12 @@ private function getConfigFileDisplay(): string $base = '.sputnik.dist.neon'; $local = '.sputnik.neon'; - $hasBase = file_exists($this->workingDir . '/' . $base); - $hasLocal = file_exists($this->workingDir . '/' . $local); + if ($this->projectDir === null) { + return 'no config'; + } + + $hasBase = file_exists($this->projectDir . '/' . $base); + $hasLocal = file_exists($this->projectDir . '/' . $local); if (!$hasBase && !$hasLocal) { return 'no config'; @@ -174,7 +199,7 @@ private function getConfigFileDisplay(): string private function registerTaskAutoloader(): void { - $directories = $this->config->getTaskDirectories($this->workingDir); + $directories = $this->config->getTaskDirectories($this->projectDir ?? $this->workingDir); if ($directories === []) { return; @@ -187,7 +212,7 @@ private function registerTaskAutoloader(): void private function loadConfig(): void { - $loader = new ConfigLoader($this->workingDir); + $loader = new ConfigLoader($this->projectDir ?? $this->workingDir); if (!$loader->hasConfig()) { $this->config = new Configuration([]); @@ -207,7 +232,7 @@ private function initializeContextName(?string $contextName): void } // Create temporary context manager to read persisted context - $tempContextManager = new ContextManager($this->config, $this->workingDir); + $tempContextManager = new ContextManager($this->config, $this->projectDir); $this->contextName = $tempContextManager->getCurrentContext(); } @@ -215,6 +240,7 @@ private function buildContainer(): void { $factory = new ContainerFactory( config: $this->config, + projectDir: $this->projectDir, workingDir: $this->workingDir, contextName: $this->contextName, debugMode: $this->debugMode, diff --git a/src/Support/ProjectLocator.php b/src/Support/ProjectLocator.php new file mode 100644 index 0000000..09f5db8 --- /dev/null +++ b/src/Support/ProjectLocator.php @@ -0,0 +1,47 @@ +assertStringContainsString('deploy', $output); } + public function testADirectoryWithoutAProjectStaysEmpty(): void + { + // .sputnik used to appear wherever the binary ran, because both the + // container cache and the persisted context hung off the working + // directory rather than off the project. + $empty = $this->tempDir . '/empty'; + mkdir($empty, 0755, true); + + foreach ([['--version'], ['--help'], ['list']] as $args) { + $this->sputnik($args, $empty); + } + + $this->assertSame(['.', '..'], scandir($empty), 'Nothing may be written next to a caller without a project'); + } + + public function testTheProjectIsFoundFromASubdirectory(): void + { + $this->scaffoldProject([ + 'where' => <<<'PHP' + #[Task(name: 'where', description: 'Reports its directories')] + final class WhereTask implements TaskInterface + { + public function __invoke(TaskContext $ctx): TaskResult + { + $ctx->writeln('cwd=' . getcwd()); + + return TaskResult::success(); + } + } + PHP, + ]); + + $deep = $this->tempDir . '/htdocs/web'; + mkdir($deep, 0755, true); + + $result = $this->sputnik(['where'], $deep); + + $this->assertSame(0, $result->getExitCode(), 'Called from a subdirectory it used to report "no config"'); + $this->assertStringContainsString('cwd=' . $this->tempDir, $result->getOutput()); + $this->assertSame(['.', '..'], scandir($deep), 'No state beside the caller'); + $this->assertDirectoryExists($this->tempDir . '/.sputnik', 'State belongs to the project root'); + } + + public function testWorkingDirMovesExecutionButNotTheProject(): void + { + $this->scaffoldProject([ + 'where' => <<<'PHP' + #[Task(name: 'where', description: 'Reports its directories')] + final class WhereTask implements TaskInterface + { + public function __invoke(TaskContext $ctx): TaskResult + { + $ctx->writeln('cwd=' . getcwd()); + + return TaskResult::success(); + } + } + PHP, + ]); + + $sub = $this->tempDir . '/frontend'; + mkdir($sub, 0755, true); + + $result = $this->sputnik(['--working-dir=' . $sub, 'where'], $this->tempDir); + + $this->assertSame(0, $result->getExitCode()); + $this->assertStringContainsString('cwd=' . $sub, $result->getOutput(), 'Tasks run where asked'); + $this->assertSame(['.', '..'], scandir($sub), 'But the project keeps its state'); + $this->assertDirectoryExists($this->tempDir . '/.sputnik'); + } + + public function testATaskCanRunInADirectoryOutsideItsProject(): void + { + // Standing in a project and pointing --working-dir somewhere unrelated + // has to keep the project: otherwise the tasks are gone, which is what + // both this and the previous behaviour did. + $this->scaffoldProject([ + 'where' => <<<'PHP' + #[Task(name: 'where', description: 'Reports its directories')] + final class WhereTask implements TaskInterface + { + public function __invoke(TaskContext $ctx): TaskResult + { + $ctx->writeln('cwd=' . getcwd()); + + return TaskResult::success(); + } + } + PHP, + ]); + + $unrelated = \dirname($this->tempDir) . '/sputnik-unrelated-' . getmypid(); + mkdir($unrelated, 0755, true); + + try { + $result = $this->sputnik(['--working-dir=' . $unrelated, 'where'], $this->tempDir); + + $this->assertSame(0, $result->getExitCode(), 'The project has to survive an unrelated working directory'); + $this->assertStringContainsString('cwd=' . $unrelated, $result->getOutput()); + $this->assertSame(['.', '..'], scandir($unrelated), 'And nothing may be written there'); + $this->assertDirectoryExists($this->tempDir . '/.sputnik'); + } finally { + @rmdir($unrelated); + } + } + + public function testInitStillScaffoldsWhereThereIsNoProject(): void + { + $empty = $this->tempDir . '/fresh'; + mkdir($empty, 0755, true); + + $result = $this->sputnik(['init'], $empty); + + $this->assertSame(0, $result->getExitCode()); + $this->assertFileExists($empty . '/.sputnik.dist.neon'); + $this->assertFileExists($empty . '/sputnik/ExampleTask.php'); + } + private function sputnik(array $args, ?string $cwd = null): Process { $process = new Process( diff --git a/tests/Integration/DependencyInjection/ContainerFactoryTest.php b/tests/Integration/DependencyInjection/ContainerFactoryTest.php index 0af4744..57bcb9b 100644 --- a/tests/Integration/DependencyInjection/ContainerFactoryTest.php +++ b/tests/Integration/DependencyInjection/ContainerFactoryTest.php @@ -37,7 +37,7 @@ protected function tearDown(): void public function testCreateReturnsContainer(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); @@ -47,7 +47,7 @@ public function testCreateReturnsContainer(): void public function testContainerProvidesConfiguration(): void { $config = new Configuration(['foo' => 'bar']); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $resolvedConfig = $container->getByType(Configuration::class); @@ -59,7 +59,7 @@ public function testContainerProvidesConfiguration(): void public function testContainerProvidesLogger(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $logger = $container->getByType(LoggerInterface::class); @@ -70,7 +70,7 @@ public function testContainerProvidesLogger(): void public function testContainerProvidesShellExecutor(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $executor = $container->getByType(ShellExecutor::class); @@ -83,7 +83,7 @@ public function testContainerProvidesContextManager(): void $config = new Configuration([ 'contexts' => ['local' => []], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'local'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'local'); $container = $factory->create(); $contextManager = $container->getByType(ContextManager::class); @@ -96,7 +96,7 @@ public function testContainerProvidesVariableResolver(): void $config = new Configuration([ 'variables' => ['name' => 'value'], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $resolver = $container->getByType(VariableResolver::class); @@ -107,7 +107,7 @@ public function testContainerProvidesVariableResolver(): void public function testContainerProvidesTaskDiscovery(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $discovery = $container->getByType(TaskDiscovery::class); @@ -118,7 +118,7 @@ public function testContainerProvidesTaskDiscovery(): void public function testContainerProvidesListenerDiscovery(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $discovery = $container->getByType(ListenerDiscovery::class); @@ -129,7 +129,7 @@ public function testContainerProvidesListenerDiscovery(): void public function testContainerProvidesEventDispatcher(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $dispatcher = $container->getByType(EventDispatcherInterface::class); @@ -140,7 +140,7 @@ public function testContainerProvidesEventDispatcher(): void public function testContainerProvidesTemplateEngine(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $engine = $container->getByType(TemplateEngine::class); @@ -151,7 +151,7 @@ public function testContainerProvidesTemplateEngine(): void public function testContainerProvidesTaskRunner(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $runner = $container->getByType(TaskRunner::class); @@ -162,7 +162,7 @@ public function testContainerProvidesTaskRunner(): void public function testContainerCachesCompilation(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); // Create container twice $container1 = $factory->create(); @@ -175,7 +175,7 @@ public function testContainerCachesCompilation(): void public function testContainerCreatesCacheDirectory(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $factory->create(); @@ -185,11 +185,11 @@ public function testContainerCreatesCacheDirectory(): void public function testCacheInvalidatesWhenConfigChanges(): void { $config1 = new Configuration(['variables' => ['constants' => ['foo' => 'bar']]]); - $factory1 = new ContainerFactory($config1, $this->tempDir, 'default'); + $factory1 = new ContainerFactory($config1, $this->tempDir, $this->tempDir, 'default'); $container1 = $factory1->create(); $config2 = new Configuration(['variables' => ['constants' => ['foo' => 'baz']]]); - $factory2 = new ContainerFactory($config2, $this->tempDir, 'default'); + $factory2 = new ContainerFactory($config2, $this->tempDir, $this->tempDir, 'default'); $container2 = $factory2->create(); $this->assertNotSame($container1::class, $container2::class); @@ -198,10 +198,10 @@ public function testCacheInvalidatesWhenConfigChanges(): void public function testCacheInvalidatesWhenContextChanges(): void { $config = new Configuration([]); - $factory1 = new ContainerFactory($config, $this->tempDir, 'dev'); + $factory1 = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'dev'); $container1 = $factory1->create(); - $factory2 = new ContainerFactory($config, $this->tempDir, 'prod'); + $factory2 = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'prod'); $container2 = $factory2->create(); $this->assertNotSame($container1::class, $container2::class); @@ -227,14 +227,14 @@ public function __invoke(TaskContext $ctx): TaskResult { PHP); $config = new Configuration(['tasks' => ['directories' => ['sputnik']]]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container1 = $factory->create(); // Touch the task file to change its mtime sleep(1); touch($taskDir . '/FooTask.php'); - $factory2 = new ContainerFactory($config, $this->tempDir, 'default'); + $factory2 = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container2 = $factory2->create(); $this->assertNotSame($container1::class, $container2::class); @@ -244,7 +244,7 @@ public function testDebugModeAffectsContainer(): void { $config = new Configuration([]); - $factoryDebug = new ContainerFactory($config, $this->tempDir, 'default', debugMode: true); + $factoryDebug = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default', debugMode: true); $containerDebug = $factoryDebug->create(); // In debug mode, container class name should differ on each recompile @@ -255,7 +255,7 @@ public function testDebugModeAffectsContainer(): void public function testContainerParametersAreSet(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'mycontext'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'mycontext'); $container = $factory->create(); diff --git a/tests/Integration/DependencyInjection/SecretServicesWiringTest.php b/tests/Integration/DependencyInjection/SecretServicesWiringTest.php index 42b83d7..ae9aee6 100644 --- a/tests/Integration/DependencyInjection/SecretServicesWiringTest.php +++ b/tests/Integration/DependencyInjection/SecretServicesWiringTest.php @@ -30,7 +30,7 @@ protected function tearDown(): void public function testContainerProvidesSecretRedactor(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $redactor = $container->getByType(SecretRedactor::class); @@ -41,7 +41,7 @@ public function testContainerProvidesSecretRedactor(): void public function testContainerProvidesSecretRegistry(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $registry = $container->getByType(SecretRegistry::class); @@ -66,7 +66,7 @@ public function testRegistrySharedBetweenResolverAndRedactorIsTheSameInstance(): ], ], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $resolver = $container->getByType(VariableResolver::class); diff --git a/tests/Integration/DependencyInjection/SputnikExtensionTest.php b/tests/Integration/DependencyInjection/SputnikExtensionTest.php index fa49384..82fa0a3 100644 --- a/tests/Integration/DependencyInjection/SputnikExtensionTest.php +++ b/tests/Integration/DependencyInjection/SputnikExtensionTest.php @@ -42,7 +42,7 @@ public function testTasksAreRegisteredInContainer(): void 'tasks' => ['directories' => [$this->fixturesTasksDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Task should be registered with tag @@ -58,7 +58,7 @@ public function testListenersAreRegisteredInContainer(): void 'tasks' => ['directories' => [$this->fixturesListenersDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Listener should be registered with tag @@ -77,7 +77,7 @@ public function testListenersAreWiredToEventDispatcher(): void 'tasks' => ['directories' => [$this->fixturesListenersDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Get dispatcher and dispatch event @@ -94,7 +94,7 @@ public function testListenersAreWiredToEventDispatcher(): void public function testBuiltinListenersAreRegistered(): void { $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Core listeners are hardwired (not tagged) — verify by service name @@ -114,7 +114,7 @@ public function testMultipleTaskDirectoriesAreScanned(): void 'tasks' => ['directories' => [$this->fixturesTasksDir, $this->fixturesListenersDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Tasks from fixtures dir @@ -135,7 +135,7 @@ public function testServicesAreAutowired(): void 'tasks' => ['directories' => [$this->fixturesTasksDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); // Task should be resolvable (autowiring should work) @@ -162,7 +162,7 @@ public function testListenerPriorityIsRegistered(): void 'tasks' => ['directories' => [$this->fixturesListenersDir]], ]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $container = $factory->create(); $taggedServices = $container->findByTag('sputnik.listener'); diff --git a/tests/Unit/DependencyInjection/PsrContainerAdapterTest.php b/tests/Unit/DependencyInjection/PsrContainerAdapterTest.php index 6426626..3e2f8cb 100644 --- a/tests/Unit/DependencyInjection/PsrContainerAdapterTest.php +++ b/tests/Unit/DependencyInjection/PsrContainerAdapterTest.php @@ -23,7 +23,7 @@ protected function setUp(): void $this->tempDir = $this->createTempDir(); $config = new Configuration([]); - $factory = new ContainerFactory($config, $this->tempDir, 'default'); + $factory = new ContainerFactory($config, $this->tempDir, $this->tempDir, 'default'); $netteContainer = $factory->create(); $this->adapter = new PsrContainerAdapter($netteContainer); } diff --git a/tests/Unit/Support/ProjectLocatorTest.php b/tests/Unit/Support/ProjectLocatorTest.php new file mode 100644 index 0000000..972e89c --- /dev/null +++ b/tests/Unit/Support/ProjectLocatorTest.php @@ -0,0 +1,72 @@ +tempDir = $this->createTempDir(); + } + + protected function tearDown(): void + { + $this->removeTempDir($this->tempDir); + parent::tearDown(); + } + + public function testFindsTheDirectoryHoldingTheConfig(): void + { + touch($this->tempDir . '/.sputnik.dist.neon'); + + $this->assertSame(realpath($this->tempDir), ProjectLocator::locate($this->tempDir)); + } + + public function testWalksUpFromASubdirectory(): void + { + touch($this->tempDir . '/.sputnik.dist.neon'); + $deep = $this->tempDir . '/htdocs/web/sites'; + mkdir($deep, 0755, true); + + $this->assertSame(realpath($this->tempDir), ProjectLocator::locate($deep)); + } + + public function testTheLocalOverrideAloneAlsoMarksAProject(): void + { + // .sputnik.neon without a committed dist file is a valid project. + touch($this->tempDir . '/.sputnik.neon'); + + $this->assertSame(realpath($this->tempDir), ProjectLocator::locate($this->tempDir)); + } + + public function testTheNearestConfigWins(): void + { + touch($this->tempDir . '/.sputnik.dist.neon'); + $inner = $this->tempDir . '/inner'; + mkdir($inner, 0755, true); + touch($inner . '/.sputnik.dist.neon'); + mkdir($inner . '/deeper', 0755, true); + + $this->assertSame(realpath($inner), ProjectLocator::locate($inner . '/deeper')); + } + + public function testNoConfigAnywhereMeansNoProject(): void + { + // Nothing may be persisted in this case, so null has to be an answer + // rather than a fallback to the starting directory. + $this->assertNull(ProjectLocator::locate($this->tempDir)); + } + + public function testAMissingDirectoryIsNotAProject(): void + { + $this->assertNull(ProjectLocator::locate($this->tempDir . '/does-not-exist')); + } +}