Skip to content

Resolve worktree bases against the mirror's real remotes, and validate them before git sees them - #5

Merged
Nurozen merged 7 commits into
weirwoodfrom
stave/stave-cli-fixes/stave
Sep 9, 2026
Merged

Nurozen merged 7 commits into
weirwoodfrom
stave/stave-cli-fixes/stave

Conversation

@Nurozen

@Nurozen Nurozen commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

The bug

A shared bare mirror can carry more than one remote — an upstream origin plus a fork. Stave could not be pointed at the second one. Every base and reference ref went through a rule that prepended origin/ to anything not already starting with origin/ or refs/:

$ stave space create --json -k sync -e t3code:fork/main --dry-run x
  "dry-run: add edit worktree stave/x/t3code from origin/fork/main ..."

origin/fork/main does not exist. A real run got as far as git worktree add and 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:

$ stave space create --json -k sync -e t3code:fork/main --dry-run x
  "dry-run: add edit worktree stave/x/t3code from refs/remotes/fork/main ..."

$ stave space create --json -k sync -e t3code:nosuch/main --dry-run x
{"error": {"code": "ref_not_found",
  "message": "repo \"t3code\": ref \"nosuch/main\" does not resolve (looked for refs/remotes/origin/nosuch/main); remotes on the bare repo: fork, origin; run 'stave repos sync t3code' if the branch was pushed after the last fetch",
  "details": {"repo": "t3code", "ref": "nosuch/main", "tried": "refs/remotes/origin/nosuch/main", "remotes": ["fork", "origin"]}}}

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:

Spelling Resolves to Recorded as
main refs/remotes/origin/main origin/main
origin/main refs/remotes/origin/main origin/main
fork/main, mirror has a fork remote refs/remotes/fork/main refs/remotes/fork/main
fork/main, mirror does not refs/remotes/origin/fork/main origin/fork/main
refs/... itself itself

origin/ keeps its short spelling so no existing manifest changes; a non-origin remote is recorded as the full refs/remotes/... because a bare fork/main is ambiguous under git's rev-parse ordering. A first segment that names no remote is still a branch on origin, so feature/login and review's pr/123 are 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 the space:<id>-only preflight (whose wording is preserved) and extends the check to reference worktrees, --start-point, and space 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/..., what space:<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, or retarget (which never fetches) a miss is a warning: on stderr and the command proceeds. That keeps stave review working for a PR whose base branch was deleted on merge, and keeps a --dry-run preview from refusing a branch pushed since the last sync. --dry-run still 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 origin resolves bare branches against the remote it does have; an explicit origin/... is left to fail honestly.

3. --no-fetch on stave space create, matching space add. It threads to every composed AddRepo and suppresses the matching dry-run: fetch lines. Same argument added to the stave_space_create agent tool.

4. Coded errors for the reachable refusals in internal/space. Messages are unchanged; each now carries a code so --json stops 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 trio memory_not_found / memory_alias_required / memory_already_attached — the memory verbs were a whole --json surface with no vocabulary at all.

5. An off-TTY stave summon exits 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-command asks 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 --json probes now use the error envelope. space status --json, saga status --json and saga list --json wrote their success payload as JSON but returned failures raw, so probing a missing space printed open /.../ghost/.stave.yaml: no such file or directory — prose, from a --json surface. All three go through runJSON now and a missing space is space_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 create is still not idempotent end to end, and I did not add an --if-exists=reuse flag. 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 on origin/main when the caller asked for fork/main is worse than the refusal. Meanwhile the refusals are already coded and detailed (space_exists, repo_already_in_space with {repo, mode}), so a scripted caller can branch on the failure of a single create call 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 . and golangci-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-run and --no-fetch must not refuse what a fetching run would build), the RemoteNames git 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), nested fork/feature/login, reference worktrees on a fork ref, space sync, retarget, and an archive/restore round trip — all with a refs/remotes/fork/main base recorded in the manifest.

🤖 Generated with Claude Code

Nurozen and others added 7 commits September 9, 2026 00:25
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>
@Nurozen
Nurozen merged commit dc5afc7 into weirwood Sep 9, 2026
2 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