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. Every command works out what to cross from five commits:

CommitLives in
BThe boundary, where the rebuild starts: on the monorepo's first-parent history, the newest commit that changed .splice and whose folder is the upstream commit it recorded. Usually the last pull. Where the splice starts, the mount (its first .splice, or a git mv), B is that commit whatever its folder, and the rebuild copies B too where its folder isn't U(B), e.g. after init, or a git mv with unpushed changes. For a nested splice, the last pull of the splice above can be B (nesting).the monorepo
U(H)The synced commit: the upstream commit .splice records at HEAD.the upstream
U(B)Where R starts: the upstream commit B's .splice records, and B's folder. Usually U(H).the upstream
TTheirs: the upstream branch as the monorepo last saw it, in refs/splices/.the upstream
RThe rebuild, ours: the folder's history since B, rebuilt as upstream commits on top of U(B), and merged with U(X) for each pull X since that merged unpushed commits.computed

For a nested splice, R's history up to the last pull of the splice above comes from the upstream above, at its synced commit (nesting).

A monorepo commit X whose .splice records an upstream commit has that as its sync point U(X): what git config --blob X:path/.splice splice.commit prints. It only changes where a commit changes .splice, e.g. a pull, so the commits in between share one, as commits can share a tree. A pull X brought in U(X^)..U(X), the synced commits before and after it, unless a push came in between (scenario).

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.

Output shows the Git commands that find each of them.

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

R, the rebuild​

The monorepo's commits that changed the folder are the splice's own changes, but they have the monorepo's layout and the monorepo's history. R copies each of them, since B, as an upstream commit: the folder's tree without .splice, the same author, dates and message, on top of U(B).

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

Circles are commits, newest on top, and boxes point at them. A frame shows what path/.splice records in the commit it holds. b and d change path/, so the rebuild copies them as b' and d', on top of U(B); c doesn't, so it's left out. The copies exist only in R, so they're dashed (scenario).

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 prime, a merge of the rebuilt d prime and n

After git splice pull, the pull commit e records n, so U(H) = n. But b and d were never pushed, so e's folder isn't n, and a rebuild from e would send their changes as one lump under e's message. So B stays a, and U(B) = k still. R copies b and d as before, and e as e', a merge of d' and n, as git pull would have (scenario).

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:

After the push and the bump: the bump commit f is H and B, U(H) = U(B) = e prime, and T and R point at e prime 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).

Pulls in between​

Between B and H, every commit that changed .splice differs from the commit it recorded, or it would be B. The rebuild copies each of them with that folder, and picks its parents from two commits: what the rebuild has so far, before, and the commit it recorded, U(X):

before and U(X)The copy's parentsWhen
neither contains the otherbefore, then U(X): a mergea pull merged unpushed commits, as e and e' above (scenario)
U(X) contains before, or there's no beforeU(X)a squash merge put local edits into the pull commit itself (scenario), or the commit is where the splice starts, e.g. a git mv (scenario)
before contains U(X)before, and no copy if the folder didn't changea hand edit of .splice, or a bump while commits are unpushed (scenario)

So each pull since B that merged unpushed commits adds one merge to R, with the commit it pulled as the second parent. R takes that commit from the pull's .splice, not from T: T may have moved on since, with a fetch, and the merge would then claim changes the pull never brought in.

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. And since R is computed, .splice needs no monorepo commit ids, which a rebase or a squash merge would invalidate (design).

Three ways, both directions​

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

R and TStateWhat crosses
the same files, or the same commitup to datenothing
T is an ancestor of Rpushpush sends R, a fast-forward
R is an ancestor of Tpullpull merges T in
neither, but with a common ancestordivergedpull merges T in, then push sends R
no common ancestorunrelated historynothing: pull and push refuse, and print the commands to keep either side, or both

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

  • base: the newest commit R and T have in common, git merge-base R T
  • 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).

Why a push isn't recorded​

A push never writes to the monorepo, so U(H) stays what the last pull brought in. Recording the push would take a monorepo commit that changes .splice, on the branch that pushed. Merging that branch would then carry the record to a branch that syncs with a different upstream branch: a feature branch's .splice would name a commit of the upstream's feature branch, and after the merge, main's would too.

It isn't needed either. status compares the folder's files with T, and after the push they're the same, so the splice is up to date. And a pull merges from where R and T meet, not from U(H). The rebuild is deterministic, so R contains the pushed commit, with the same hash, and they meet there (example). Except after git splice push --rebuilt-commit: R doesn't contain an edited commit, so git splice pull --bump has to record it as U(H) before the next change, as push reminds you (edited-before-push).

The cost is that each rebuild walks every commit since B, pushed or not. Bumping records the pushed commit as U(H) where that matters.

Output​

The walkthrough's sandbox, where vendor/pkg-a/ is behind its upstream by one commit.

