Skip to main content

Sync model

A splice's folder has two histories: the monorepo's, where it's a folder, and its upstream's, where it's the whole repository. Commits don't cross between them, changes do. This is the README's drawing, with letters for its names:

Before the pull​

Monorepo commits a to d, upstream commits k to n, and the rebuilt commits b' and d' on top of k: U(H) = U(B) = k

Circles are commits, newest on top, and boxes point at them. The lane lib/ holds the splice's upstream commits (scenario). H is HEAD, the monorepo commit whose splice is being examined. From H, the model finds five other commits:

NameMeaningAlso calledLives in
BThe boundary, where the rebuild starts: usually the last pull (where exactly).boundarythe monorepo
U(H)The synced commit: the upstream commit .splice records at HEAD.the upstream
U(B)The upstream commit recorded by B's .splice. R starts there. Usually U(H).the upstream
TThe fetched tip of tracked(X), in refs/splices/: the branch tracked for monorepo branch X, or the upstream branch named X.tracked, theirsthe upstream
RThe rebuild, ours: the folder's history since B, rebuilt as upstream commits on top of U(B).rebuild, ourscomputed

These commands locate the five commits in the diagram:

$ B="$(git splice boundary lib)" &&
> U_H="$(git config --file lib/.splice splice.commit)" &&
> U_B="$(git config --blob "$B:lib/.splice" splice.commit)" &&
> T="$(git rev-parse "splices/$(git splice key lib)/heads/main")" &&
> R="$(git splice rebuild lib)" &&
> git show -s --format='B: %s' "$B" &&
> git show -s --format='U(H): %s' "$U_H" &&
> git show -s --format='U(B): %s' "$U_B" &&
> git show -s --format='T: %s' "$T" &&
> git show -s --format='R: %s' "$R"
B: a
U(H): k
U(B): k
T: n
R: d

The letters now match the diagram: B is a, U(H) and U(B) are k, T is n, and R is d', the rebuilt copy of d.

A monorepo commit X has a sync point U(X): the upstream commit recorded in X's .splice.

The sync point changes only when .splice changes, for example in a pull. The monorepo commits between two such changes share the same sync point. A pull commit X normally brought in U(X^)..U(X), the range between the previous and new sync points. A push between them is the exception (scenario).

How R and T determine the status​

R and T are two histories of the same folder, as upstream commits. How they relate is the sync state:

R and Tstatus shows
the same files, or the same commitup to date
T is an ancestor of Rahead N
R is an ancestor of Tbehind N
neither, but with a common ancestorahead N, behind M
no common ancestor!! unrelated history

U(H) and T may be missing: right after init, there's no U(H) yet, and R starts as a root commit; before the first fetch, there's no T, and the splice is never fetched, or the upstream has no such branch.

A pull is a three-way merge, in the monorepo's layout:

  • base: normally the newest commit R and T have in common, git merge-base R T; after an upstream rewrite, pull can use the fetched branch's fork point
  • ours: HEAD, with the folder as it is
  • theirs: HEAD with T's files in the folder

Git merges them as usual. Only the folder differs between base and theirs, so only the folder can conflict. The result is one ordinary monorepo commit, which records T as the new U(H). It's the new B too, unless it merged commits that were never pushed.

The base is usually U(H). After a push, it's the pushed commit: R contains it, so the merge doesn't replay changes the upstream already has. That's also why U(H) alone says little about where a splice stands. A push moves T, and with it R's relation to T, but not U(H).

Where B is​

B is the newest commit on the monorepo's first-parent history that changed .splice and whose folder is the upstream commit it recorded. A pull that merged unpushed commits is not B because its folder also contains those local changes. Two exceptions:

  • Where the splice starts, its mount (its first .splice), B is that commit whatever its folder. The rebuild copies it too where its folder isn't U(B), e.g. after init. A git mv of the folder isn't a start: the rebuild follows it to the old path (scenario).
  • For a nested splice, B can be the last pull of the splice above, and R's history up to there comes from the upstream above, at its synced commit (nesting).

boundary prints B.

R, the rebuild​

