Skip to content
academia.sh

Lesson 04 / 15

Detached HEAD State

When two commits made on a detached HEAD are reachable from no reference, history drops from 15 records to 13 and answers 4 of the six questions instead of 5; the real cost is that all three counting queries are answered incompletely, and history does not report it.

Contents

The previous three lessons built a three-link chain between HEAD and a commit: HEAD points to a branch name, the branch name points to a commit ID, the commit points to its own parent. Working on a branch depends on this chain being complete — when a commit is made, the middle link advances, HEAD stays in place, and the new commit ends up bound to a name.

The middle link is not always in place. The tool recognizes a state where HEAD points directly to a commit ID, and this is called detached HEAD. This lesson measures how that state arises, where a commit made there attaches, and what history loses when a link of the chain is missing. The measurement’s real result is not the amount lost; it is that history does not know it lost anything.

How HEAD Detaches

The detached state is not a defect or corruption; it is the tool’s way of satisfying a specific request. The request is: stand not at a branch’s tip, but at a particular point in history. When you switch to a commit ID, a tag, or a remote branch tip, HEAD cannot bind to a name, because the target is not a name.

# taught command and example dump — not executed

$ git switch --detach 7c3f9d2
HEAD is now at 7c3f9d2 Format report heading
$ git status --short --branch
## HEAD (no branch)
$ git symbolic-ref HEAD
fatal: ref HEAD is not a symbolic ref

The last line is the definition of the state. In the first lesson, the same command answered refs/heads/main: HEAD was pointing to a reference. Here there is no reference to point to, so the command cannot find a symbolic reference. git status says the same thing in different words.

The state can be chosen deliberately. Moving back and forth through history while searching for which commit a bug entered on, building and testing an old version, inspecting the tree a tag points to — all of these are done in the detached state, and none of them is a problem. The problem starts with committing in the detached state.

A Commit Made in the Detached State

Committing in the detached state is not blocked. The commit object is written, its parent is correctly linked, its tree is recorded; in terms of content, it is no different from any other commit. The only thing that changes is who advances: HEAD points directly to the new ID, and no branch reference is written.

The consequence is not yet visible at the second commit, because the HEAD chain still holds those commits. The consequence is born the moment you switch to a different branch. HEAD now points to a different branch, and the commits written in the detached state are reachable from no reference. The tool reports this at the moment of switching.

# taught command and example dump — not executed

$ git switch report
Warning: you are leaving 2 commits behind, not connected to
any of your branches:

  b71c05e report: move threshold table into config
  a04e9d2 report: add summary line

If you want to keep them by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> b71c05e

Switched to branch 'report'

The warning gives both the count and the ID, and it also states the fix: write a branch. The first lesson’s measurement gave the cost of this suggestion — a branch reference is 41 bytes. Binding two commits to history is writing forty-one bytes, and this is why the way out is cheap.

There are three ways out of the detached state. git switch -c name writes a branch at the point you are standing on and switches to it; this is the way that rescues the work in place. git switch <branch> returns to a branch and leaves the detached commits behind. The third is retroactive: a reference can also be written afterward with git branch name <id> — as long as the ID is still known.

The Reflog

The way to know the ID is the reflog. The tool writes every value HEAD and every branch reference ever took to a local log: which commit was switched to, which commit was made, which reference moved where. The log is local and does not travel with the repository — a cloned repository does not get the source repository’s reflog.

# taught command and example dump — not executed

$ git reflog
b71c05e HEAD@{0}: commit: report: move threshold table into config
a04e9d2 HEAD@{1}: commit: report: add summary line
7c3f9d2 HEAD@{2}: checkout: moving from report to 7c3f9d2

$ git branch rescue b71c05e
$ git branch --contains b71c05e
  rescue

The HEAD@{0} syntax names HEAD’s previous positions and can be used anywhere a commit ID is expected. The third line records the detachment itself; the first two record the two commits made in the detached state. After the branch is written, the last command confirms the commit is now reachable from a reference.

