feat!: anchor state on the project, not on the caller - #49
Merged
Conversation
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.
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.
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:The model
The project directory holds the config, and with it
.sputnik/state.jsonand.sputnik/cache. It is found by searching upwards for.sputnik.dist.neonor.sputnik.neon, the waygitandcomposerfind 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
initis what remains. That is what makes a stray.sputnikstructurally impossible rather than merely unlikely.The working directory is where tasks run: the cwd of
exec()andshell(), and what relative file access in a task resolves against. It defaults to the project directory, and--working-dirmoves only that.Verified against the binary:
Which project applies
This was asked during review — what if
--working-dirpoints somewhere else entirely? — and the honest answer was that both this branch and 0.2.3 got it wrong: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-dirpoints atThe 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 — fromawith--working-dir=../byou get b's task and b's state, and a's task isnot defined.One deliberate deviation from the spec
The spec asked that
--working-dirno longer affect the config search at all — always search from the current directory. The search starts at--working-dirwhen 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:
--version,--helpandlisthtdocs/web, state lands at the root, the subdirectory stays clean--working-dir=frontendruns there while state stays at the rootinitstill scaffolds where there is no projectPlus one for the question above — a task running in a directory outside its project — and
ProjectLocatoras 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
ContainerFactorywere updated mechanically: they pass one temp directory that is both project and working directory, which is now explicit rather than implied.Breaking
--working-dirno 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.ContainerFactoryandContextManagertake the project directory.ContextManageracceptsnulland 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
cdboth 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.🤖 Generated with Claude Code
https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A