fix(repo): validate and fetch a --repo mirror instead of reusing it blind [SC-A2.1] - #73
Merged
Merged
Conversation
…lind
Lane SC-A2, slice 1 of the project-continuity work.
`aether agent --repo owner/name` reused an existing local mirror on the
strength of one `existsSync` check:
if (existsSync(join(dir, ".git"))) return { dir, cloned: false };
There is no `git fetch` anywhere in this codebase, so that mirror was never
refreshed. `createWorktree` then branched off it with no start-point argument,
meaning a task could silently begin from a tip that was current days ago, and
`prCreateHint` would go on to invite a PR from it.
The mirror path is derived from the slug alone
(~/.aether-agent/repos/<owner>-<name>), so the remote was never validated
either: any directory sitting at that path was accepted as the requested repo.
Adds `refreshMirror`, called by `ensureLocalClone` before an existing mirror is
returned. It guarantees three things:
1. the mirror's origin really is the repo that was asked for — compared
through parseRepoSpec so https/ssh/.git/trailing-slash forms normalize
rather than needing a second, subtly different URL parser
2. the mirror is fetched, and the resulting tip is reported
3. when the fetch cannot happen — offline, auth expired, remote gone — the
result is "unknown" with the git error attached. It never degrades to
"fresh" as a convenience
Read-only with respect to the user's tree: it runs remote get-url, fetch and
rev-parse. Never checkout, reset, clean, merge, pull or rebase. A test asserts
each of those six verbs is absent from the recorded argv.
Auth stays the user's own git/gh configuration, inherited from the environment.
A test asserts no Aether credential shape (aek_, Authorization,
http.extraheader, GIT_ASKPASS, x-access-token) reaches the git argv.
`ensureLocalClone` now takes an injected Runner, defaulting to the existing
`defaultRunner()` from worktree.ts. It previously called spawnSync directly and
was therefore untestable; this is the same seam the gated-worktree flow already
uses, so no second runner abstraction is introduced.
The user-facing line stops rounding off. "(reusing local clone)" was equally
true of a mirror last fetched a week ago:
⎇ repo octocat/hello-world (fetched) @ a1b2c3d
⎇ repo octocat/hello-world (NOT REFRESHED — Could not resolve host: github.com)
! this worktree will branch off whatever the mirror already had;
its base is not known to match the remote.
Written test-first: the six tests were added and confirmed failing to compile
against the missing export before `refreshMirror` existed.
Mutation-checked: rewriting every `state: "unknown"` to `state: "fresh"` fails
"a failed fetch reports unknown, never fresh" (11 pass / 1 fail); restoring
gives 12 / 12.
Gates at this commit:
npm run typecheck exit 0
npm test 928 pass / 0 fail (922 on clean 41a7e26)
Noted, not fixed here: test/diagnostics.test.ts "a hanging backend cannot stall
the fast report" failed once under full-suite load at 1818ms and passes in
isolation at 82ms. Its 50ms budget is load-sensitive. Pre-existing fragility,
unrelated to this change, left for the lane that owns that file.
This was referenced Aug 19, 2026
Merged
Merged
AetherAI3
marked this pull request as ready for review
August 19, 2026 12:46
This was referenced Aug 19, 2026
AetherAI3
added a commit
that referenced
this pull request
Aug 19, 2026
…nknown base (#83) PR #73 added remote validation and a fetch, and recorded "pinning the worktree to freshness.remoteTip explicitly" as a follow-up. That follow-up turns out to be load-bearing, not cosmetic. `git fetch` advances remote refs and FETCH_HEAD. It does NOT move the mirror's checked-out HEAD — refreshMirror says so in its own contract ("never checks out, resets, merges, pulls or cleans"). And worktreeAddArgs built: ["-C", repoRoot, "worktree", "add", "-b", branch, dir] with no start point, so `worktree add` branched off whatever the mirror already had. code.ts read co.freshness.remoteTip only to print it. The reachable shape was: mirror HEAD A origin/main A-B-C FETCH_HEAD C printed "(fetched) @ C" worktree cut A so a run could report a fresh base and then start days behind it. The summary this project shipped — "--repo fetches instead of branching off a stale copy" — was true of the fetch and false of the worktree. Two changes. 1. worktreeAddArgs and createWorktree take an optional startRevision, appended last so git reads it as the start point. Omitted, behaviour is unchanged, so a plain --worktree run still branches from the user's own checkout. 2. code.ts pins to co.freshness.remoteTip, and REFUSES when the base cannot be named. Previously an unfetchable mirror printed a warning and proceeded. Proceeding is the dangerous half: it starts work on an unknown base while having just printed a reassuring line. A run that cannot establish its base now exits 1 and says why. Tests: three argv tests plus a real-git canary that asserts the resulting checkout rather than the arguments. It builds a remote at A, clones it, moves the remote to C, fetches, asserts the mirror's HEAD is still A (the defect itself), cuts the worktree, and requires `git rev-parse HEAD` inside it to equal C. Mutation-checked, and this is the evidence the bug was real: reverting worktreeAddArgs to drop the start point fails the canary with "the worktree must start at the fetched revision" — the worktree lands on A. Restored, 19/19. Gates at this commit: npm run typecheck exit 0 npm test 1090 pass / 0 fail
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.
Problem
aether agent --repo owner/namecould silently start work from a stale tip.An existing local mirror was reused on the strength of a single check —
src/core/repo.ts:76:There is no
git fetchanywhere in this codebase. So that mirror was never refreshed.createWorktreethen branched off it with no start-point argument, andprCreateHintwent on to invite a PR from whatever it found.The mirror path is derived from the slug alone (
~/.aether-agent/repos/<owner>-<name>), so the remote was never validated either — any directory sitting at that path was accepted as the requested repo.Root cause
Reuse was decided by path existence, which answers "is there a git repo here" and was being read as "is this the right repo, and is it current". Those are three different questions.
Contract
refreshMirrorruns before an existing mirror is handed back, and guarantees:parseRepoSpec, sohttps/ssh/.git/ trailing-slash forms normalize — rather than adding a second, subtly different URL parser. A mismatch throws; it is never quietly used.unknown, with the git error attached. It never degrades tofreshas a convenience.Read-only with respect to the user's tree:
remote get-url,fetch,rev-parse. Nevercheckout,reset,clean,merge,pull,rebase.Auth stays the user's own git/gh configuration, inherited from the environment. No Aether credential is passed, and none is reachable from this function.
Implementation
ensureLocalClonenow takes an injectedRunner, defaulting to the existingdefaultRunner()fromworktree.ts. It previously calledspawnSyncdirectly and was therefore untestable — this is the same seam the gated-worktree flow already uses, so no second runner abstraction is introduced (there are already two in this repo; that was flagged as a hazard during recon).The user-facing line stops rounding off.
(reusing local clone)was equally true of a mirror last fetched a week ago:Tests
Written test-first — all six were added and confirmed failing to compile against the missing export before
refreshMirrorexisted.fetchappears in the recorded argvaek_,Authorization,http.extraheader,GIT_ASKPASS,x-access-tokenMutation-checked. Rewriting every
state: "unknown"tostate: "fresh"fails "a failed fetch reports unknown, never fresh" — 11 pass / 1 fail. Restoring gives 12 / 12.Gates at
7f55b0e:npm run typechecknpm testBaseline on clean
41a7e261measured in the same session: 922 / 0.Security notes
parseRepoSpec's existing argument-injection guards (leading-,.,..) are unchanged and still load-bearing —cloneArgsemits the slug with no--separator.Known limits
refreshMirrorreports the remote tip, not a divergence measure. The fullProjectContextSnapshot(dirty counts, upstream name, ahead/behind) is a later slice of this lane.createWorktreestill branches with no explicit start-point. It now branches off a fetched mirror, which closes the reported failure, but pinning the worktree tofreshness.remoteTipexplicitly is a follow-up.~/.aether-agent/worktreesand~/.config/aether/worktreesstill coexist with no registry and no migration. Separate slice.ensureLocalCloneremains uncovered end-to-end; onlyrefreshMirroris directly tested. ItsspawnSyncclone path still has no seam.Dependency and merge order
Independent of SC-A0 (#72) — branched from
origin/mainand touches no file that lane touches. Either can merge first.Per the integration order this lands after SC-A0 and before SC-A3.
Unrelated observation
test/diagnostics.test.ts— "a hanging backend cannot stall the fast report" failed once under full-suite load at 1818ms, and passes in isolation at 82ms. Its 50ms budget is load-sensitive. Pre-existing fragility, unrelated to this change; left for the lane that owns that file rather than touched opportunistically here.