The reflog itself is not a reference and does not enter the reachability computation. It is a recovery tool, not a storage mechanism: entries have a limited lifetime and expire after a configurable period; entries belonging to unreachable commits have a shorter lifetime than the others.

The Boundary That Cannot Be Recovered

Once a reflog entry expires, no record remains to give an unreachable commit’s ID. The object is still in the database, but it has no name and nowhere to be looked up.

One step past this is permanent. The tool has a maintenance command that collects objects reachable from no reference; this command’s pruning option deletes unreachable objects from the database. This lesson does not write that call in executable form, because its result is irreversible: a deleted commit’s content cannot be brought back from anywhere, and the reflog cannot restore a deleted object either. The maintenance command can also run on its own as part of certain operations; this is why leaving an unreachable commit for “I’ll recover it later” is not safe.

The safe habit is one sentence: write the reference first. If work is going to be done in the detached state, a branch is opened before starting; if it is noticed after the work is done, a backup branch is written while the reflog is still fresh. Recovery’s entire cost is forty-one bytes, and as long as that byte count is not written, the work is a single maintenance call away from gone.

The measurement’s assumptions:

  • BR22 — The setup is the shared definition’s twelve commits, and development is not changed. The lesson changes a single step of the setup: the report work’s second and third commits were made on a detached HEAD and are not bound to a branch tip.
  • BR23 — Reachability starts from references. If a commit cannot be reached from any branch reference, that commit is outside history; sitting in the object database does not change this.
  • BR24 — The measurement uses a single integration format: merge commit. Its definition is taken from the shared definition and not changed; this lesson does not compare formats.
  • BR25 — The definition of the six questions is not changed, and they are asked only of the reachable history. A question cannot know about a commit it was never given.
  • BR26 — Three counting queries are also asked and compared against the oracle: the number of commits belonging to the report work, the number of commits that introduced the bug, the number of commits touching the shared file. The oracle knows the setup and gives the true count.
  • BR27 — The reflog is not a reference and does not enter the reachability computation; it only serves to find an unreachable commit’s ID.
  • BR28 — Recovery is binding the dropped commits to a reference. The measurement builds the recovered history by putting the dropped ones back and ordering them by time; recovery changes neither the commits’ content nor their order.
  • BR29 — Recovery’s cost is a single branch reference: forty hex digits and a line ending, 41 bytes. The first lesson’s measure applies here exactly as it was.

Measurement

