Skip to content

fix: make the working directory the working directory - #47

Merged
refsz merged 1 commit into
mainfrom
fix/working-dir-becomes-the-cwd
Aug 20, 2026
Merged

fix: make the working directory the working directory#47
refsz merged 1 commit into
mainfrom
fix/working-dir-becomes-the-cwd

Conversation

@refsz

@refsz refsz commented Aug 20, 2026

Copy link
Copy Markdown
Owner

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 /tmp with --working-dir pointing elsewhere:

PHP:    file_exists('.sputnik.dist.neon') = false
exec(): sees the file                     = yes

docs/cli.md promised "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 cd before calling the binary, with a comment saying that --working-dir alone 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/sputnik enters 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:

  • a relative --working-dir would otherwise be looked up inside itself after the chdir, wd/.sputnik.dist.neon becoming wd/wd/.sputnik.dist.neon
  • the container cache key contains this path, so relative and absolute forms used to compile two separate containers for the same project

A 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:

PHP Warning:  mkdir(): Permission denied in phar:///…/sputnik.phar/src/DependencyInjection/ContainerFactory.php
 Error: Could not create cache directory: /gibt/es/nicht/.sputnik/cache

Now:

Working directory does not exist: /does/not/exist

Tests

Three E2E tests against the real binary, each of which failed before the change:

  • a task doing file_exists() on a relative path under --working-dir, run from a different cwd, and asserting getcwd() === $ctx->getWorkingDir()
  • a relative --working-dir, which is the case the absolute resolution exists for
  • the missing-directory message, asserting the word mkdir is absent — the cache was a symptom, not the problem

Verified against the reporting project too: sputnik --working-dir=/home/.../drupal.z list from /tmp finds the config, and so does the relative form from the parent directory — neither needs a cd.

vendor/bin/phpunit                        768 tests, 1318 assertions, OK (3 new)
vendor/bin/phpstan analyse                [OK] No errors
vendor/bin/php-cs-fixer fix --dry-run     0 of 156 files
vendor/bin/rector --dry-run               [OK]
mkdocs build --strict                     clean

Docs: the --working-dir section now states what it does instead of a claim that was half true, and tasks.md says relative paths in a task resolve against the project root.

Their wrapper's cd becomes redundant with this, but it stays harmless — worth telling them once this ships.

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

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
refsz merged commit b4f0264 into main Aug 20, 2026
10 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant