Central Test Suite
This is central’s own test suite, and also its book: pages are generated
from OCaml source files (via mdexp)
that are also real, running dune runtests, so the narrative prose, the
code, and the snapshots you see embedded in a page are never allowed to
drift from what actually happens when the code runs.
Layout
expect/holds every test file, both the literate, book-generating ones (each carrying@mdexpdirectives and a generated.mdcounterpart, checked in next to the.ml- seeSUMMARY.mdfor the current list of pages) and the plain ones (test__central.ml).gitrepo/andgitrepo-file-parser/test the.gitrepofile parser in isolation.
This is part of the effort to move central’s subrepo workflow off
git-subrepo and onto small, dedicated pieces of OCaml logic: each command
gets a page here explaining what it does and demonstrating it end to end.
The intent is for this book to grow to cover central more broadly, not
just the subrepo commands.
How does it relate to the user documentation?
There is inevitable overlap between this test book and the documentation in
doc/. The key difference is intent:
doc/is focused on the user experience - how to install, configure, and usecentral. It omits low-level details.test/is focused on correctness - every guardrail, every error case, every CLI invocation. It includes details that would overwhelm a user guide but are essential for someone modifying the code.
When both cover the same topic, the doc version explains what to do while the test version proves that it works.
Building
dune runtest
regenerates the book’s pages from their source .ml files, and runs every
other test in the tree. To browse the book itself:
cd test && mdbook serve --open
A day-to-day workflow
This walks through the everyday loop of working in central: edit
something, check central todo for what needs attention, act on it, and
confirm the dashboard is clear again.
central todo’s dashboard covers every subrepo central knows about, so
this fake repo (unlike the one on the Export page) is built
with a few of them, not just widget - enough to show the dashboard
correctly narrows down to only what needs attention.
With nothing out of the ordinary going on, the dashboard is empty:
$ central todo
Editing directly in central
Suppose someone edits repo/widget/README.md directly from within central
and commits it there - the way most day-to-day changes happen.
central todo now shows widget needs attention:
$ central todo
┌──────────┬───────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼───────────┼──────┤
│ central │ push │ 2 │
│ widget │ export │ 2 │
└──────────┴───────────┴──────┘
Following it means exporting:
$ central export widget -m "Document installation"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
widget’s row is still there, but the next step changed: export only
advances the subrepo’s subrepo branch, so widget’s own main branch
is now behind it. This is a genuine second step, not a leftover of the
first:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 6 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
advance-main is exactly that: catch widget’s main branch up:
$ central advance-main widget
==================== widget ====================
Updating 1185512..f452a6f
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
widget is left with a push next step, same as central itself: both
now have local commits their own remote doesn’t have yet - central’s
edit and the .gitrepo update export made, and the commit
advance-main just fast-forwarded widget’s own main to:
$ central todo
┌──────────┬───────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼───────────┼──────┤
│ central │ push │ 6 │
│ widget │ push │ │
└──────────┴───────────┴──────┘
push closes the loop for both at once - a real git push to each
one’s own remote:
$ central push central widget --yes
==================== central ====================
[ OK ] Pushed.
==================== widget ====================
[ OK ] Pushed.
And the dashboard is clear again - back where we started, the change now genuinely out, all the way to both real remotes:
$ central todo
Export
central export <repo> -m MSG moves changes made directly in central,
under repo/<repo>/, out into <repo>’s own standalone git repository - as
a single new commit on top of its subrepo branch.
It is the native-OCaml replacement for the one git subrepo command that
sits on central’s critical, day-to-day path: git subrepo push. Unlike
that command, export always squashes whatever changed since the last sync
into exactly one new commit, using the message supplied with -m.
Concretely, it:
- Verifies the subrepo’s
subrepobranch has not moved since the last sync recorded inrepo/<repo>/.gitrepo(if it has, someone likely pushed there directly - bring that in first before exporting). - Computes the diff of everything under
repo/<repo>/since that sync, usinggit diff --relative=, which also strips therepo/<repo>/path prefix from the patch. - Applies that patch as one commit onto the subrepo’s
subrepobranch, viagit apply --3way --index. - Updates
.gitrepoincentralto record the new sync point.
A fake repo to work with
This walkthrough uses Central_test_helpers to build a fake central
repo with a made-up subrepo, widget, and a fake standalone repo standing
in for widget’s own history, wired together with a .gitrepo file
exactly as central subrepo init would leave them.
Right after that, repo/widget/README.md in central and widget’s own
README.md are identical:
# widget
This is a fake [widget] repo, generated by [Central_test_helpers] for tests.
Editing directly in central, then exporting
Suppose someone edits repo/widget/README.md directly from within central
and commits it there - the way most day-to-day changes happen. Running
export then brings that change out, as a single new commit on top of
widget’s subrepo branch:
$ central export widget -m "Document installation"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
It landed in widget’s own history as one new commit on the subrepo
branch, carrying exactly that change:
Document installation
Initial commit
# widget
This is a fake [widget] repo, generated by [Central_test_helpers] for tests.
Added a line about installation.
Guardrails
Running export again right away, with nothing new to export, is a clean
error rather than an empty commit:
$ central export widget -m "Nothing changed"
==================== widget ====================
Error: Nothing to export: no changes under "repo/widget" since the last sync.
[123]
And if the subrepo’s subrepo branch moved since the last sync - for
example because someone pushed to it directly, bypassing central - export
refuses rather than silently basing the new commit on the wrong parent:
$ central export widget -m "Should not apply"
==================== widget ====================
Error: The [subrepo] branch of [widget] has moved since the last sync
recorded in [.gitrepo].
Hint: Bring those changes into central first with [central import] before
exporting, or pass [--force] to export anyway.
[123]
And as a precondition, export first checks that central’s own working
tree is clean - an unstaged edit is rejected outright, before anything else
is even looked at:
$ central export widget -m "Should not apply"
==================== widget ====================
Error: Repo "$CENTRAL_ROOT" has uncommitted changes.
M repo/widget/README.md
Hint: Commit or stash them first.
[123]
Exporting several subrepos at once
Chores that make the same systematic change across many subrepos are common
enough that export accepts more than one REPO at a time (or --all for
every subrepo it knows about) - each is exported in turn, in the order
given, with a separator banner between them, reusing the same -m message
for every commit:
$ central export widget gadget -m "Add license footer"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
==================== gadget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [gadget].
Both landed the same commit message on their own subrepo branch:
-- widget --
Add license footer
Initial commit
-- gadget --
Add license footer
Initial commit
central todo picks up both subrepos as done - since export doesn’t
touch central’s own history, both are shown ready for their next
step, advance-main, while central itself needs a push for the
.gitrepo updates:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 12 │
│ gadget │ advance-main │ │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
export stops at the first repo that fails, leaving the ones after it in
the list untouched - it doesn’t try to skip ahead and report a summary at
the end. Say gadget’s subrepo branch moved independently in the
meantime (someone pushed to it directly, bypassing central) while widget
is perfectly exportable:
$ central export widget gadget -m "Add license footer"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
==================== gadget ====================
Error: The [subrepo] branch of [gadget] has moved since the last sync
recorded in [.gitrepo].
Hint: Bring those changes into central first with [central import] before
exporting, or pass [--force] to export anyway.
[123]
Import
central import <repo> brings commits from the tip of the subrepo’s
subrepo branch - normally landed there by fetching from the subrepo’s real
remote - into repo/REPO. -m MSG is optional and defaults to "Import changes from REPO".
Unlike export, this has to account for central having moved on since the
last sync - so it picks between two ways of bringing the change in,
depending on whether central has any local changes of its own under
repo/REPO:
- If central hasn’t touched
repo/REPOat all since the last sync,repo/REPOat central’s current HEAD is - by construction - exactly what it was at the last sync point, so the subrepo’s diff is guaranteed to apply there too. There is nothing to merge, soimportdoesn’t build one: it applies the diff directly as a single new commit on top of HEAD. This is the default, and it keeps history linear in the common case where central had no reason to conflict with what the subrepo brings in. - Otherwise - central does have local changes of its own under
repo/REPO-importfalls back to an ordinary two-parentgit merge: it builds a new commit as a direct child of the central revision recorded inrepo/REPO/.gitrepo- not of the current HEAD - and applies the subrepo’s diff there, where it is guaranteed to apply cleanly regardless of what else happened on central’s actual HEAD since. That commit (the “import commit”) also updates.gitrepoto record the new sync point. Merging it into the active branch is then an ordinarygit merge: if there is a real conflict, git surfaces it exactly as it always does, for a human to resolve.
Applying directly
A fake central and a fake widget, as usual - someone pushes directly to
widget’s subrepo branch (standing in for something fetched from its
real remote), while central hasn’t touched repo/widget/ since the last
sync. So import applies the upstream change straight onto HEAD, no merge
needed:
$ central import widget
[ OK ] Imported into [main] directly (no merge needed).
A single, linear commit lands directly on top of HEAD - no second
parent, no merge commit. -m was left out here, so the message falls
back to "Import changes from widget":
Import changes from widget
Add fake subrepo widget
Initial commit
# widget
This is a fake [widget] repo, generated by [Central_test_helpers] for tests.
Edited directly upstream.
Guardrails
Running import again right away, with nothing new upstream, is a clean
error rather than an empty commit - symmetric to export’s own guard:
$ central import widget
Error: Nothing to import: the [subrepo] branch of [widget] has not moved
since the last sync.
[123]
And just like export, import first checks that central’s own working
tree is clean - an unstaged edit is rejected outright:
$ central import widget
Error: Repo "$CENTRAL_ROOT" has uncommitted changes.
M README.md
Hint: Commit or stash them first.
[123]
Falling back to a merge
This time, central does touch something under repo/widget/ since the
last sync - a new file of its own, NOTES.md, unrelated to the file
upstream changed. import can no longer assume repo/widget/ is
untouched, so it falls back to building an import commit as a child of the
last sync point and merging it in. Since the two sides touch different
files, the merge still completes cleanly on its own:
$ central import widget
[ OK ] Built the import commit.
Merge made by the 'ort' strategy.
repo/widget/.gitrepo | 4 ++--
repo/widget/README.md | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
[ OK ] Imported into [main].
Unlike the direct case above, the import commit sits as a child of the old sync point, not of central’s HEAD at the time - a separate line of history, joined by the merge:
Merge widget import
Add local notes under widget
Add fake subrepo widget
Initial commit
A real conflict
Same fallback, but this time central and the subrepo both edit the very
same line under repo/widget/ since the last sync:
$ central import widget -m "Bring in upstream retitle"
[ OK ] Built the import commit.
Auto-merging repo/widget/README.md
CONFLICT (content): Merge conflict in repo/widget/README.md
Automatic merge failed; fix conflicts and then commit the result.
Error: Merge conflict while importing - resolve the conflicts above in
[main], then [git add] the resolved files and [git commit] to finish the
merge.
Hint: .gitrepo has already been updated as part of the import commit being
merged - no further action needed there once the merge is complete.
[123]
Central is left in the middle of the merge, exactly as an ordinary
git merge would - conflict markers included, with the trailing
revision on >>>>>>> naming the import commit, the side being merged
in. That revision is a deterministic mock rather than the real sha -
see Deterministic Revisions for why:
<<<<<<< HEAD
# widget, edited by central
=======
# widget, retitled upstream
>>>>>>> 1185512b92d612b25613f2e5b473e5231185512b
Resolving it is the same as for any git merge conflict - pick a
resolution, git add, git commit:
Resolve README retitle conflict
Central retitles the README
Add fake subrepo widget
Initial commit
.gitrepo was already updated as part of the import commit, so
export is available again right away - it carries the resolution
itself out to widget, since that’s what central’s own history now
disagrees with:
$ central export widget -m "Resolve conflicting retitle"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
Resolve conflicting retitle
Upstream retitles the README
Initial commit
Stitch
central stitch <repo> is for a narrower situation than import:
someone exported a change, then reworked the commit(s) that just landed in
the subrepo’s own history - splitting one commit into a nicer sequence,
squashing, reordering, rewording - without changing the tree they arrive at.
repo/<repo>/.gitrepo in central still names the pre-rewrite commit, which
no longer exists on the subrepo’s subrepo branch.
Running import at this point would either fail outright (the old commit
isn’t an ancestor of the new tip any more) or, if it somehow went through,
apply an empty patch for no reason - there is nothing to actually bring in,
since the tree hasn’t changed. stitch is the narrow fix: it just repoints
.gitrepo at the subrepo’s new tip, and commits that update with an
auto-generated message - central’s copy of a subrepo isn’t public history,
so unlike export there’s nothing worth writing by hand here.
The required pre-conditions:
- The
subrepobranch has actually moved since the last sync. - There is no real content diff between the commit recorded in
.gitrepoand the subrepo’s current tip - i.e. this really is a pure history rewrite. - Central has no local changes of its own under
repo/<repo>/since the last sync.
Rewriting history after an export
Suppose a change lands in central and gets exported as usual, as one squashed commit:
$ central export widget -m "Document feature A and B"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
Now, imagine that squashed commit gets reworked directly in widget’s
own history into two smaller, better organized commits - reaching the
exact same final README.md either way. .gitrepo still names the
abandoned squash commit, which no longer exists on subrepo:
import would refuse here - the commit .gitrepo names is gone, so it
can’t tell this apart from a more troubling rewrite. stitch recognizes
it for what it is and just catches .gitrepo up, committing the update
itself with an auto-generated message:
$ central stitch widget
==================== widget ====================
[ OK ] Stitched [widget].
Stitch repo widget
export widget
Document feature A and B
Add fake subrepo widget
Initial commit
Running stitch again right away is a clean error - .gitrepo is
already caught up, so there is nothing left to stitch:
$ central stitch widget
==================== widget ====================
File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0:
Error: Nothing to stitch: the [subrepo] branch of [widget] is already the
commit recorded in [.gitrepo].
[123]
And central todo confirms both sides agree - widget’s own main
just needs to catch up, same as after any ordinary export:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 8 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
Guardrails
stitch only repoints .gitrepo - it never brings in real content changes.
If the subrepo’s tip actually differs in substance from what .gitrepo
records, this isn’t a pure history rewrite any more, and stitch refuses
rather than silently pretending the trees still match:
$ central stitch widget
==================== widget ====================
File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0:
Error: Nothing to stitch: the [subrepo] branch of [widget] is already the
commit recorded in [.gitrepo].
[123]
$ central stitch widget
==================== widget ====================
File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0:
Error: Cannot stitch: "repo/widget" has content changes between the commit
recorded in [.gitrepo] and its current tip - this isn't a pure history
rewrite.
Hint: Use [central import] instead to bring those changes in.
[123]
And if central itself has moved on with local changes of its own under
repo/<repo>/ since the last sync - even alongside an otherwise legitimate
history rewrite upstream - stitch refuses too, since a plain re-pointing
of .gitrepo can no longer account for the full picture:
$ central export widget -m "Document feature A"
==================== widget ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [widget].
$ central stitch widget
==================== widget ====================
File "$CENTRAL_ROOT/repo/widget/.gitrepo", line 1, characters 0-0:
Error: Cannot stitch: central has local changes of its own under
"repo/widget" since the last sync.
Hint: Export or import those changes first, then stitch.
[123]
And as a precondition, stitch first checks that central’s own working
tree is clean - an unstaged edit is rejected outright, before anything else
is even looked at:
$ central stitch widget
Error: Repo "$CENTRAL_ROOT" has uncommitted changes.
M README.md
Hint: Commit or stash them first.
[123]
Push
central push <repo>... pushes the given repo’s (or repos’) main branch
to its real remote - the final step once a change has made its way all the
way to a subrepo’s own main (via export, then advance-main if needed),
or simply for central’s own local commits.
By default, before pushing, it opens gitk --all to visualize the history
and asks for confirmation; every example below passes --yes to skip both,
the way this would run non-interactively (e.g. from a script or CI).
Nothing to push
Right after Central_test_helpers.create, every repo’s main is already
up to date with its own remote - pushing is a clean no-op:
$ central push central --yes
==================== central ====================
[SKIP] Skipping [push] (not applicable).
Pushing central’s own commits
Once central has a local commit its remote doesn’t have yet, push is
applicable, and sends it there. Here, that commit comes from importing an
upstream change from widget - the everyday way central ends up with
something to push:
$ central import widget
[ OK ] Imported into [main] directly (no merge needed).
Add fake subrepo widget
Initial commit
$ central push central --yes
==================== central ====================
[ OK ] Pushed.
The commit really is on the remote now - reading its log directly
(rather than trusting central’s own say-so) confirms it:
Import changes from widget
Add fake subrepo widget
Initial commit
Pushing a subrepo
The same, for a subrepo’s own main - as it would be after a change went
through export (and advance-main, catching main up to the subrepo
branch export landed on). Here, to isolate what push itself does, main
gets a commit directly:
$ central push widget --yes
==================== widget ====================
[ OK ] Pushed.
Direct edit in widget
Initial commit
--dry-run/interactive mode (the default) opens gitk --all to preview the
history before confirming - which needs a real display and isn’t something
this book can exercise deterministically. The “not applicable” guard is
checked before the preview, though, so that much is safe to demonstrate
regardless of confirm mode:
$ central push central --dry-run
==================== central ====================
[SKIP] Skipping [push] (not applicable).
Advance Main, Advance Subrepo
export only ever moves a subrepo’s subrepo branch - it never touches
main, so main falls one commit behind after every export. Two commands
catch it back up, at different scopes:
central advance-main <repo>fast-forwards that subrepo’s localmainbranch to matchsubrepo, from the same machine anexport(orimport) just ran on - the everyday, single-machine case.central advance-subrepo <repo>is the more thorough version, aimed at a second machine: after pulling central’s ownmain(which brings in whatever.gitreponow says), it fast-forwards that subrepo’s localsubrepoandmainbranches to match - both in one go, provided the commit.gitrepopoints to is already present locally (e.g. already fetched from the subrepo’s real remote -advance-subrepoitself never fetches anything).
Advance-main, right after an export
export lands a commit on widget’s subrepo branch - main hasn’t
moved:
$ central advance-main widget
==================== widget ====================
Updating 1185512..f452a6f
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
main is fast-forwarded to the same commit subrepo already carried:
Document installation
Initial commit
Right after Central_test_helpers.create, there’s nothing for main to
catch up to:
$ central advance-main widget
==================== widget ====================
[SKIP] Skipping [advance-main] (not applicable).
Advance-subrepo, catching up a fresh checkout on another machine
Say a change was already exported and pushed from elsewhere: central’s own
.gitrepo (as pulled from its real remote) now points at a newer widget
commit, already fetched into this machine’s own checkout of widget (e.g.
by a plain git fetch, run once ahead of time), but not yet merged into
either its subrepo or main branch. advance-subrepo brings both up to
date in one command:
$ central advance-subrepo widget
==================== widget ====================
Updating f452a6f..1185512
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
Updating f452a6f..1185512
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
subrepo is on the new commit now:
Document installation
Initial commit
And so is main - both branches now point at the very same commit:
Document installation
Initial commit
Todo
central todo is the dashboard: one row for central itself, plus one row
per subrepo that has something outstanding - each with its Next step
(what central command to run next) and, for subrepos, Diff (how many
lines under repo/<name>/ differ from what’s already been dealt with).
Subrepos with nothing outstanding simply don’t appear, so the table only
ever shows what actually needs attention - see the day-to-day
workflow for it used end to end.
central’s own row uses Repo_config.root_repo_name as its label - "central" by
default, but configurable per-repo (see config.md).
Once repo/widget/ has an uncommitted-to-upstream change, widget shows
up with export as its next step, and central itself already needs a
push for the commit that introduced it:
After export, widget’s next step becomes advance-main - the Diff
column goes blank, since there’s no longer a content diff to size, only a
branch to fast-forward:
And with a .central/repo-config.json setting a custom name, that name -
not "central" - is what labels the top row:
Config
central reads two small, optional JSON config files, each with a
$schema under schema/ for editor support:
Repo_config- read from.central/repo-config.json, at the root of the monorepo itself. Currently justname, the string that identifies “the central repo” among the positional arguments to commands likepushandtodo(as opposed to a subrepo) - defaults to"central".User_config- read from the XDG config directory,~/.config/central/user-config.json. Empty for now; a placeholder for per-user settings to come.
Both are entirely optional: a missing file just means the default.
Repo_config.find_and_load is what commands actually call: it looks for
.central/repo-config.json in the repo and falls back to the default if
it isn’t there.
Unknown fields are rejected rather than silently ignored - a typo in the config file is a loud error, not a silently-dropped setting:
User_config follows the same shape, currently an empty record:
Subrepo
Central.Subrepo.t identifies one of the sub-repos vendored under repo/
in the enclosing monorepo. It isn’t a fixed, hand-maintained enum: it’s just
a validated string (the directory name under repo/), and the set of known
sub-repos is discovered dynamically by walking the filesystem.
Discovery
all walks repo/’s direct children and keeps the ones that contain a
.gitrepo file, sorted by name:
find_on_disk looks a sub-repo name up against all - unlike of_string,
it validates that the name actually names a vendored sub-repo, not merely
that it has the right shape:
Manipulating paths
Unlike all and find_on_disk above, everything in this section is pure
path manipulation: none of it reads the filesystem, or confirms that
anything it is given actually exists on disk.
root and gitrepo_file_path locate a sub-repo’s own directory, and its
.gitrepo file, as paths in the enclosing monorepo:
central_path and subrepo_path convert a path back and forth between the
two frames of reference a path can be expressed in: relative to the
sub-repo’s own root (what the sub-repo’s standalone checkout sees), or
relative to the enclosing monorepo (prefixed with root, what the monorepo
checkout sees):
repo/widget/src/dune
subrepo_path returns None for a path that doesn’t belong to the
sub-repo at all - and, since a path in the sub-repo’s own repo is never
empty, for the sub-repo’s root itself:
Deterministic Revisions
Every command that touches git prints real revisions - commit hashes,
MERGE_HEAD, the trailing >>>>>>> <sha> on a conflict marker - and a real
revision is different every time a test runs, since it’s derived from tree
content, parents, and commit timestamps, none of which stay fixed between
runs. Left alone, that would make every snapshot in this book flaky.
Central_test_harness fixes this by redacting: every real revision it sees
is auto-detected and rewritten to a deterministic mock counterpart (see the
conflict example in Import for one in the wild), so the same
command run today or a year from now prints the exact same snapshot.
to_mock_rev/register_rev map a real revision to its mock; redact
applies that mapping - and every abbreviated prefix git might plausibly
print for it - to a piece of text.
A mock revision is itself just a 40-character hex string, indistinguishable
from a real one. That has one sharp edge worth pinning down directly: a
mock revision can coincidentally contain a run of characters equal to
some other, unrelated revision’s abbreviated prefix. redact has to
substitute every registered revision starting from the original text in a
single pass, so that a mock revision it has already written out is never
handed back for re-examination - otherwise a later, shorter pattern could
match inside it and corrupt it:
1185512b92d612b25613f2e5b473e5231185512b