Skip to content
academia.sh

Lesson 10 / 12

Multiple Working Trees

An extra working tree shares the object store and copies 0 objects: in a thousand-commit history, the clone scheme writes 22560 objects for four branches while the tree scheme stays at 5640; 11466 of the 16920 gained objects are binary.

Contents

The previous lesson dropped clone cost to a third but did not eliminate the cost: the table still said clone, and the clone was paid again every time it was done. This carries an assumption with it too — the assumption that working on two branches of the same repository together requires a second copy.

The assumption is not true. A repository has two components, and they can be duplicated separately: an object store and a working tree. A clone copies both at once. This lesson’s question is: if the object store is left single and the working tree is duplicated, how many objects go uncopied compared to a clone per branch?

One Object Store, Multiple Trees

A worktree is a repository commit written to the file system. Multiple working trees is the scheme that lets more than one working tree be set up bound to the same object store. An extra tree is opened as a directory; inside it, a small file pointing not to a repository directory but to the main repository is placed, and from that moment the tree reads its objects from the main repository.

# taught commands — example dump, not executed

git worktree add ../tree-report report
git worktree add --detach ../tree-review <commit-id>
git worktree list
git worktree remove ../tree-report
git worktree prune

add opens the new directory and checks out the given branch there. --detach checks out not a branch but a commit directly; the tree stands in a detached-HEAD state, and this is the state wanted for a temporary inspection. list reports every tree and which branch each one is on. remove removes a tree, and prune cleans the administrative record of trees whose directory was deleted by hand.

One constraint has to be known from the start: a branch can be checked out in only one tree at a time. Trying to check the same branch out in a second tree is refused. The constraint is not arbitrary — if two trees wrote commits to the same branch, which end the branch showed would depend on the tree, and it would lose reference uniqueness.

What Gets Shared

The distinction is built in one sentence: what belongs to history is shared, what belongs to work is specific to the tree.

The shared side is the object store and the references. A commit written in one tree drops into the store as an object and becomes reachable from the other trees from that moment — no transfer is required. Branches, tags, and the reflog are shared too.

The tree-specific side is the working state: the HEAD reference, the staging area, and the files in the working area. Every tree keeps its own HEAD, so it can stand on a separate branch; every tree keeps its own staging area, so a change staged in one does not show up in another. An uncommitted change stays inside the tree and is not shared.

Configuration stands between the two. The repository’s configuration file is shared, and a change made in one tree affects them all; making a setting specific to just one tree is an option that has to be separately turned on. Repository attributes are shared too — that file, the subject of the next lesson, is a tracked file and belongs to history, so it applies the same rule across all trees.

This distinction directly gives the measurement’s setup: an extra tree copies no object, it only writes an administrative record and checks files out to the working area. A clone, by contrast, copies the entire object store.

The Measurement’s Assumptions

  • LR14 — The measurement uses the shared setup’s four scales. The same work, on the same number of branches, is done under two separate schemes; the produced commit and object set is the same in both.
  • LR15 — In the clone scheme, a separate clone is opened per branch and each clone copies the entire object store; partial clone and shallow clone are not used in this measurement.
  • LR16 — In the tree scheme, the object store is single; an extra tree copies 0 objects. The only thing it writes is 3 administrative records per tree, and these records are not objects, they are counted separately.
  • LR17 — The files checked out to the working area are the same in both schemes; four branches materialize four file sets in both schemes. This is why it is outside the measurement.
  • LR18 — “Binary uncopied” is the share of the gained objects coming from binary bodies; this is the same count as the previous lesson’s object-type distinction.
  • LR19 — The unit of the first and second parts is touched object. The unit of the third part is step, and a step is a single fetch that moves one branch’s tip from one copy to another.

Measurement

"""Multiple working trees: what object sharing gains over a clone per branch.

Part 1 - four trees, four scales: clone-scheme and tree-scheme objects.
Part 2 - gain as tree count grows (1000-commit history).
Part 3 - access from one tree to a commit written in another: unit step.
"""
SEED = 20260814
FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md")
SCALES = (50, 200, 1000, 4000)
TREES = (1, 2, 4, 8)
ADMIN_RECORD = 3   # admin record written per extra tree; copies no object


