Introduction to central
A user-facing tour of the central CLI.
This book is a short, user-facing tour of central - the command-line tool
that manages the relationship between a monorepo and the standalone git
repos (“subrepos”) that live inside it.
This is an introductory read, not the full reference. Where it matters, it points at
central <command> --helpfor the details it leaves out. For a detailed, developer/agent-facing account of exactly how each command behaves, including every guardrail and error case, see central’s own test suite, which doubles as executable documentation.
Why subrepos?
A number of independent projects are sometimes developed together, in one place, so that changes spanning several of them can be made and reviewed atomically. But each of those projects is also a real, standalone open-source repository with its own history, its own remote, and its own life outside the monorepo.
central is what keeps those two things true at once. Each subrepo lives
under repo/<name>/ in the monorepo, with a small .gitrepo file
recording where its own, independent git history currently stands relative
to the monorepo’s. Two commands keep that relationship moving:
central export- takes changes made directly underrepo/<name>/in the monorepo and turns them into a real commit in the subrepo’s own history.central import- brings commits made in the subrepo’s own history (typically fetched from its real, public remote) back into the monorepo.
Together, they mean you can edit a subrepo’s code from within the monorepo like any other file, and separately, its own history stays a normal, coherent git history - not a copy, not a submodule, a real independent repo that happens to also be mirrored here.
What’s in this book
- Exporting a change - the everyday case: you edited something
under
repo/<name>/, and want it to become a proper commit in that subrepo’s own history. - Importing a change - the other direction: bringing commits made directly in a subrepo’s own history back into the monorepo, conflicts included.
- Stitching a rewritten history - a narrower case: the
subrepo’s history was reworked after an export without changing its
content, so there’s nothing to import, only
.gitrepoto catch up. - Pushing your changes - the last step: getting a change that’s landed in a subrepo’s own history (or in the monorepo’s) out to its real remote.
central todo - a dashboard of outstanding work across every subrepo -
comes up throughout as the constant thread tying these commands together.
More chapters will follow as central grows, notably recovering from less
common situations (a subrepo pushed to directly).
Exporting a change
Most day-to-day edits to a subrepo happen the easy way: you just edit files
under repo/<name>/ directly in central, like you would any other file, and
commit as usual. The one extra step is telling central to carry that change
out into the subrepo’s own history:
central export <name> -m "<message>"
Say you’ve just edited widget’s README and committed that in central. Not
sure what to do next? central todo always knows:
$ 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 now has a real new commit, with your message, on top of its
subrepo branch:
Document installation
Initial commit
Checking back in with central todo shows widget’s row is still
there, but the next step changed: export only advances the
subrepo branch, so widget’s own main is now behind it - a
genuine second step, covered in
Pushing your changes, not a leftover of the first:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 6 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
export always squashes everything you changed under repo/<name>/ since
the last export into that one new commit - it doesn’t try to replay your
central commits one by one. If you made several commits in central along
the way, only the final state matters; -m is the message the subrepo
commit gets.
If there’s nothing to export
Running export again right away, with nothing new under repo/<name>/,
is a clean error rather than an empty commit - a useful sanity check if
you’re not sure whether your change already went out:
$ central export widget -m "Nothing changed"
==================== widget ====================
Error: Nothing to export: no changes under "repo/widget" since the last sync.
[123]
Exporting several subrepos at once
Some changes are chores that touch many subrepos the same way - bumping a
shared convention, applying the same fix everywhere. export accepts more
than one REPO on the command line (or --all for every subrepo), and
exports them one after another, in the order given, reusing the same -m
message for each:
$ central export widget gadget sprocket -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].
==================== sprocket ====================
[ OK ] Applied patch in the subrepo.
[ OK ] Exported to [sprocket].
$ central todo
┌────────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├────────────┼──────────────┼──────┤
│ central │ push │ 18 │
│ gadget │ advance-main │ │
│ sprocket │ advance-main │ │
│ widget │ advance-main │ │
└────────────┴──────────────┴──────┘
If one of them fails partway through - say a subrepo’s subrepo branch
moved on its own since the last sync - export stops right there: the
repos before it keep whatever they already got exported, and the ones after
it are never even attempted. See test/expect/export.ml for that scenario
in detail, along with every other guardrail covered above.
That’s the everyday case covered. The next chapter, Importing a change, covers the other direction - bringing commits made directly in a subrepo’s own history back into central.
Importing a change
The other direction: commits made directly in a subrepo’s own history -
typically because someone fetched from its real, public remote - don’t show
up under repo/<name>/ in central on their own. Bringing them in is:
central import <name>
-m "<message>" is optional here - it defaults to "Import changes from <name>". Unlike export, repo/<name>/ in central isn’t public history, so
there’s rarely anything worth saying beyond that.
Unlike export, central may itself have moved on with changes of its own in
the meantime, so import has to pick between two ways of bringing the
subrepo’s commits in:
- If central hasn’t touched
repo/<name>/at all since the last sync, the subrepo’s changes are applied straight onto the current commit, as a single new commit - no merge, because there is nothing underrepo/<name>/for it to possibly conflict with. This is the default, and keeps history linear in the common case. - Otherwise - central does have changes of its own under
repo/<name>/-importfalls back to an ordinary two-parentgit merge: it builds a new commit carrying the subrepo’s changes, then merges it into whatever branch you have checked out (normallymain).
The default: applying directly
Say new commits landed on widget’s own subrepo branch (from fetching its
real remote), while central moved on with an unrelated change of its own -
elsewhere, outside repo/widget/. central todo already knows there’s
something to bring in:
$ central todo
┌──────────┬───────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼───────────┼──────┤
│ central │ push │ 2 │
│ widget │ import │ │
└──────────┴───────────┴──────┘
Following it here means importing. Since central never touched
repo/widget/, the change lands directly, with no merge commit:
$ central import widget -m "Bring in upstream usage example"
[ OK ] Imported into [main] directly (no merge needed).
A single, linear commit - no second parent to merge:
* Bring in upstream usage example
* Unrelated central change
* Add fake subrepo sprocket
* Add fake subrepo gadget
* Add fake subrepo widget
* Initial commit
repo/widget/README.md now carries the upstream change:
# widget
This is a fake [widget] repo, generated by [Central_test_helpers] for tests.
Upstream added a usage example.
And central todo shows both central and widget with the same,
ordinary next step - push, covered next:
$ central todo
┌──────────┬───────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼───────────┼──────┤
│ central │ push │ 8 │
│ widget │ push │ │
└──────────┴───────────┴──────┘
Falling back to a merge
If central has touched repo/widget/ since the last sync - even in a file
the upstream change never went near - import can no longer assume
repo/widget/ is untouched, so it falls back to building a separate import
commit and merging it in, an ordinary two-parent git merge:
$ central import widget -m "Bring in upstream usage example"
[ 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
|\
| * Bring in upstream usage example
* | Add internal note
* | Add fake subrepo sprocket
* | Add fake subrepo gadget
|/
* Add fake subrepo widget
* Initial commit
When it doesn’t merge cleanly
Falling back to a merge means it can behave like an ordinary git merge in
every other way too: if your own central changes happen to touch the exact
same lines the upstream commits did, import leaves you in the middle of a
real conflict, markers included:
$ 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]
Resolve it exactly like you would any git merge conflict - edit the
file, then git add and git commit. .gitrepo is already updated at
this point, so there’s nothing else to do for the subrepo side of it:
<<<<<<< HEAD
# widget, edited by central
=======
# widget, retitled upstream
>>>>>>> 1185512b92d612b25613f2e5b473e5231185512b
With the merge committed, export is available again right away - it
carries your resolution out to widget, since that’s what its 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
central todo shows the same pattern as after any export: widget’s
own main is left one advance-main behind its subrepo branch -
nothing to do with the conflict just resolved:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 8 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
Either way, once a change has landed in a subrepo’s own history, the last step is pushing it out for real.
Stitching a rewritten history
A narrower situation than importing a change: you exported a
change, then reworked the commit(s) that just landed in the subrepo’s own
history - splitting it into a nicer sequence, rewording, reordering -
without changing the content it arrives at. repo/<name>/.gitrepo in
central still names the pre-rewrite commit, which doesn’t exist on the
subrepo’s subrepo branch any more.
import isn’t the right tool here: there is nothing to actually bring in,
since the content hasn’t changed - only the commit(s) carrying it have. The
fix is:
central stitch <name>
which simply repoints .gitrepo at the subrepo’s new tip and commits that
update itself, 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.
Say widget’s README got a couple of new paragraphs, exported as usual:
$ 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 commits instead - reaching the exact same
final README.md either way:
Document feature B
Document feature A
Initial commit
import would refuse at this point - the commit .gitrepo names is
gone, so it can’t tell this apart from history that was reset or
rewritten in some more troubling way. stitch recognizes it for what
it is and just catches .gitrepo up:
$ central stitch widget
==================== widget ====================
[ OK ] Stitched [widget].
central todo shows the same pattern as after any export - widget’s
own main is one advance-main behind, nothing to do with the rewrite
just stitched over:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 8 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
stitch refuses if the subrepo’s tip has any real content diff against what
.gitrepo records - that would mean actual changes, not just a rewrite, and
those need import instead - or if central has moved on with local changes
of its own under repo/<name>/ in the meantime.
That covers the two directions changes travel between central and a subrepo. Either way, once a change has landed in a subrepo’s own history, the last step is pushing it out for real.
Pushing your changes
export (and advance-main, when needed) bring a change all the way to a
subrepo’s own main branch - but only in your local checkout. The last
step is getting it out to the subrepo’s real remote:
central push <name>
central itself needs the same treatment: any local commit not yet on its
own remote (including, as you’re about to see, the one export itself just
made to update .gitrepo). By default push opens gitk to show you what
you’re about to push and asks for confirmation; pass --yes to skip both
and push right away.
Finishing the loop
Picking up where Exporting a change left off: widget’s
README was edited in central and committed there. central todo is the
constant thread through all of this - it’s what tells you export is next:
$ 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].
Checking back in, widget’s next step changed - export only moved
its subrepo branch, so main is now behind it:
$ central todo
┌──────────┬──────────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼──────────────┼──────┤
│ central │ push │ 6 │
│ widget │ advance-main │ │
└──────────┴──────────────┴──────┘
advance-main catches it up:
$ central advance-main widget
==================== widget ====================
Updating 1185512..f452a6f
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
Now both central and widget have local commits their remotes don’t
have yet - the dashboard agrees, with the same next step for both:
$ central todo
┌──────────┬───────────┬──────┐
│ Repo │ Next step │ Diff │
├──────────┼───────────┼──────┤
│ central │ push │ 6 │
│ widget │ push │ │
└──────────┴───────────┴──────┘
push sends them all in one go:
$ central push central widget --yes
==================== central ====================
[ OK ] Pushed.
==================== widget ====================
[ OK ] Pushed.
And the dashboard is clear - back where it started, the change now genuinely out, all the way to both real remotes:
$ central todo
That’s the full loop, start to finish: edit under repo/<name>/, export
it, advance-main if main is left behind, push - and central todo
tells you what’s next at every step along the way.