Repository navigation
Resolve worktree bases against the mirror's real remotes, and validate them before git sees them - #5
Merged
Merged
Conversation
A bare mirror can carry more than one remote (an upstream `origin` plus a
`fork`), but every base and reference ref was normalized by prepending
`origin/` to anything that did not already start with `origin/` or `refs/`.
`stave space create -e t3code:fork/main` therefore planned
`origin/fork/main`, a ref that does not exist:
$ stave space create --json -k sync -e t3code:fork/main --dry-run x
"dry-run: add edit worktree ... from origin/fork/main ..."
A real run failed at `git worktree add` with a raw git error, and the only
working spelling was the undiscoverable `refs/remotes/fork/main`.
Resolution now asks the bare repo which remotes it has. A base whose first
path segment names one of them resolves against that remote and is recorded
as the unambiguous `refs/remotes/<remote>/<branch>`; `origin/` keeps its
short spelling, which is what existing manifests carry; a first segment that
names no remote is still a branch on `origin`, so `feature/login` is
unaffected. A mirror that cannot be interrogated falls back to the old
offline rule.
The resolved ref is then verified with `show-ref` before it reaches git, so
an unresolvable base is a stave-level `ref_not_found` naming the ref tried
and the remotes available, not a raw `git worktree add` failure. `--dry-run`
runs the same resolution and check, which is where the bug was first visible.
This subsumes the `space:<id>`-only preflight, which kept its wording, and
extends the check to reference worktrees and `--start-point`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stave space add` could skip the pre-add fetch of a repo's bare mirror; `stave space create` could not, so a scripted caller that had just run `stave repos sync` still paid one `git fetch --all --prune` per repo, and a caller deliberately building against a pinned mirror had no way to say so. CreateOptions.NoFetch threads to every AddRepo the create composes (edits, references, and tether-expanded common references) and suppresses the matching `dry-run: fetch` plan lines. The agent tool surface gets the same `no_fetch` argument on `stave_space_create` for parity with `stave_space_add`, including the rendered command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several refusals were plain fmt.Errorf, so `--json` reported them as
`{"error":{"code":"unknown"}}` and a scripted caller had to match on prose.
The messages are unchanged; each now carries a code (and, where a host would
otherwise re-derive them, details).
Reused vocabulary: retarget's "repo not found" / "reference-only" are
RepoNotInSpaceError (`repo_not_in_space`), adding an edit repo to a saga
space is `saga_space`, the two archive-selection refusals are
`archive_not_found`, and the usage refusals (`--after` without `--saga`, a
reserved kind, an empty repo-spec ref, an unknown repo mode, archive with
`--memory destroy`, duplicate `--memory` specs, a saga added to itself,
ambiguous `--after` bases, disabled tethers) are `invalid_arguments`,
matching the code the CLI layer already emits for the same class.
New codes: `not_a_saga` (six sites where a saga verb met a plain space),
`not_a_saga_member`, and the memory trio `memory_not_found`,
`memory_alias_required`, `memory_already_attached` — the memory verbs are a
whole --json surface that previously answered only "unknown".
`space retarget` also gains the remote-aware base resolution and existence
check that `space create`/`add` now use, so its `--base` accepts a
non-origin remote and its preflight covers every base spelling rather than
only `stave/...`. Under `--dry-run` an unresolvable base is a warning, since
nothing is written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stave summon` under a non-interactive terminal correctly prints the launch command instead of launching, but returned nil. A scripted caller could not tell "printed a command, launched nothing" from "launched the agent, which exited 0": both were exit 0, separated only by a stderr notice. The degraded path now exits 3. `--print-command` asks for the same output deliberately and stays exit 0, so the distinction is intent, not output. The command still goes to stdout and the notice to stderr, so piping is unaffected. Verbs that only trail a summon (`space create --summon`, `saga create --summon`, `review --summon`) swallow it and keep their own exit status: the space was created, and the skipped launch is advisory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`space status --json`, `saga status --json` and `saga list --json` wrote
their success payload with writeJSON but returned failures raw, so a probe
for a space that does not exist printed
open /.../agent-work/ghost/.stave.yaml: no such file or directory
to stdout and exited 1 — prose, not JSON, from a --json surface. These are
exactly the calls a scripted caller makes before deciding whether to create
a space, so they are the ones that most need a code.
All three now run through runJSON, and the two status verbs type a missing
manifest as SpaceNotFoundError, so the answer is
`{"error":{"code":"space_not_found"}}`. Success shapes are unchanged,
including the frozen SagaStatus contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the previous commit found the existence check was stricter than the run it was meant to protect. `show-ref` is a read-only probe, so it executes under a dry-run client; but a dry-run only *prints* the fetch. Three regressions followed: - `space create --dry-run` / `space add --dry-run` refused a base that the un-previewed real run would fetch and then find. - `stave review` broke for any PR whose base branch had been deleted on merge: it passes `NoFetch: true` (having fetched the PR ref itself) and the codebase already treats a missing base as a non-fatal note. - `space retarget`, which never fetches, refused a just-pushed base where it previously only checked `refs/heads/stave/` bases. resolveRef now takes mirrorFresh — did this call refresh the mirror? A local ref (refs/heads/..., what `space:<id>` sugar resolves to) is conclusive regardless: stave never pushes its own branches. A remote-tracking ref is conclusive only against a mirror the call just fetched; otherwise the miss is a warning and git has the last word. So the headline case, `stave space create -e repo:nosuch/main`, still refuses, while previews and `--no-fetch` callers no longer refuse what a real run would build. The `stave repos sync` hint is dropped from local-ref misses, where it cannot help. Also from the review: - A mirror whose single remote is not named `origin` now resolves bare branches against the remote it does have; an explicit `origin/...` is still left to fail honestly. - The `refs/remotes/<remote>/<branch>` spelling is reduced to the branch name before it reaches a memory provider, which resolves against the clone URL, not stave's mirror. - `manifest.HasPath` runs before resolution again, so a path collision still reports `repo_path_taken` rather than the base's `ref_not_found`. - A local ref is still existence-checked when `git remote` itself fails, instead of silently skipping the `space:<id>` preflight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The bug
A shared bare mirror can carry more than one remote — an upstream
originplus afork. Stave could not be pointed at the second one. Every base and reference ref went through a rule that prependedorigin/to anything not already starting withorigin/orrefs/:origin/fork/maindoes not exist. A real run got as far asgit worktree addand failed with a raw git error. The only spelling that worked was-e t3code:refs/remotes/fork/main, which nothing tells you about.After this change, against the same mirror:
What changed
1. Base/ref resolution consults the mirror's actual remotes (
resolveRefSpelling,internal/space/service.go). A base whose first path segment names a configured remote resolves against that remote:mainrefs/remotes/origin/mainorigin/mainorigin/mainrefs/remotes/origin/mainorigin/mainfork/main, mirror has aforkremoterefs/remotes/fork/mainrefs/remotes/fork/mainfork/main, mirror does notrefs/remotes/origin/fork/mainorigin/fork/mainrefs/...origin/keeps its short spelling so no existing manifest changes; a non-origin remote is recorded as the fullrefs/remotes/...because a barefork/mainis ambiguous under git's rev-parse ordering. A first segment that names no remote is still a branch onorigin, sofeature/loginand review'spr/123are unaffected. A mirror that cannot be interrogated falls back to the old offline rule.2. The resolved ref is verified before it reaches git (
Service.resolveRef+RefNotFoundError/ref_not_found). This replaces thespace:<id>-only preflight (whose wording is preserved) and extends the check to reference worktrees,--start-point, andspace retarget.The check only refuses what a fetch could not have supplied, so it never makes a preview stricter than the run it previews. A local ref (
refs/heads/..., whatspace:<id>sugar resolves to) is always conclusive — stave never pushes its own branches. A remote-tracking ref is conclusive only when that command fetched the mirror itself; under--dry-run(which only prints the fetch),--no-fetch, orretarget(which never fetches) a miss is awarning:on stderr and the command proceeds. That keepsstave reviewworking for a PR whose base branch was deleted on merge, and keeps a--dry-runpreview from refusing a branch pushed since the last sync.--dry-runstill resolves the spelling exactly as the real run does, which is where the bug was visible in the first place.A mirror whose single remote is not named
originresolves bare branches against the remote it does have; an explicitorigin/...is left to fail honestly.3.
--no-fetchonstave space create, matchingspace add. It threads to every composedAddRepoand suppresses the matchingdry-run: fetchlines. Same argument added to thestave_space_createagent tool.4. Coded errors for the reachable refusals in
internal/space. Messages are unchanged; each now carries a code so--jsonstops answering{"code":"unknown"}. Reused:repo_not_in_space(retarget),saga_space,archive_not_found,invalid_arguments(nine usage refusals). New:not_a_saga,not_a_saga_member, and the memory triomemory_not_found/memory_alias_required/memory_already_attached— the memory verbs were a whole--jsonsurface with no vocabulary at all.5. An off-TTY
stave summonexits 3. It correctly printed the command instead of launching, but returned nil, so a script could not tell "printed a command, launched nothing" from "launched the agent, which exited 0".--print-commandasks for the same output deliberately and stays exit 0 — the distinction is intent, not output. Composite verbs (space create --summon,saga create --summon,review --summon) swallow it and keep their own status: their work succeeded.6. The read-only
--jsonprobes now use the error envelope.space status --json,saga status --jsonandsaga list --jsonwrote their success payload as JSON but returned failures raw, so probing a missing space printedopen /.../ghost/.stave.yaml: no such file or directory— prose, from a--jsonsurface. All three go throughrunJSONnow and a missing space isspace_not_found. These are the calls a scripted caller makes before deciding whether to create a space, so they are the ones that most needed a code.Deliberately not done
stave space createis still not idempotent end to end, and I did not add an--if-exists=reuseflag. Making reuse safe would mean deciding what happens when the existing space holds the repo on a different base than the one requested — silently handing back a worktree onorigin/mainwhen the caller asked forfork/mainis worse than the refusal. Meanwhile the refusals are already coded and detailed (space_exists,repo_already_in_spacewith{repo, mode}), so a scripted caller can branch on the failure of a singlecreatecall without a probe — and item 6 above fixes the probe it would otherwise have used.Testing
go build ./...,go test ./...(also under-race),gofmt -l .andgolangci-lint run ./...are clean. New coverage: a real-git test that builds a two-remote mirror with a diverged fork and asserts the worktree's actual HEAD is fork's commit (internal/space/baseref_test.go), a spelling table including the single-non-origin-remote case, the refuse-vs-warn matrix (--dry-runand--no-fetchmust not refuse what a fetching run would build), theRemoteNamesgit probe,create --no-fetch, the coded refusals, the summon exit code and stream split, and the read-verb envelope.Manually exercised against a real two-remote mirror: create/add on
fork/main(worktree HEAD matches fork's commit), nestedfork/feature/login, reference worktrees on a fork ref,space sync,retarget, and an archive/restore round trip — all with arefs/remotes/fork/mainbase recorded in the manifest.🤖 Generated with Claude Code