"""Detached HEAD: a commit reachable from no reference is outside history.

Part 1 - two commits made in the detached state drop out of history.
Part 2 - six questions asked of three histories: which question falls outside.
Part 3 - counting queries compared against the oracle: answered, but incompletely.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
DETACHED = ("report", (2, 3))     # these two commits were made on a detached HEAD
REF_BYTES = 40 + 1


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

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


def development():
    draw, record, time = rng(SEED), [], 0
    for step in range(1, 5):
        for branch in BRANCHES:
            time += 1 + draw(3)
            file = (SHARED_FILE if step == 3 and branch in ("metrics", "report")
                    else FILES[branch])
            record.append({"branch": branch, "step": step, "file": file,
                          "buggy": (branch, step) == BUGGY, "time": time})
    return record


def integrate(record, fmt):
    t = []
    if fmt == "merge commit":
        for branch in BRANCHES:
            for k in [x for x in record if x["branch"] == branch]:
                t.append({**k, "branch_record": branch, "time_kept": True})
            t.append({"branch": branch, "step": 0, "file": None, "buggy": False,
                      "time": max(x["time"] for x in record
                                   if x["branch"] == branch),
                      "branch_record": branch, "merge": True,
                      "time_kept": True})
    return t


def question1_grouping(t):
    return all(x.get("branch_record") for x in t if not x.get("merge"))


def question2_bug(t):
    buggy = [x for x in t if x["buggy"]]
    return len(buggy) == 1 and not buggy[0].get("squashed")


def question3_order(t):
    return all(x.get("time_kept") for x in t)


def question4_file(t):
    return all(isinstance(x["file"], str) or x.get("merge") for x in t)


def question5_integrity(t):
    spots = {}
    for i, x in enumerate(t):
        if x.get("merge"):
            continue
        spots.setdefault(x["branch"], []).append(i)
    return all(y[-1] - y[0] == len(y) - 1 for y in spots.values())


def question6_conflict(t):
    return any("chosen" in x for x in t)


QUESTIONS = (("feature grouping", question1_grouping), ("bug isolation", question2_bug),
             ("true order", question3_order), ("file trail", question4_file),
             ("branch integrity", question5_integrity), ("conflict decision", question6_conflict))


def reachable(record, dropped):
    branch, steps = dropped
    return [k for k in record if not (k["branch"] == branch and k["step"] in steps)]


def recover(remaining, dropped_list):
    """Commits found via the reflog are bound to a reference."""
    return sorted(remaining + dropped_list, key=lambda x: x["time"])


def query(group):
    """Three counting queries asked of history or the setup."""
    return {"commits belonging to report": sum(1 for x in group
                                               if x["branch"] == "report"
                                               and not x.get("merge")),
            "commits that introduced the bug": sum(1 for x in group if x["buggy"]),
            "commits touching config.py": sum(1 for x in group
                                              if x["file"] == SHARED_FILE)}


record = development()
remaining = reachable(record, DETACHED)
dropped = [k for k in record if k["branch"] == DETACHED[0] and k["step"] in DETACHED[1]]
HISTORIES = (("attached HEAD", record), ("detached HEAD", remaining),
             ("recovered", recover(remaining, dropped)))
T = {name: integrate(k, "merge commit") for name, k in HISTORIES}

print(f"setup: {len(record)} commits; {len(dropped)} commits made on a detached HEAD "
      f"are reachable from no reference")
print()
print(f"{'history':<16s}{'reachable commits':>18s}{'history entries':>17s}"
      f"{'unreachable':>13s}{'answered':>10s}")
for name, k in HISTORIES:
    print(f"  {name:<14s}{len(k):18d}{len(T[name]):17d}"
          f"{len(record) - len(k):13d}"
          f"{sum(q(T[name]) for _, q in QUESTIONS):10d}")
print()
HEADER = (("attached HEAD", 14), ("detached HEAD", 14), ("recovered", 12))
print(f"{'question':<20s}" + "".join(f"{a:>{w}s}" for a, w in HEADER))
for name, q in QUESTIONS:
    print(f"  {name:<18s}" + "".join(
        f"{('yes' if q(T[a]) else 'no'):>{w}s}" for a, w in HEADER))
print()
oracle = query(record)
detached_q, recovered_q = query(T["detached HEAD"]), query(T["recovered"])
print(f"{'counting query':<32s}{'oracle':>7s}{'detached HEAD':>14s}{'missing':>9s}"
      f"{'recovered':>11s}")
for s in oracle:
    print(f"  {s:<30s}{oracle[s]:7d}{detached_q[s]:14d}{oracle[s] - detached_q[s]:9d}"
          f"{recovered_q[s]:11d}")
print(f"\n{sum(1 for s in oracle if oracle[s] != detached_q[s])} of three queries are "
      f"answered incompletely; history does not report the shortfall")
print(f"recovery cost: to bind {len(dropped)} commits back, "
      f"1 reference, {REF_BYTES} bytes")
setup: 12 commits; 2 commits made on a detached HEAD are reachable from no reference

history          reachable commits  history entries  unreachable  answered
  attached HEAD                 12               15            0         5
  detached HEAD                 10               13            2         4
  recovered                     12               15            0         5

question             attached HEAD detached HEAD   recovered
  feature grouping             yes           yes         yes
  bug isolation                yes            no         yes
  true order                   yes           yes         yes
  file trail                   yes           yes         yes
  branch integrity             yes           yes         yes
  conflict decision             no            no          no

counting query                   oracle detached HEAD  missing  recovered
  commits belonging to report         4             2        2          4
  commits that introduced the bug      1             0        1          1
  commits touching config.py          2             1        1          2

3 of three queries are answered incompletely; history does not report the shortfall
recovery cost: to bind 2 commits back, 1 reference, 41 bytes

Reading the Numbers

The top table gives the size of the loss. Reachable commits fall from 12 to 10, entries in history from 15 to 13; unreachable is 2. Answered questions drop from 5 to 4. In a twelve-commit set, the loss is 2/12; in a six-question set, 1/6 — both are within the sets’ resolution and defensible.

The middle table says which question drops, and the answer is a single one: bug isolation. The commit that introduced the bug was the report work’s second, and it was one of the commits made in the detached state. History no longer sees a buggy commit; when the question “can the commit that introduced the bug be found on its own” is asked, the answer is no. This is the reported face of the loss: history says it cannot answer a question.

None of the other five rows changes, and this lesson’s real result comes from here. Feature grouping is yes in all three cases; all ten of the remaining commits carry a branch record, and history is internally consistent. File trail is also yes in all three; every remaining commit carries a file name. History is not aware that it is incomplete, because incompleteness can only be seen with an outside measure.

The bottom table supplies that outside measure. All 3 of the three counting queries are answered incompletely. The commit count belonging to the report work comes out to 2 instead of 4 — the group is readable, but half of it is missing. The commit that introduced the bug comes out to 0 instead of 1. Commits touching the shared file come out to 1 instead of 2 — the file trail question says yes, but the trail it gives is half. No row carries an error message; all three answers look reasonable, and all three are wrong.

This is the distinction between the two kinds of loss. When a question drops, history says no, and the reader knows it. When a commit drops, history keeps saying yes, and the reader is left with a wrong number. The 1/6 drop in the number of answered questions is only the visible part of the real loss.

The last two lines measure recovery. When the two dropped commits are bound to a reference, reachable commits return to 12, history to 15, answered questions to 5, and the three counting queries match the oracle exactly. Recovery’s cost is 41 bytes. The first lesson’s measure had also said the reverse: writing a reference was forty-one bytes, so not writing that reference was a forty-one-byte shortfall too. The whole story of the detached state is these unwritten forty-one bytes.

Summary

  • Detached HEAD is the state where HEAD points directly to a commit ID instead of a branch name; git symbolic-ref HEAD fails, and the state is not a defect, it is a way of positioning.
  • A commit made in the detached state is written normally, but no branch reference advances; once you switch to a different branch, those commits are reachable from no reference.
  • 2 unreachable commits bring history down from 15 entries to 13 and drop answered questions from 5 to 4; the single question that drops is bug isolation.
  • The real cost is that all 3 of the three counting queries are answered incompletely: history keeps saying yes, and the numbers it gives are 2 instead of 4, 0 instead of 1, 1 instead of 2.
  • The reflog holds HEAD’s past positions and brings recovery down to 41 bytes; once a log entry expires, object collection makes the loss permanent and irreversible.

Next Step

This topic has finished the opening side of branching. Opening a branch is a name and writes 123 bytes; keeping the name orderly adds to history’s first question what the record alone cannot give; never writing the name at all drops two commits outside history. All three measurements look in the same direction: what ties a set of commits to a piece of work is the reference.

What remains is the fork’s other end. Three branches were opened, twelve commits were written, and each stands at its own tip; at some point these tips have to be brought into the main branch. The next topic asks what merging writes to history: does the tool produce a new record when it closes the two tips, what is lost if it does not, and how many of the six questions does the way of closing leave answerable. From here on, the course runs on top of this measure.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close