git splice rebuild computes R. It recreates each monorepo commit since B that changed the folder as an upstream commit on top of U(B). A rebuilt commit contains the folder's tree without .splice and keeps the original author, dates and message.

After the pull​

Pulling the upstream creates commit e in the next diagram:

$ git splice pull -m e lib
ok lib fetched
ok lib: pulled df79a48

The same graphs after a pull: the pull commit e records n, so U(H) = n; B stays at a, and U(B) = k; R is e', a merge of the rebuilt d' and n

A box U(X) points at the commit recorded in X's .splice. After the pull in the drawing:

  • The pull commit e records n, so U(H) = n.
  • The earlier changes b and d were never pushed. They remain in e's folder, so that folder is not the tree of n. Therefore e is not B.
  • B stays at a, and U(B) stays at k.
  • When the rebuild reaches e, it has two histories to preserve: the unpushed changes ending at d', and the pulled history ending at n. Therefore e' has both as parents and is a merge commit.

The scenario builds this state.

After the push and bump​

A push sends b', d' and e'. B and U(B) stay where they are until git splice pull --bump records e' as U(H), in a new commit f:

$ git splice push lib
ok lib: pushed 8430dba to main
$ git splice pull --bump -m f lib
ok lib fetched
ok lib: bumped the synced commit to 8430dba

After the push and the bump: the bump commit f is H and B, U(H) = U(B) = e', and T and R point at e' too

f's folder is e', so f is the new B, and the rebuild has nothing to add: R = T = U(H) = U(B) = e' (scenario).

Parents of rebuilt commits​

In the situation after the pull, the rebuild walks through the monorepo commits from B to H. It rebuilds b and d, then reaches H, the pull commit e. At that point, the rebuilt tip—the newest commit produced so far—is d', and the synced commit U(H) is n. The parents of e' depend on the relationship between these commits. Neither contains the other, so e' gets d' as its first parent and n as its second parent. The rebuild has reached H, so e' is R.

Git numbers these parents first and second:

$ R="$(git splice rebuild lib)" &&
> git show -s --format='R^1 (first parent): %s' "$R^1" &&
> git show -s --format='R^2 (second parent): %s' "$R^2"
R^1 (first parent): d
R^2 (second parent): n

Most monorepo commits are copied on top of the rebuilt tip. A pull also records a new synced commit, so its rebuilt copy may need a second parent. The table shows how the relationship between those two commits determines the parents. A scenario with two pulls shows each pull becoming a separate merge in R's history.

Rebuilt tip and synced commitThe copy's parentsCommon situation
neither contains the otherrebuilt tip as first parent, synced commit as second: a mergepulling after both the monorepo and upstream changed, without rewritten upstream history (scenario)
The pull e is rebuilt as e', a merge with b' as its first parent and l as its second
synced commit contains the rebuilt tip, or there is no rebuilt tipsynced commitsquash-merging a feature branch that also changed the folder (scenario), or starting a splice
The synced commit U(H) contains U(B), and the squash merge s is rebuilt as s' on top of U(H)
rebuilt tip contains the synced commitrebuilt tip, and no copy if the folder didn't changebumping after a push while newer commits remain local (scenario), or editing .splice by hand
The bump f records d', which the rebuild e' already contains, so f adds no copy

The rebuilt copy becomes the new tip. Once the rebuild reaches H, its final tip is R.

R is computed, not stored​

R is never stored. It's computed from HEAD each time, and the same HEAD always gives the same R. So a commit pushed once is rebuilt identically, and the next push is a fast-forward. Since R is computed, .splice needs no monorepo commit ids, which a rebase or a squash merge would invalidate (design).

A push moves T but doesn't write to the monorepo, so U(H) stays at the last pull. It need not record anything: status compares R with T, and the deterministic rebuild contains the pushed commit with the same hash. A later pull normally starts where R and T meet (example).

After git splice push --rebuilt-commit, R doesn't contain the edited commit. git splice pull --bump must record it as U(H) before the next change, as push reminds you (scenario). Rebuilding walks every commit since B, pushed or not; pull --bump moves B forward where that cost matters.

A pull could have been a merge, with U(H) as its second parent: weak parents compares the two.