Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

The Ref Never Outruns the Worktree

~13 min · version-control, concurrency, convergence, failure-modes

Level 0Wet Clay
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Three Things Called "the Branch"

A checkout holds three separate states. The ref is where the branch points. The index is what is staged. The worktree is the files on disk. Everyday commands move all three together, which is why most people never see them apart. Plumbing moves them one at a time, on purpose — that is exactly what made the scratch-index landing three lessons back possible. The freedom that isolated the landing from its neighbors is the same freedom this lesson is about, because it is also how the landing broke the checkout those neighbors were working in.

The Staged Reversion Nobody Wrote

Follow the old behind case. The remote had commits this checkout did not, so the landing built its commit on the remote tip and moved the ref there with a compare-and-swap. Every step was correct. Now look at the checkout afterward: the ref holds another machine's commits, and the shared index and worktree still hold the files from before them. To every session working in that checkout, the other machine's work appears as a staged change reverting it — and the next ordinary commit by anyone records that reversion under their own name.

It was reproduced exactly: a file the other machine had changed from 1 to 2 read 2 at the ref, 1 in the worktree, and sat in the index staged back to 1. The publish verb, which took the same base, did something worse. It read the quest's record from the stale worktree and committed it on the remote tip, so the other machine's stamp vanished: the record's history came out holding only the new entry instead of both.

No operation failed. No error was printed. The state was incoherent, the way the first lesson of this track described three machines each being locally right — except this time the workshop manufactured the incoherence itself.

Move All Three, or Move Nothing

The rule now is short: a ref never moves past what its worktree holds. When the checkout is behind, the landing fast-forwards the checkout itself before anything is written — a fast-forward-only merge moves the ref, the index and the files together, carries unrelated edits and staged hunks through untouched, and refuses, moving nothing, when it would overwrite a local edit. A refusal stops the landing with nothing written, so running it again later is safe; the edit it protected belongs to another session, and what happens to it is a person's call. After the fast-forward, the base is the checkout's own tip; there is nothing left to choose.

The lost push race follows the same rule. The old recovery rebuilt the commit on the new remote tip and moved the ref alone — the same bug, one step later. Now the branch steps back to the landing's parent (the quest edit is still staged and still on disk), fast-forwards with its worktree to the new tip, and commits again there. If the checkout cannot follow — the fast-forward would overwrite someone's edit, or the checkout holds commits the remote lacks — the branch returns to the landing, and the landing is recorded as committed and not pushed. A recovery that exits before writing its record is worse than the gap it was guarding.

The Engine's Tree Is Not the Author's

The same season showed two more faces of one mistake: assuming that the checkout a process can see is the one that matters.

The brief that missed the remote. The engine commits every brief it writes into its own checkout, on one machine. A landing pushed from another machine seconds earlier made the engine's push lose. The log row said "not pushed", the queue command printed success, and that checkout had now diverged, so every later brief push would fail the same way. A rejected push is now replayed onto the remote tip in a scratch index and pushed — without moving the engine checkout's own branch, because moving that ref without its worktree would stage the same reversion there. Anything still off the remote prints a warning naming the machine and the command that fixes it, on every verb that writes a brief.

The review that could not see the tracks. A review request sharded by track used to be scoped by listing the engine's checkout. A quest authored on another machine exists only in that author's checkout until it lands, so the engine saw no tracks and refused — and for an evolution, the new lessons would have been silently missing from the review instead. Now the requesting machine sends the quest's file names, names only, nothing opened. A listing with no tracks is refused rather than filled in from the engine's older copy. The engine still renders, writes and commits the request, so the single writer stays single. What it stopped doing is assuming its tree is the author's.

A move is not finished until every state that describes the same thing has moved. When only one of them can move, record that the others did not — which state, on which machine, and the command that brings it along. A ref that has run ahead of its worktree is a claim the checkout cannot back, and every session that trusts it inherits the lie.

Code

What every other session saw after the old behind-case landing·text
the other machine changed shared.txt from 1 to 2 and pushed.
this checkout was behind; the landing built on the remote tip
and moved the ref alone (update-ref).

            ref (HEAD)    index      worktree
shared.txt      2           1           1

$ git status
Changes to be committed:
        modified:   shared.txt        <- staged: 2 -> 1

nobody typed this. the next ordinary `git commit` in this checkout
records it, and the other machine's change is reverted under
somebody else's name.
Bring the checkout along, or stop before writing·python
def converged_base(repo, branch, own_paths):
    """Choose the base AND bring the checkout to it - never one without
    the other. Every exit below happens before anything is written."""
    run(["git", "fetch", "origin"], cwd=repo)
    local = rev(repo, branch)
    remote = rev(repo, f"origin/{branch}", missing_ok=True)
    if remote is None or local == remote:
        return local                                    # level
    if is_ancestor(repo, local, remote):                # BEHIND
        if changed(repo, local, remote, own_paths):
            stop("the remote moved THIS unit's paths - reconcile by hand")
        ff = run(["git", "merge", "--ff-only", "-q", remote],
                 cwd=repo, check=False)  # ref + index + worktree, together
        if ff.returncode != 0:
            stop("fast-forward would overwrite a local edit - another "
                 "session's work. nothing written; re-run once it converges")
        return remote                   # now the checkout's own tip
    if is_ancestor(repo, remote, local):
        return local                                    # AHEAD
    stop("diverged - reconcile by hand")                # DIVERGED


def recover_lost_push(repo, branch, landing, new_remote, paths, msg):
    parent = rev(repo, f"{landing}^")
    update_ref(repo, branch, new=parent, old=landing)   # step back; the
                                                        # edit stays staged
    if not fast_forward(repo, new_remote):              # cannot follow?
        update_ref(repo, branch, new=landing, old=parent)
        return landing, False     # committed, NOT pushed - and RECORDED
    rebuilt = scoped_commit(repo, new_remote, paths, msg)
    update_ref(repo, branch, new=rebuilt, old=new_remote)
    return rebuilt, push(repo, branch)


# The line this replaced, in the BEHIND branch:
#     return remote   # build on the remote tip; move the ref alone
# It chose the right base and left the checkout behind it.

External links

Exercise

In a scratch directory, make a bare repository and two clones. In clone B, change a file and push. In clone A, without pulling, build a commit on origin's tip with commit-tree from a temporary index and move the branch with update-ref, exactly as the old landing did. Run git status in clone A and read what it says is staged. Then reset the experiment and do it again with git merge --ff-only first. Write one sentence describing what the second run did that the first did not.
Hint
Point GIT_INDEX_FILE at the temporary index only for the commands that build the commit, not for the shell. If the variable outlives those commands, your git status will read the scratch index instead of the real one and show you a third, even more confusing state.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.