Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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> --help for 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 under repo/<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 .gitrepo to 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 under repo/<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>/ - import falls back to an ordinary two-parent git merge: it builds a new commit carrying the subrepo’s changes, then merges it into whatever branch you have checked out (normally main).

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.