For vendor/pkg-a/:

monorepo, newest first pkg-a.git, newest first

. lib-c: first version T > pkg-a: a second commit, after the clone
B = splice: clone vendor/pkg-a |
(folder = U(B)) U(H) = R * pkg-a: seed

On the left, as in the README, = marks the commits that change .splice, * those that change other files in the folder, whether or not they change files elsewhere too, and . those that don't touch the folder. On the right, as in git splice log, < marks R's commits that T lacks, > T's commits that R lacks. Since B, no commit changed the folder, so R has nothing to add to U(H): R = U(H). U(H) is an ancestor of T, so status shows behind 1, the commit between them.

B is what boundary prints. Here, it's the newest commit that changed .splice:

$ git log --first-parent -1 --oneline -- vendor/pkg-a/.splice
3aef6fd splice: clone vendor/pkg-a from main at 9bb866a

U(H) is the commit recorded there. fetch copied it into the monorepo, so Git can show it:

$ git log -1 --oneline "$(git config --file vendor/pkg-a/.splice splice.commit)"
9bb866a pkg-a: seed

Its folder is U(H), so it's B, and U(B) = U(H), where R starts:

$ git diff --quiet "$(git config --file vendor/pkg-a/.splice splice.commit)" "$(git log --first-parent -1 --format=%H -- vendor/pkg-a/.splice):vendor/pkg-a" -- ':!.splice' && echo "B's folder is U(B)"
B's folder is U(B)

T is the fetched branch. Its history leads to U(H):

$ git log --oneline splices/pkg-a/main
703b936 pkg-a: a second commit, after the clone
9bb866a pkg-a: seed

R is what rebuild prints, and log --graph draws it and T, labeled, down to the commit they have in common, o. Nothing changed the folder since B, so R is U(H):

$ git splice log --graph vendor/pkg-a
=== vendor/pkg-a (main)
> 703b936 (T) pkg-a: a second commit, after the clone (Walkthrough <walkthrough@example.com>)
o 9bb866a (R) pkg-a: seed (Walkthrough <walkthrough@example.com>)

A change in the monorepo, to a file the upstream didn't change:

$ echo "local" >vendor/pkg-a/local.txt && git add vendor/pkg-a/local.txt && git commit -q -m "pkg-a: a local file"

Now R has a commit T lacks, and the other way around:

$ git splice status vendor/pkg-a
On branch main

ahead 1, behind 1 vendor/pkg-a
$ git splice log --graph vendor/pkg-a
=== vendor/pkg-a (main)
< f985b9f (R) pkg-a: a local file (Walkthrough <walkthrough@example.com>)
| > 703b936 (T) pkg-a: a second commit, after the clone (Walkthrough <walkthrough@example.com>)
|/
o 9bb866a pkg-a: seed (Walkthrough <walkthrough@example.com>)

The pull merges T in, with o as the base:

$ git splice pull vendor/pkg-a
ok vendor/pkg-a fetched
ok vendor/pkg-a: pulled 703b936

The pull commit records T as U(H). Its folder isn't T's tree, since it has the local file too, so it isn't the new B: B stays the clone, and U(B) its synced commit, pkg-a: seed,

$ git log -1 --format=%s "$(git splice boundary vendor/pkg-a)"
splice: clone vendor/pkg-a from main at 9bb866a

and R joins the local commit with T by a merge, as git pull would have:

$ git splice log --graph vendor/pkg-a
=== vendor/pkg-a (main)
< 6dc0e40 (R) splice: pull vendor/pkg-a from main at 703b936 (Walkthrough <walkthrough@example.com>)
|\
< | f985b9f pkg-a: a local file (Walkthrough <walkthrough@example.com>)
| o 703b936 (T) pkg-a: a second commit, after the clone (Walkthrough <walkthrough@example.com>)
|/
o 9bb866a pkg-a: seed (Walkthrough <walkthrough@example.com>)

The push sends R, a fast-forward of T:

$ git splice push vendor/pkg-a
ok vendor/pkg-a: pushed 6dc0e40 to main
$ git log --graph --format='%h %s' splices/pkg-a/main
* 6dc0e40 splice: pull vendor/pkg-a from main at 703b936
|\
| * 703b936 pkg-a: a second commit, after the clone
* | f985b9f pkg-a: a local file
|/
* 9bb866a pkg-a: seed

Now R and T are equal, and the splice is up to date. U(H) is still the commit the pull brought in. It lags behind T, since the push didn't write to the monorepo:

$ git splice status vendor/pkg-a
On branch main

up to date vendor/pkg-a
$ git config --file vendor/pkg-a/.splice splice.commit && git rev-parse splices/pkg-a/main
703b9360f7ac335a1134e9b5771a90a7c09ba39a
6dc0e40cd5eda556b7b18759d2e2bb1ae2ae214a