Splitting a surface out into a private repo¶
Who this is for: you are about to move a whole surface — infra, ops, a corpus, a research area — out of this repo into a private one, and you want the decisions already paid for rather than rediscovering them.
Where this comes from: the eval research split (arc 1 + arc 2, PR #2134 and its follow-ups). 2,146 files, −198,669 lines, plus a week of consequences. Every rule below is here because breaking it cost something real, and the cost is named so you can judge whether it applies to you.
The worked example is chipi/podcast-scraper-eval-data; see
Mounting the private eval research repo for what the
result looks like from a user's seat.
The one rule that decides everything¶
Tradecraft moves. Runtime stays.
For eval that was: research (datasets, reports, tuned constants, harness) moves; the application stays. For infra it will be some other pair — probably secrets, topology and live state versus the code that consumes them. Write your version of that sentence down before you move a single file, because every argument later resolves to it.
Two corollaries that were not obvious:
- Both axes of a discipline move together. Eval had QUALITY (WER, ROUGE) and
PERFORMANCE (wall time, peak RSS). Splitting them would have left half a
discipline behind, so
data/profiles/,data/perf/and the capture harness went withdata/eval/. - The generator can stay while its output moves.
scripts/build_v3_fixtures.pystayed public because it produces the fixtures the public tests consume. Ask "who authors this?" not "who reads it?"
Sequencing: two arcs, never one¶
Arc 1 — copy everything in. Change nothing on the public side. Arc 2 — delete from public, fix every calling site.
Copying in does not authorise deleting there. Keeping them separate means arc 1 can be wrong without breaking anything, and arc 2 is reviewable as a pure deletion. It also gives you a window where both copies exist, which is when you prove equivalence.
While arc 2 is open, main keeps moving. Before each rebase, diff what main touched against what you delete:
- A delete/modify conflict git shows you. Fine.
- An add on one side git shows you nothing. This is the dangerous one.
Both happened. Main added data/baselines/baseline-2026-W39.json into a
directory arc 2 deletes — no conflict, silently lost. And a bug fix (#2075)
landed in a file arc 2 deletes; a clean-looking resolution would have dropped
it. Port first, then rebase.
Finding what to move: three sweeps, and why the first two failed¶
Do not grep for a word. It fails in both directions, and it failed four times:
| sweep | basis | missed |
|---|---|---|
| 1 | the word "eval" | every autoresearch/ doc — the directory moved, its ADRs stayed |
| 2 | the word "autoresearch" | RFC-015, RFC-041 and the five ADRs they were built on |
| 3 | do the paths this doc cites still exist? | nothing of that class |
Sweep 3 is the one to start with: extract every path literal in the repo and test each against the pre-deletion tree. It narrows 200 candidates to ~30.
Then read all thirty. The shortlist is a filter, not a verdict. Four documents matched every name-based signal and correctly stayed, each identified only by opening it:
enrichment/eval/— application runtime that happens to be called evalRFC-090— runtime search architecture; mentions "eval" 29 timesRFC-116— its own header calls it the driver for a public fixture generator- two ranking scorers — they grade a product surface
A grep for a word is not a test of subject. Neither is a path heuristic. It only narrows what you have to read.
Watch for audit false positives too. Four "dangling" paths turned out to be
package-relative (podcast_scraper/data/known_models.yaml, resolvable at
runtime) and one was a RELEASE_vX.Y.Z template placeholder. Verify before
"fixing" working code.
The mount: one folder, both contexts¶
The private repo clones into a folder inside the public one, so one agent sees both. That is the whole trick, and it needs four changes before the first clone.
1. .gitignore — both forms, before the clone exists¶
/eval-data/
/eval-data
Both, deliberately. The trailing slash matches a directory (a plain clone);
the bare form matches a symlink (the sibling+symlink setup). Only the first
was there initially, and git status showed an untracked ?? eval-data for the
symlink variant — exactly the leak the entry exists to prevent.
It must exist before the clone does. Git will not add a nested repo's
contents — it writes a gitlink — but that still records the private repo's
existence and commit SHA in public history. And if the nested copy ever loses
its .git (a file copy, an rsync, a Docker build context), every file in it
becomes addable by git add -A.
2. Tool exclusions — every tool that walks the tree¶
Each of these was found by a gate going red, not by foresight:
| tool | exclusion |
|---|---|
| black | \| eval-data in the exclude regex (pyproject.toml) |
| isort | skip_glob = [..., "eval-data/**"] |
| markdownlint | --ignore "eval-data/**" --ignore eval-data |
| mkdocs | not in docs_dir, so nothing needed — verify |
3. A setup guide¶
Someone will be told "check out the other repo too" and needs to know where and why. One page, linked from the guides index.
4. No pointers back¶
If the private side lives on a private network, nothing public may point at it — not a URL, not a hostname, not in a comment. The mkdocs nav carries an explicit note saying the link is deliberately absent. 88 such links across 26 files had to be reverted once; write the rule down before that happens.
Citations: the thing that breaks six months later¶
When code cites evidence that now lives elsewhere, the citation rots and nothing notices. 19 of them rotted here, and exactly one test could see it.
Three forms, and reject anything else:
private-repo-name:path/to/doc.md a doc in the private repo
docs/path/to/doc.md a public doc — MUST resolve locally
#1234 an issue
The middle one is what catches the regression: a citation left as a bare local path fails immediately once the file is gone.
Do not point at the mount. The first fix used eval-data/…, which reads like
a local directory that is present in maybe one working tree in five. The check
almost never ran and the value was misleading. Name the repo instead.
Put the resolving check in the repo that has both halves. The public repo has the citation but not the evidence; a developer's checkout has the evidence only if they mounted it. The private repo has both — the docs on disk, and the public code via its pinned dependency — so the check runs unconditionally there, and is impossible here. ~140 lines.
Publish the claim, withhold the data. Every option here carries a
headline_metric and a measured_at alongside its citation — the number, its
date, the corpus size, what it superseded. A reader can see the claim is
specific and dated without being handed the raw material. The gap worth closing
is not visibility; it is that nothing verifies the published claim against the
withheld evidence.
Silent failure modes, all of which bit¶
The recurring shape: a surface with no assertion over it. Three separate incidents in one week, same root cause.
| pattern | what it did |
|---|---|
pytest.skip when a file is missing |
two tests went vacuous the moment the file moved; suite stayed green |
if script.exists(): around a feature |
a CLI flag silently became a no-op |
| an empty leftover directory | import pkg.sub still succeeded — Python treats it as a namespace package, so a missed caller passes locally and fails in CI |
cmd && lint \|\| echo "not installed" |
swallowed the linter's own exit code — 21 findings printed, then "skipped", exit 0 |
;-chained make recipe |
printed "synced." after every rsync failed |
Rules that follow:
- Never skip on a missing committed fixture. Absence is a failure.
- Delete empty directories after moving their contents, or the import lies.
- Guard, then run — never
a && b || echo. - When a check "passes", ask what would make it fail? If nothing, it is not a check.
Before deleting anything¶
- Who WRITES here, not just reads?
build_v3_fixtures.pywrote its dataset into a directory arc 2 deleted — a live break, not a stale comment. Grep for the path as a write target. - Prove byte-equality for anything generated. When a generated artifact moves, regenerate at the new location and hash it against the old. All three files matched, so the move changed location only.
- Check whether the tooling you plan to build already exists. The big
registry refactor was scoped around "nothing gates drift between these two
copies."
make profiles-checkalready did, and already ran inci-fast. The premise was false and most of the work was unnecessary. Look before pitching.
Verify by breaking¶
Every assertion added during this split was mutation-tested. Several were vacuous until broken:
- the citation check passed on 42 refs it never resolved
- the rationale check missed citations written without a
.mdsuffix - a "3 copies" assertion looked green because the
sedthat was meant to break it silently failed
The discipline: change the thing the test is about, watch it go red, restore. If you cannot make it fail, you have not written a test. A one-off script that breaks each surface in turn and reports CAUGHT/MISSED is worth the ten minutes.
CI mechanics that will surprise you¶
- Pushing cancels runs.
cancel-in-progress: trueon a workflow means a new push kills the in-flight run;falsemeans it queues, and GitHub keeps only one pending run per group, so a third push cancels the second. Four of this week's commits never completed a full run because of rapid pushes. Batch, then push once. - Heavy lanes may not run on PRs at all. The full integration and e2e lanes
here gate on
event_name == 'push'and a main ref. A green PR is not a green branch — check which lanes actually ran before believing it. - Tier-3 / nightly suites are where stale expectations hide. A spec that runs only nightly sat red for four nights after a rename because no PR ever exercised it.
Checklist¶
Arc 1
- [ ] Write the tradecraft/runtime sentence for your surface
- [ ] Sweep by paths, not names; read every candidate
- [ ] Copy in; nothing on the public side changes
- [ ] Bring the tests and gates, not just the content — they are what make it run
- [ ] Private gate green (
make cior equivalent)
Arc 2
- [ ]
.gitignoreboth forms, before any clone - [ ] Tool exclusions: black, isort, markdownlint, doc build
- [ ] Setup guide written and linked
- [ ] Port anything main added to a directory you delete
- [ ] Delete; fix every calling site, including writers
- [ ] Remove emptied directories (namespace-package trap)
- [ ] Citations converted to the three-form rule
- [ ] Resolving check added in the repo that has both halves
- [ ] Every new assertion mutation-tested
- [ ] Full gate chain, then one push