fix: make the working directory the working directory - #47
Merged
Conversation
exec() has always run commands in the project root, while PHP file I/O inside a
task resolved against whatever directory the binary was called from. Measured
with the 0.2.1 release, invoked from /tmp with --working-dir pointing elsewhere:
PHP: file_exists('.sputnik.dist.neon') = false
exec(): sees the file = yes
docs/cli.md promised "all paths ... are resolved relative to this directory",
which was true for config, tasks and templates but not for a task's own file
access. The consuming project hit this: its wrapper has to cd before calling the
binary, with a comment explaining that --working-dir alone would leave relative
file I/O resolving against the caller's cwd. Tasks there do exactly that -
is_dir('.ddev'), file_get_contents('dev-ops/ddev/config.yaml').
bin/sputnik now enters the directory. Not the Kernel: a library class that
changes the process working directory would move the cwd of every test that
constructs one.
Resolving to an absolute path first is not cosmetic. A relative --working-dir
would otherwise be looked up inside itself after the chdir - `wd/.sputnik.dist.neon`
becoming `wd/wd/...`. It also keeps the container cache key stable, since that
key contains this path.
A working directory that does not exist now says so. It used to surface as a raw
PHP warning about a cache directory it could not create, quoting a phar:// path:
PHP Warning: mkdir(): Permission denied in phar:///...
Error: Could not create cache directory: /gibt/es/nicht/.sputnik/cache
Three E2E tests against the real binary: relative file access under
--working-dir, a relative --working-dir, and the missing-directory message
asserting the word "mkdir" is absent - the cache was a symptom, not the problem.
refsz
added a commit
that referenced
this pull request
Aug 20, 2026
* 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 = <project>, state at <project>
sputnik --working-dir=sub w cwd = <project>/sub, state at <project>
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.
* 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by reading the Sputnik migration in the consuming project.
exec()has always run commands in the project root, while PHP file I/O inside a task resolved against whatever directory the binary was called from. Measured with the released 0.2.1 PHAR, invoked from/tmpwith--working-dirpointing elsewhere:docs/cli.mdpromised "All paths (config files, task directories, templates) are resolved relative to this directory". True for config, tasks and templates — not for a task's own file access.The consuming project hit this and worked around it. Its wrapper has to
cdbefore calling the binary, with a comment saying that--working-diralone would leave relative file I/O resolving against the caller's cwd. Its tasks do exactly that kind of access —is_dir('.ddev'),file_get_contents('dev-ops/ddev/config.yaml'),scandir('.ddev'). So the two halves of one directory notion disagreed, and every consumer had to know it.The fix
bin/sputnikenters the directory before the Kernel is built.Not in the Kernel. A library class that changes the process working directory would move the cwd of every test that constructs one — including the regression tests from #36 that assert a directory stays empty. The binary owns the process; the Kernel does not.
Resolved to an absolute path first, which is not cosmetic:
--working-dirwould otherwise be looked up inside itself after the chdir,wd/.sputnik.dist.neonbecomingwd/wd/.sputnik.dist.neonA missing directory now says which one
Before, it surfaced as a raw PHP warning about a cache directory, quoting a
phar://internal path — complaining about the symptom:Now:
Tests
Three E2E tests against the real binary, each of which failed before the change:
file_exists()on a relative path under--working-dir, run from a different cwd, and assertinggetcwd() === $ctx->getWorkingDir()--working-dir, which is the case the absolute resolution exists formkdiris absent — the cache was a symptom, not the problemVerified against the reporting project too:
sputnik --working-dir=/home/.../drupal.z listfrom/tmpfinds the config, and so does the relative form from the parent directory — neither needs acd.Docs: the
--working-dirsection now states what it does instead of a claim that was half true, andtasks.mdsays relative paths in a task resolve against the project root.Their wrapper's
cdbecomes redundant with this, but it stays harmless — worth telling them once this ships.🤖 Generated with Claude Code
https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A