Skip to content
academia.sh

Lesson 04 / 12

Reflog

Rewriting does not shorten history, it doubles it: in a thousand-commit repository, a transformation from the first commit leaves 1000 commits reachable and 1000 unreachable; in a six-entry log, when the retention horizon is 7, 30, 60, and 90 days, the recoverable commits are 3, 50, 500, and 1000.

Contents

Two of the previous lesson’s recovery paths looked at the same place: the local record holding the branch tip’s old value. Bringing back an overwritten branch and recovering stranded local work both started from that record. The lesson ended with a warning there — that record has a lifespan.

This lesson measures that lifespan. Two questions are asked, and they are separate. First: after a rewrite, what stays inside the repository, how many commits are reachable and how many are not. Second: how long do the unreachable ones stay recoverable, at what moment does the window close, and what is lost when it does.

Being Unreachable Is Not Ceasing to Exist

This course’s first lesson said that rewriting is not a deletion but a duplication. The measurement turns that sentence into a number. When a thousand-commit history is rewritten, the new chain is also a thousand commits; the old chain’s commits go nowhere. The repository now holds two chains: one reachable from the branch tip, the other pointed to by no reference at all.

For an object to be reachable means it can be found by starting from a branch, a tag, or HEAD and following parent links. An unreachable object cannot be found by this walk; but it stays on disk, is read if its identity is given, and stays there until garbage collection runs. When rewriting finishes, the repository does not shrink, it grows.

The Log Holds Movements, Not Commits

The way to recover the unreachable chain is to know the old tip’s identity. The tool records this on its own: every move of every reference is written to a local log. This is the reflog.

The unit of the record is this lesson’s most important distinction. The log holds not commits but movements. A rewrite that leaves five hundred commits unreachable does not write five hundred lines to the log; it writes one line. That line holds the tip at that moment, and walking backward from the tip reaches all five hundred commits. The number of entries and the number of recoverable commits are therefore not the same thing.

The log has three limits. It is local: it does not travel to the other side, is not cloned, and one copy’s log does not recover another copy. It is per reference: every branch has its own log, and HEAD additionally keeps a general one. It is assumed to be indefinite but is not: entries are cleaned up according to a retention horizon, and that horizon is a setting.

# taught command and example dump — not executed

$ git reflog metrics
5b6c7d8 metrics@{0}: rebase (finish): refs/heads/metrics onto 9c4d7e1
2f8a1c3 metrics@{1}: commit: metrics: print threshold value
7e1b4d2 metrics@{2}: commit: metrics: split config file

$ git branch recovery/metrics metrics@{1}

The second line is the entire recovery: a branch name is given to the old tip, and that chain becomes reachable again. The operation deletes nothing and moves no reference; this is why it can be written here in full form. Recovery paths are not destructive — what is destructive is the operation that made them necessary.

There is a second tool for a case the log cannot show: the subcommand that checks the repository’s integrity lists objects pointed to by no reference as dangling. If an entry has been cleaned but garbage collection has not run yet, recovery is done from there. That, too, is a read operation.

The third limit’s most common practical cost is this: when a branch is deleted, its log is deleted with it. Looking at a mistakenly deleted branch’s own record is no longer possible at that point. In this case the remedy is HEAD‘s log — the tips the working area stood on are additionally kept there, and the deleted branch’s last tip usually appears in that list. Keeping the two logs separate is not a detail, it can be the only recovery path left.

When objects are actually cleaned up also has to be written down. Garbage collection runs on its own; it is triggered during maintenance operations and can also be called manually. But for an unreachable object to actually be cleaned up, two conditions have to be met at once: the log entry pointing to it has to have fallen outside the horizon and collection has to have run. Until both conditions are met, the object stays in place. The horizon itself is not a single number either: the entries for reachable tips and the entries for unreachable tips can be set separately, and the latter is usually kept shorter.

The measurement’s assumptions:

  • RH25 — The setup is the previous lessons’ setup and is unchanged; the measurement is done on a 1000-commit history.
  • RH26 — Rewriting does not shorten history: the new chain is the same length, and the old chain’s commits stay in the repository, unreachable.
  • RH27 — The reflog records the branch tip’s movements, not commits. An entry holds the tip at that moment; it represents the whole chain reachable from that tip.
  • RH28 — The old tips the entries point to are nested suffixes; the recoverable set is determined by the entry that goes furthest back, not by the sum of the entries.
  • RH29 — The retention horizon is a setting, not a fixed number. The measurement tries four values: 7, 30, 60, 90 days.
  • RH30 — An entry outside the horizon is deleted, and only the commits reachable from that entry become collectible. The measurement does not count when garbage collection runs, it counts which set is collectible.
  • RH31 — Two separate things are counted in this lesson and are not mixed: reachability is counted in commits, cost is counted in touched objects. The step unit is not used.
  • RH32 — The resolution is 1/1000 in commits, 1/6 in log entries.

