From 136d032ed7c33826fa48fe0e322d20064701851e Mon Sep 17 00:00:00 2001 From: Marcel Reuss Date: Thu, 20 Aug 2026 11:23:33 +0200 Subject: [PATCH 1/2] feat!: anchor state on the project, not on the caller Three things hung off the working directory, and all three were wrong for it: the config lookup, the container cache and the persisted context. So .sputnik appeared wherever the binary ran, even where there was nothing to remember: $ cd /tmp/empty && sputnik --version $ ls -a . .. .sputnik And a call from a subdirectory of a real project found no config at all, while leaving a second .sputnik behind: $ cd htdocs/web && sputnik list Sputnik | no config | PHP 8.5 Two directories now, one question each. The project directory holds the config, and with it .sputnik/state.json and .sputnik/cache. It is found by searching upwards for .sputnik.dist.neon or .sputnik.neon, the way git and composer find their root - so a call from htdocs/web works, and nothing is written beside the caller. No config in any parent means there is no project: the container compiles into the system temp directory, no context is persisted, and the built-in init is what remains. 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 --working-dir moves only that. cd htdocs/web && sputnik w cwd = , state at sputnik --working-dir=sub w cwd = /sub, state at cd /tmp/empty && sputnik list nothing written Deviation from the handover spec, deliberate: the upward search starts at --working-dir when given, not always at the current directory. Otherwise a project could no longer be addressed from outside - which the release smoke test and the E2E tests from #47 both do, and which nobody asked to lose. The rule stays one sentence: --working-dir behaves as if you had cd'd there. ProjectLocator is its own class with its own tests, because "nearest config wins", "the local override alone counts" and "no config means null, not the starting directory" are three decisions that deserve to be pinned. BREAKING: --working-dir no longer selects a project by itself - it selects a directory, and the project is whatever config sits at or above it. ContainerFactory and ContextManager take the project directory; the latter accepts null and then persists nothing. --- bin/sputnik | 49 +++++++---- docs/cli.md | 16 +++- docs/configuration.md | 5 ++ docs/project-structure.md | 30 ++++++- src/Context/ContextManager.php | 36 ++++++-- src/DependencyInjection/ContainerFactory.php | 25 +++++- src/DependencyInjection/SputnikExtension.php | 13 ++- src/Kernel.php | 33 ++++++-- src/Support/ProjectLocator.php | 47 +++++++++++ tests/E2E/SputnikBinaryTest.php | 83 +++++++++++++++++++ .../ContainerFactoryTest.php | 42 +++++----- .../SecretServicesWiringTest.php | 6 +- .../SputnikExtensionTest.php | 14 ++-- .../PsrContainerAdapterTest.php | 2 +- tests/Unit/Support/ProjectLocatorTest.php | 72 ++++++++++++++++ 15 files changed, 399 insertions(+), 74 deletions(-) create mode 100644 src/Support/ProjectLocator.php create mode 100644 tests/Unit/Support/ProjectLocatorTest.php diff --git a/bin/sputnik b/bin/sputnik index 025452f..6da4b54 100755 --- a/bin/sputnik +++ b/bin/sputnik @@ -33,43 +33,58 @@ 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); } +$projectDir = Sputnik\Support\ProjectLocator::locate($workingDirOption ?? $cwd); +$workingDir = $workingDirOption ?? $projectDir ?? $cwd; $resolvedWorkingDir = realpath($workingDir); if ($resolvedWorkingDir === false || !@chdir($resolvedWorkingDir)) { @@ -103,7 +118,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..4eb893a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,10 +24,18 @@ 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. 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 +``` 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..f52f868 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,32 @@ 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'); + + $this->projectDir = $projectDir ?? ProjectLocator::locate($workingDir ?? $cwd); + $this->workingDir = $workingDir ?? $this->projectDir ?? $cwd; $this->debugMode = $debugMode; $this->loadConfig(); @@ -156,8 +172,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 +194,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 +207,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 +227,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 +235,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 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')); + } +} From fbd4e96b71f7d866cff6340617ebfcdc7831f414 Mon Sep 17 00:00:00 2001 From: Marcel Reuss Date: Thu, 20 Aug 2026 11:30:28 +0200 Subject: [PATCH 2/2] fix: a working directory outside the project keeps the project Asked while reviewing the anchor: what happens when --working-dir points somewhere else entirely? Measured, and the answer was bad in both versions. cd project && sputnik --working-dir=../unrelated w 0.2.3 Command "w" is not defined. and .sputnik left in ../unrelated the anchor Command "w" is not defined. ../unrelated stays clean So the anchor already stopped the litter, but neither could run a task in an unrelated directory - the tasks disappeared with the project. Not a regression, but this is the change that defines what the two directories mean, so it belongs here. The project is now the nearest config at or above --working-dir, and where there is none, the project of the current directory is kept. Three cases, one rule: --working-dir=frontend subdirectory -> your project --working-dir=/tmp/scratch no config -> your project, tasks run there --working-dir=../other-proj a project -> that project, its own state The last case is the rule in short - naming a directory behaves as if you had cd'd there - and only a directory without a project of its own leaves you with yours. Verified with two projects side by side: from a with --working-dir=../b you get b's task and b's state, and a's task is not defined. --- bin/sputnik | 7 ++++++- docs/cli.md | 19 ++++++++++++++---- src/Kernel.php | 7 ++++++- tests/E2E/SputnikBinaryTest.php | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/bin/sputnik b/bin/sputnik index 6da4b54..22cc37c 100755 --- a/bin/sputnik +++ b/bin/sputnik @@ -83,7 +83,12 @@ if ($workingDirOption !== null && !is_dir($workingDirOption)) { exit(1); } -$projectDir = Sputnik\Support\ProjectLocator::locate($workingDirOption ?? $cwd); +// 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); diff --git a/docs/cli.md b/docs/cli.md index 4eb893a..fce88da 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,15 +28,26 @@ 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. 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. +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/src/Kernel.php b/src/Kernel.php index f52f868..a1cf96f 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -61,7 +61,12 @@ public function __construct( $cwdResult = getcwd(); $cwd = $cwdResult !== false ? $cwdResult : throw new SputnikRuntimeException('Could not determine working directory'); - $this->projectDir = $projectDir ?? ProjectLocator::locate($workingDir ?? $cwd); + // 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; diff --git a/tests/E2E/SputnikBinaryTest.php b/tests/E2E/SputnikBinaryTest.php index 453ee6d..a4fe062 100644 --- a/tests/E2E/SputnikBinaryTest.php +++ b/tests/E2E/SputnikBinaryTest.php @@ -1013,6 +1013,41 @@ public function __invoke(TaskContext $ctx): TaskResult $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';