def rng(seed):
    d = seed % 2147483646 + 1

    def draw(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return draw


def history(n, seed=SEED):
    """n-commit linear history; each commit touches one file."""
    draw, commits = rng(seed), []
    for i in range(n):
        file = FILES[draw(5)]
        binary = draw(11) == 0
        commits.append({"no": i + 1, "file": file, "binary": binary,
                         "objects": 2 + (40 if binary else 0)})
    return commits


def clone_cost(t, include_binary=True):
    return sum(x["objects"] for x in t if include_binary or not x["binary"])


def clone_scheme(t, trees):
    """One clone per branch: each clone copies its own object store."""
    return trees * clone_cost(t)


def tree_scheme(t, trees):
    """Multiple working trees: one object store, extra trees copy no object."""
    return clone_cost(t)


def access_step(trees, scheme):
    """Access from a commit written in one tree to the other trees: fetch step."""
    return 0 if scheme == "tree" else trees - 1


print("commits  clone objects  4-clone objects  4-tree objects  gained objects  binary uncopied")
for n in SCALES:
    t = history(n)
    k, a = clone_scheme(t, 4), tree_scheme(t, 4)
    binary = 3 * (clone_cost(t) - clone_cost(t, False))
    print(f"{n:6d} {clone_cost(t):13d} {k:15d} {a:14d} {k - a:15d} {binary:16d}")
print()
t = history(1000)
print("trees  clone-scheme objects  tree-scheme objects  gained objects  objects per extra tree  admin records")
for trees in TREES:
    k, a = clone_scheme(t, trees), tree_scheme(t, trees)
    extra = (k - clone_cost(t)) // (trees - 1) if trees > 1 else 0
    print(f"{trees:4d} {k:20d} {a:20d} {k - a:15d} {extra:22d} "
          f"{(trees - 1) * ADMIN_RECORD:13d}")
print()
print("trees  fetch steps in clone scheme  fetch steps in tree scheme")
for trees in TREES:
    print(f"{trees:4d} {access_step(trees, 'clone'):27d} "
          f"{access_step(trees, 'tree'):27d}")
print()
print(f"single object store {clone_cost(t)} objects; four trees share the same store, "
      f"objects copied by the extra trees {tree_scheme(t, 4) - clone_cost(t)}")
print(f"if the same work were done with four clones, objects copied "
      f"{clone_scheme(t, 4) - clone_cost(t)}")
commits  clone objects  4-clone objects  4-tree objects  gained objects  binary uncopied
    50           380            1520            380            1140              882
   200           920            3680            920            2760             1638
  1000          5640           22560           5640           16920            11466
  4000         22600           90400          22600           67800            45990

trees  clone-scheme objects  tree-scheme objects  gained objects  objects per extra tree  admin records
   1                 5640                 5640               0                      0             0
   2                11280                 5640            5640                   5640             3
   4                22560                 5640           16920                   5640             9
   8                45120                 5640           39480                   5640            21

trees  fetch steps in clone scheme  fetch steps in tree scheme
   1                           0                           0
   2                           1                           0
   4                           3                           0
   8                           7                           0

single object store 5640 objects; four trees share the same store, objects copied by the extra trees 0
if the same work were done with four clones, objects copied 16920

What Sharing Gains

The first table’s unit is touched object. In a thousand-commit history, working on four branches writes 22560 objects under the clone scheme; under the tree scheme, the total stays at 5640. Gained objects: 16920 — the equivalent of three full clones. At four thousand commits, the same computation gives 67800 objects.

Where the gain comes from is written in the last column. Of the 16920 objects gained at a thousand commits, 11466 come from binary bodies; the previous lesson’s 0.6777 share drops in here exactly the same way. The more binary assets a repository has, the more opening a second clone costs — and that entire cost is never paid with a shared object store.

The pattern is the same at small scales too, but the magnitudes can mislead. At fifty commits, the gain is 1140 objects; next to four thousand commits’ 67800, this looks small, but the ratio between the two schemes is identical in every row. The gain is directly the product (trees − 1) × clone; scale only grows the multiplier, it does not change the relationship. The smallest measurable difference at this scale is 1/50, and no number in the table rests on a claim below that resolution.

The second table gives the same thing by tree count, and the middle column stays fixed: two trees, four trees, eight trees — all 5640. The object copied per extra tree is 0 in every row; the 5640 on the right is what the clone scheme pays per extra copy. The only thing paid under the tree scheme is the administrative record at the far right: 21 records for eight trees. These are not objects and do not enter history.

The metric that follows from this is one sentence: working-tree count does not grow the object store. Under the clone scheme, cost grows linearly with tree count; under the tree scheme, it is fixed. This is the one pattern among the costs measured in this course that is entirely independent of scale: bisect grew slowly with scale, the rewrite ratio was fixed but its total grew; here, an extra copy’s object cost is zero at every scale and every tree count.

Visibility and the Fetch Step

The third table’s unit is step, and what it measures is not objects but access.

Under the clone scheme, a commit written to a branch stands in that clone’s object store. For the other copies to access it, a fetch is required; in a four-copy scheme, a commit reaching all of them is 3 steps, in an eight-copy one, 7. Under the tree scheme, this number is 0 in every row — the commit is already in the shared object store, and the other tree sees it the moment it is written.

This is the scheme’s second gain, independent of object count, and it matters more in practice. A fix written in one tree can be tested immediately in another; cherry-pick, comparison, and merge operations happen between trees without going over the network. Under the clone scheme, the same work requires a round trip to a remote repository.

The limit comes from the same place. A shared object store means a shared failure surface: a garbage collection done in one tree concerns every tree, and a repository corrupted in one tree affects all of them at once. Under the clone scheme, copies are isolated from each other, and this isolation is the one thing bought in exchange for the 16920 objects paid.

A Third Scheme: Switching in a Single Tree

The measurement compared two schemes, but the third one is the most common in practice: a single working tree and switching between branches. This scheme copies 0 extra objects — same as the tree scheme — and requires 0 fetch steps. Looked at by the object unit, it is the cheapest scheme.

Its cost is paid somewhere else. Every switch rewrites the files in the working area; if there is an uncommitted change, the switch is either blocked or the change is carried along. The Branching and Collaboration course counted this case in detail: which change gets carried, which is blocked, and which is left behind was measured there and is not repeated here. The stash is the standard way to get past this obstacle — it sets an uncommitted change aside and reopens it after the switch.

What multiple working trees solves is exactly this: there is no switch. Two branches are open at the same time, in two separate directories; a test can run in one while an edit is made in the other. The stash is not needed, because an uncommitted change does not need to give up its place.

In exchange, every tree materializes its own working state, and this is not limited to files. Dependency directories, build outputs, and caches are recreated per tree; these are outside the measurement because they do not belong to history, but they really do take up room on disk, and in a four-tree scheme they are produced four times. The 16920 gained in the object unit does not cover this cost. The choice between the two schemes therefore does not come down to a single number: the measured object gain is real and large, the unmeasured working state grows with tree count.

Removing a Tree and Recovery

When a working tree is removed, objects are not removed — the objects did not belong to the tree. The only thing that can be lost is what was uncommitted in that tree: the changes in the working area and the staging area’s contents. The remove command refuses if there is an uncommitted change in the tree; deleting the directory by hand asks nothing. The difference between these two paths is whether there is a warning.

The second loss path is quieter. A commit written in a tree in a detached-HEAD state is not bound to any branch; once the tree is removed, that commit becomes unreachable and drops once the garbage-collection window closes. The recovery path: the reflog is shared, so the commit’s tip can be read from another tree and bound to a branch before the window closes. The cheaper path is opening that tree not with --detach but with a branch name — a branch is a reference, and a reference is independent of the tree’s lifetime.

For a tree standing on a removable medium or a temporarily mounted directory, there is a separate safeguard: the tree is locked. A locked tree’s administrative record is not cleaned up even when the directory is unreachable; otherwise the cleanup operation would ignore the tree, and the branch in that tree would become checkable out somewhere else. The lock does not prevent loss, it prevents a misdiagnosis.

The third is the administrative record itself: the record of a tree whose directory was deleted by hand stays in the repository and keeps preventing that branch from being checked out in another tree. prune cleans these records and touches no object.

Summary

  • A repository has two components: an object store and a working tree. A clone copies both; multiple working trees duplicates only the second.
  • The object store, branches, tags, and reflog are shared; HEAD, the staging area, and the working area are tree-specific. A branch can be checked out in only one tree at a time.
  • In a thousand-commit history, the clone scheme writes 22560 objects for four branches, the tree scheme stays at 5640; 11466 of the 16920 gained objects come from binary bodies.
  • The object copied per extra tree is 0 at every scale; the only thing paid under the tree scheme is 3 administrative records per tree, and the object store does not grow with tree count.
  • In the step unit: a commit reaching every copy requires 3 and 7 fetches under the clone scheme, 0 under the tree scheme; what is lost in exchange is the isolation between copies.

Next Step

The measurement left one assumption out of scope: the assumption that the files checked out to the working area are the same under both schemes. This said that how files are written is not a choice — but there is a transformation layer applied to a file during checkout, and the blob in the object store and the file in the working area do not have to be identical to each other. The next lesson measures that layer: when line-ending conversion is not configured, which commit, in which file, writes a new object while actually changing nothing at all?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close