Candidate manifest, patch against 559ef2287, build notes, five killed
mutants and the targeted gate (seat, cli, harness, two scripts).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
115 lines
10 KiB
Markdown
115 lines
10 KiB
Markdown
# Row 117 (#1611) round 1: fetch a role clone that is on another branch or dirty
|
|
|
|
Brief: docs/plans/2026-10-14_clone-fetch-off-branch.md (559ef2287), "Fetch a role clone that is on another branch or dirty". On 2026-10-14 the coder sat on a work branch and its clone's `origin/refactor` stayed several landings old, because `update()` ran no git at all in a clone it kept for `other-branch` or `dirty`.
|
|
|
|
BASE: 559ef2287. The candidate is build.patch against BASE, and candidate-manifest.sha256 lists the 8 files. I applied the patch to a fresh `git archive 559ef2287` export and every digest matched (DIGESTS_OK). The worktree still matched the manifest after the mutants and before the gate.
|
|
|
|
## What changed
|
|
|
|
### update(), packages/seat/src/checkout.mjs
|
|
|
|
Everything up to the root-detached check is unchanged: `unsafe()`, the `ls-files` gitlink check and the root reads. Nothing in `unsafe()`, `allowed()`, `gitEnv()` or `SAFE` changed. After the root checks, the new tail does this:
|
|
|
|
1. It reads the branch with `symbolic-ref -q --short HEAD`. A detached HEAD fails that call, so `branch` is null, and null is never the root's branch, so the reason is `other-branch`.
|
|
2. On the root's branch it runs the same `status` call as before. A failed status still returns `kept`, `status-failed`, with no fetch. Output from status means `dirty`.
|
|
3. It reads HEAD, then runs the clean path's fetch with the same arguments and environment. A failed fetch is `kept`, `fetch-failed`, for any clone.
|
|
4. With a reason set, it reads `refs/remotes/origin/<root branch>` and returns `fetched`. No merge runs.
|
|
5. A clean clone on the root's branch merges `--ff-only` exactly as before.
|
|
|
|
The `fetched` result is `{ outcome: "fetched", reason, branch, commit, origin: { branch, commit } }`:
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| `reason` | `other-branch` or `dirty` |
|
|
| `branch` | the clone's branch, null on a detached HEAD |
|
|
| `commit` | the clone's HEAD, read before the fetch (a fetch doesn't move HEAD), null when unreadable |
|
|
| `origin.branch` | the root's branch |
|
|
| `origin.commit` | `refs/remotes/origin/<branch>` after the fetch, null when unreadable |
|
|
|
|
Choices I made:
|
|
|
|
- I used the brief's shape. `origin` is always an object, because by this point the root's branch is known. Only `origin.commit` can be null.
|
|
- `commit` is now null when `rev-parse HEAD` fails. Before this change the code used `.out.trim()` without checking `.ok`, and git prints the unresolved argument on a failure. That could only matter for an unborn HEAD, which a clone made by `git clone` of a root with a commit doesn't have.
|
|
- A failed status stays `kept` with no fetch. It isn't one of the brief's two reasons, and "Only those two reasons fetch".
|
|
|
|
### The launcher, packages/cli/src/launcher.mjs
|
|
|
|
After `ensureCheckouts`, a `fetched` clone writes `{ event: "checkout-fetched", instance, project, reason }` to the launch log, next to `checkout-kept`. The header comment says so.
|
|
|
|
### The prompt, packages/harness/src/bundle.mjs
|
|
|
|
`checkoutLines()` gives a fetched clone the started line, then `, fetched but not fast-forwarded at this launch (<reason>), with origin/<branch> at commit <sha>`. A null branch reads "on no branch", and a null origin commit reads "at a commit the host couldn't read", using the same `at()` helper as before. The shared sentence now says the host fetches each clone it can safely run git in and fast-forwards only a clean clone on the root's branch. The rest of it (never during a session, the origin is outside the sandbox, no git config, identity from the environment) is unchanged.
|
|
|
|
### READMEs
|
|
|
|
- packages/seat/README.md, "The project clones". The outcome table gets the `fetched` row, and the result paragraph describes `origin`. The reason table marks `other-branch` and `dirty` as fetched and says every other reason runs no fetch. A failed fetch on either of those clones is `kept`, `fetch-failed`.
|
|
- packages/cli/README.md, "Sessions". A `checkout-fetched` line with the project and the reason, then the existing `checkout-kept` sentence.
|
|
|
|
packages/cli/tests/host.test.mjs:826 reads packages/cli/README.md, so that README is not doc-only under decision 137. `unslop-check` is clean on both READMEs.
|
|
|
|
## Tests
|
|
|
|
### packages/seat/tests/checkout.test.mjs
|
|
|
|
The old "a dirty clone, one on another branch and one diverged are kept as they are" test becomes the first test below, as the brief asks. Its diverged case moves to its own test, because a diverged clone is still `kept`.
|
|
|
|
| Test | Checks |
|
|
|---|---|
|
|
| a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves | 5 cases: a modified, an untracked and a staged file (`dirty`, branch main), another branch (`other-branch`, work), a detached HEAD (`other-branch`, null). After a commit to the root: the whole result by deepEqual, `refs/remotes/origin/main` is the root's new commit, and `work()` is unchanged. `work()` hashes `.git/HEAD`, every `refs/heads/*` ref, `.git/index` and every file outside `.git`, the brief's narrowed fingerprint |
|
|
| a diverged clone gets the fetch into refs/remotes and is kept, not-fast-forward | `kept`, `not-fast-forward`, and `refs/remotes/origin/main` is the root's new commit |
|
|
| a clone kept before the branch check, or for a failed status, gets no fetch | 8 cases through a PATH git wrapper that logs each call run in the clone. `not-a-clone`, `hooks-present` and `config-changed` run nothing in the clone. `submodule`, `root-unreadable` and `root-detached` run only `ls-files`. Both `status-failed` cases, clean and dirty, run `ls-files`, `symbolic-ref`, `status`. Each case checks the reason and the exact call list, and that `refs/remotes/origin/main` didn't move |
|
|
| a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was | the wrapper fails `fetch`. `kept`, `fetch-failed`, no merge call, and the full fingerprint, `.git` included, is unchanged |
|
|
|
|
The rigged-clone test is unchanged and passes.
|
|
|
|
How I read the brief on "still runs no git, the fetch included": the gitlink check is itself the `ls-files` call, so `submodule` can't be found without it. `root-unreadable` and `root-detached` come after it, and their own reads run in the root, not the clone. Those three run `ls-files` and nothing else, as they did at BASE. The test pins the exact call list so a fetch, or any other call, fails it. If Dewey reads the brief as asking for literally zero calls in those three cases, that's a change to the check order, which the brief rules out.
|
|
|
|
The fingerprint helper gets a `skip` list of top-level names, so `work()` can hash the files outside `.git`.
|
|
|
|
### packages/cli/tests/launcher.test.mjs
|
|
|
|
"each launch makes sure the workspace has a clone of the project" now ends "cloned, fast-forwarded, or fetched and logged". The first two launches log no `checkout-fetched`. The third launch has a dirty `notes.txt`. It now checks that HEAD stays at two while `refs/remotes/origin/main` is three, that the log has `checkout-fetched` `[{ instance: "pm", project: "stack", reason: "dirty" }]` and no `checkout-kept`, and that the prompt has the fetched line with origin/main at three.
|
|
|
|
### packages/harness/tests/bundle.test.mjs
|
|
|
|
The checkout test adds two entries. `lib` is fetched, dirty, on main, with an origin commit. `app` is fetched, other-branch, with a null branch and a null origin commit. It checks the new shared sentence and both new lines exactly.
|
|
|
|
## Mutants
|
|
|
|
mutants.py runs each mutant against the full seat suite (`node --test tests/*.test.mjs` in packages/seat, 130 tests) and restores checkout.mjs from a `.orig` copy after each. Log: mutants-run.log. Each mutant's full output is in mutants/mutant-<name>.log, and the failing test names are listed under each mutant in mutants-run.log.
|
|
|
|
| Mutant | Change | Result |
|
|
|---|---|---|
|
|
| m1, fetch for a third reason | a failed status sets `reason = "status-failed"` instead of returning `kept` | killed, 3 fail: both `status-failed` cases of "gets no fetch" (they see `fetch`) and the parent test |
|
|
| m2, merge after the fetch | inside `if (reason)`, the clean path's `merge --ff-only` runs before the return | killed, 5 fail: untracked, staged, another branch and detached, plus the parent. The modified-file case passes, because git refuses that merge over a local change to README |
|
|
| m3, drop the fetch | the fetch runs only when `reason` is unset | killed, 7 fail: all 5 fetched cases and the parent (`origin/main` stays at the old commit), and "whose fetch fails" (no fetch, so the result is `fetched`) |
|
|
| m4, wrong origin commit | `origin.commit` is the clone's HEAD (`before`) | killed, 6 fail: all 5 fetched cases and the parent, on the deepEqual |
|
|
| m5, fetch before the gitlink check | the fetch runs right after `unsafe()`, before `ls-files` | killed, 6 fail: `submodule`, `root-unreadable`, `root-detached`, both `status-failed` cases and the parent, on the call list |
|
|
|
|
All five were restored, and `git diff --stat` matched the candidate afterwards (8 files, 183+, 52-).
|
|
|
|
## Gate
|
|
|
|
This is a targeted builder round under Sage's rule (#271, sage-0180): the changed packages, the packages that import them, and every scripts/test-*.sh that reads a changed path. Sage's landing gate stays full.
|
|
|
|
- Changed packages: seat, cli, harness.
|
|
- Importers: none outside those three. Nothing else references checkout.mjs, launcher.mjs or bundle.mjs (grep over packages/*/src, packages/*/tests and scripts; Sage checked this too, sage-0188).
|
|
- Scripts: no scripts/test-*.sh reads a changed path. test-task.sh says "seat" only inside skill names. test-conductor.sh and test-extension-package.sh match the basename README.md, but only for their own sandbox files. Sage asked me to keep those two anyway, so they ran.
|
|
- No pkg-queue step, per #271.
|
|
|
|
gate.sh ran these steps in order, each teed. GATE_PASSED at 17:30:33Z, run 17:29:52 to 17:30:33. Log: gate-run.log, per-step logs under gate/.
|
|
|
|
| Step | Pass |
|
|
|---|---|
|
|
| seat | 130 (120 at BASE: the old test and its 6 cases are 7, the four new tests and their cases are 17) |
|
|
| cli | 184 |
|
|
| harness | 81 |
|
|
| scripts/test-conductor.sh | 17 |
|
|
| scripts/test-extension-package.sh | 18 |
|
|
|
|
There were no failures and no flakes. The busy check was clear before the mutants and before the gate. Afterwards no process had a cwd under my r117 directory, and nothing created a Docker network.
|
|
|
|
## Going live
|
|
|
|
This change runs in the host at launch, so decision 109 applies. It goes live only through a stack copy and a re-pin on Mos's go. I start, stop and restart nothing.
|