Measurement

"""Reflog: reachability and the recovery window.

Part 1 - what stays in the repository after a rewrite.
Part 2 - log entries: entry count and recoverable commits are separate things.
Part 3 - when the retention horizon closes the window.
"""
SEED = 20260814
FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md")
POSITIONS = (1, 300, 501, 800, 951, 998)
HORIZONS = (7, 30, 60, 90)


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

    def draw(n):
        nonlocal state
        state = (state * 48271) % 2147483647
        return state % 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 rewrite(t, position):
    after = [x for x in t if x["no"] >= position]
    return len(after), sum(x["objects"] for x in after)


def log(seed=SEED):
    """Every move of the branch tip writes an entry; an entry holds the day and the tip."""
    draw, entries, day = rng(seed), [], 0
    for i, position in enumerate(POSITIONS):
        day += 1 + draw(30)
        entries.append({"no": i + 1, "day": day, "position": position})
    return entries


def recoverable(t, entries):
    """The union of the tips the entries point to: the one going furthest back decides."""
    if not entries:
        return 0, 0
    return rewrite(t, min(e["position"] for e in entries))


T = history(1000)
G = log()
TODAY = G[-1]["day"]

print(f"{'rewritten at':>20s} {'reachable':>13s} {'unreachable':>11s}"
      f" {'total in repo':>16s} {'unreachable objects':>17s}")
for position in (998, 501, 300, 1):
    changed, touched = rewrite(T, position)
    print(f"{position:20d} {len(T):13d} {changed:11d} {len(T) + changed:16d} {touched:17d}")

print()
print(f"{'entry':>6s} {'day':>5s} {'old tip position':>17s}"
      f" {'reachable from entry':>20s}")
for e in G:
    changed, _ = rewrite(T, e["position"])
    print(f"{e['no']:6d} {e['day']:5d} {e['position']:17d} {changed:20d}")
print(f"entry count {len(G)}, today day {TODAY}")

print()
print(f"{'retention horizon':>18s} {'entries left':>13s} {'recoverable commits':>21s}"
      f" {'recoverable objects':>20s} {'permanent loss':>14s}")
total_changed, _ = recoverable(T, G)
for horizon in HORIZONS:
    remaining = [e for e in G if TODAY - e["day"] <= horizon]
    changed, touched = recoverable(T, remaining)
    print(f"{horizon:18d} {len(remaining):13d} {changed:21d} {touched:20d}"
          f" {total_changed - changed:14d}")
        rewritten at     reachable unreachable    total in repo unreachable objects
                 998          1000           3             1003                 6
                 501          1000         500             1500              3000
                 300          1000         701             1701              4162
                   1          1000        1000             2000              5640

 entry   day  old tip position reachable from entry
     1    11                 1                 1000
     2    20               300                  701
     3    40               501                  500
     4    49               800                  201
     5    58               951                   50
     6    81               998                    3
entry count 6, today day 81

 retention horizon  entries left   recoverable commits  recoverable objects permanent loss
                 7             1                     3                    6            997
                30             2                    50                  260            950
                60             4                   500                 3000            500
                90             6                  1000                 5640              0

The Repository Does Not Shrink

The top table’s second column is the same across all four rows: 1000. No matter where the rewrite starts, the history reachable from the branch tip is a thousand commits. The third column varies: 3, 500, 701, and 1000 stay unreachable. The fourth column is the sum of the two and carries the lesson’s headline — after a transformation from the first commit, 2000 commits stand in the repository, half reachable, half not.

This explains why the previous lesson’s bulk transformation did not bring an immediate gain. Removing a binary asset from history does not lower the repository’s size; it only moves that asset’s objects into the unreachable set. The unreachable-objects column is 5640 for a transformation from the first commit, and that number is only freed once garbage collection runs. There is a delay between the gain and the operation, and that delay is at the same time the recovery window. The two are the same thing: the duration objects are kept is the duration a mistake can be undone.

Entry Count vs. Recoverable Commits

The middle table shows the log’s six entries. One entry recovers three commits, another recovers a thousand. A log of 6 entries can recover 1000 commits; the ratio is one hundred sixty-seven commits per entry. This is why the log is a cheap mechanism: it keeps tips, not chains.

There is a second piece of information in reading the entries. Every line also records the movement’s type: it reports separately whether a commit was written, a rebase finished, or a branch tip was reset. This is the field to look at when searching for the tip to recover — what is being sought is usually the tip one entry before the one reporting the rewrite. The log holding identity and type together turns finding the right entry from a guessing game into something else.

