Skip to content

feat!: anchor state on the project, not on the caller - #49

Merged
refsz merged 2 commits into
mainfrom
feat/project-dir-anchor
Aug 20, 2026
Merged

feat!: anchor state on the project, not on the caller#49
refsz merged 2 commits into
mainfrom
feat/project-dir-anchor

Conversation

@refsz

@refsz refsz commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Implements the handover spec from the consuming project's side. Two directories with one job each, instead of one directory with three.

What was wrong

Three things hung off the working directory, and all three were wrong for it: the config lookup, the container cache (ContainerFactory::CACHE_DIR) and the persisted context (ContextManager::STATE_DIR). Measured before changing anything:

$ cd /tmp/empty && sputnik --version
$ ls -a
.  ..  .sputnik                       <- for a command that needs nothing
$ cd htdocs/web && sputnik list
🛰  Sputnik │ no config │ PHP 8.5      <- inside a real project
$ ls -a
.  ..  .sputnik                       <- and a second one left behind

The model

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. Paths declared in the config — task directories, template sources and targets — resolve against it.

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. That is what makes a stray .sputnik structurally impossible rather than merely unlikely.

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.

Verified against the binary:

cd htdocs/web && sputnik w      cwd = <project>       state at <project>
sputnik --working-dir=sub w     cwd = <project>/sub   state at <project>, sub/ untouched
cd /tmp/empty && sputnik list   nothing written

Which project applies

This was asked during review — what if --working-dir points somewhere else entirely? — and the honest answer was that both this branch and 0.2.3 got it wrong:

cd project && sputnik --working-dir=../unrelated w

0.2.3         Command "w" is not defined.    and .sputnik left in ../unrelated
first draft   Command "w" is not defined.    ../unrelated stays clean

The anchor had already stopped the litter, but neither could run a task in an unrelated directory — the tasks vanished along with the project. Not a regression, but this is the PR that defines what the two directories mean, so it is fixed here.

--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 without a project of its own leaves you with yours. Checked 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.

One deliberate deviation from the spec

The spec asked that --working-dir no longer affect the config search at all — always search from the current directory. The search starts at --working-dir when given, with the fallback above.

Reason: with the literal rule, a project can no longer be addressed from outside itself. The release smoke test does exactly that (--working-dir="$dir" list, --working-dir="$dir" example), as do the E2E tests from #47. Taking that away is a second breaking change nobody asked for, and it would have surfaced as a red release job rather than as a decision.

Tests

The four acceptance criteria from the spec, as E2E tests against the real binary — each failed before this change:

  • an empty directory stays empty after --version, --help and list
  • the project is found from htdocs/web, state lands at the root, the subdirectory stays clean
  • --working-dir=frontend runs there while state stays at the root
  • init still scaffolds where there is no project

Plus one for the question above — a task running in a directory outside its project — and ProjectLocator as its own class with unit tests, because "nearest config wins", "the local override alone counts" and "no config means null, not the starting directory" are three decisions worth pinning.

32 test call sites of ContainerFactory were updated mechanically: they pass one temp directory that is both project and working directory, which is now explicit rather than implied.

Breaking

  • --working-dir no longer selects a project by itself; it selects a directory, and the project is the config at or above it — or yours, when there is none there.
  • ContainerFactory and ContextManager take the project directory. ContextManager accepts null and then persists nothing — a context cannot be remembered for something that does not exist.

Worth a 0.3.0 together with #44 and #45.

What this frees on the consumer side

The wrapper's upward walk and its cd both become redundant — the binary does the search itself now, and enters the project. exec ./sputnik.phar "$@" is the whole wrapper, once older pinned versions are gone.

vendor/bin/phpunit                        784 tests, 1352 assertions, OK (11 new)
vendor/bin/phpunit --testsuite e2e        against a built PHAR, OK
.github/scripts/smoke-phar.sh             exit 0
vendor/bin/phpstan analyse                [OK] No errors
vendor/bin/php-cs-fixer fix --dry-run     0 of 158 files
vendor/bin/rector --dry-run               [OK]
mkdocs build --strict                     clean

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

refsz added 2 commits August 20, 2026 11:23
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.
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.
@refsz
refsz merged commit 9b9f109 into main Aug 20, 2026
10 checks passed
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