The same structure creates a trap too. Because the sets the entries cover are nested suffixes, the recoverable set is determined not by the number of entries but by which one goes furthest back among them. Even if five entries remain, if the deepest one is gone, the recoverable set drops to that deepest one among the remaining five. Seeing many lines in the log says nothing about the scope of recovery.

As the Window Closes

The bottom table shows how the window closes. When the retention horizon is 90 days, all six entries stand, all 1000 commits are recoverable, and the permanent loss is 0. When the horizon drops to 60 days, four entries remain and recoverable commits fall to 500: half of history can no longer be found from the log. At 30 days, two entries and 50 commits remain. At 7 days, a single entry is left, recoverable commits are 3, permanent loss is 997.

The numbers’ uneven jump is not the horizon’s result, it is the result of the entries’ distribution: the moment the deep entry from day eleven falls outside horizon sixty, five hundred commits become unreachable at once. The window does not close smoothly, it closes in steps. This is why the sentence “there is still a week left on the log” is not a guarantee — without knowing which entry drops on which day, how much stays recoverable cannot be known. The steps’ locations are random too: the entries’ days come from the work schedule, while the depth they cover comes from what work was done that day. There is no link between the two, which is why the deepest entry being the oldest entry is common but not guaranteed.

The horizon itself is a setting and varies from repository to repository. What the measurement says is not to defend a particular number but that the horizon directly determines the scope of recovery. A longer horizon means more recovery, more objects kept; a shorter one means the opposite. It is a two-way trade-off, and both directions show in this table: the recoverable-objects column climbing from 6 to 5640 is matched by an equal drop in the objects that could be freed.

Loss Paths and Recovery Paths

Every loss path counted in this topic has a recovery path, and all four can be written together.

When the old form of a rewritten chain is lost, recovery is in the log: a branch name is given to the old tip. If the window is closed, the dangling-object list is the second path.

If an asset removed in a bulk transformation was removed by mistake, recovery is again in the log — but only in the repository that did the transformation. The separate-clone habit pays off here: if the actual repository stays untouched, the question of recovery never comes up.

For an overwritten remote branch, recovery is not in the local log but in the copies. The overwritten side’s backup is the side that pays the cost, and that backup has no retention horizon; recovery stays open as long as a copy still points at the old tip.

For an entry that has fallen outside the horizon, there is no recovery path. The only remedy is prevention, and prevention is the backup branch opened before rewriting: a backup branch is a reference, references are not subject to the horizon, and it keeps reachability standing until deleted.

The four paths share a common feature, and it is the lesson’s conclusion: recovery always happens on the side that holds the objects. There is no central recovery location. Your own log recovers only your own repository; your log does not show a copy’s lost work, and your log does not bring back a branch you overwrote. This is the mirror image of the previous lesson’s cost distribution — the cost was distributed to the copies, and so is the ability to recover.

From here a one-line habit follows: before rewriting, where the tip is gets recorded. A backup branch is the cheapest way to do this, and its cost is as much as the previous course measured — a single reference. Not needing the window at all, rather than asking how long it will stay open, is the shortest of all the paths counted here.

Summary

  • Rewriting does not shorten history. At all four positions, reachable commits stay at 1000; unreachable commits become 3 / 500 / 701 / 1000, and the total in the repository climbs as high as 2000.
  • Unreachable objects stay on disk until garbage collection runs; a transformation from the first commit leaves 5640 objects waiting to be freed. The delay in the gain and the recovery window are the same duration.
  • The reflog records branch tip movements, not commits: 6 entries can recover 1000 commits. Entry count does not indicate the scope of recovery.
  • The recoverable set is determined by the entry that goes furthest back; at retention horizons of 7 / 30 / 60 / 90 days, recoverable commits are 3 / 50 / 500 / 1000 and permanent loss is 997 / 950 / 500 / 0.
  • The log is local, per reference, and subject to the horizon; a backup branch is outside all three and is the only permanent safeguard.
  • Recovery operations are read operations: giving a branch name to an old tip deletes nothing.

Next Step

Up to this point, history was changed, and every change’s cost was counted in touched objects: 3002 in interactive mode, 5640 in bulk transformation, 39480 through the copies in force push, between 6 and 5640 in recovery depending on the window’s width. The cost of changing is now known. What is not known is the opposite’s cost: how much it costs to ask a question of the same history. The next topic measures that — in a thousand-commit history, how many steps does finding which commit introduced a defect take, and how many does that step count climb to when history quadruples in size. The unit of cost changes there too: not the object, but the step.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close