Skip to content
academia.sh

Lesson 03 / 15

Branch Naming

A branch naming convention adds to history's first question what the branch record alone cannot: of the eight combinations of two integration formats and four naming conventions, the group is actually readable in only 3, because keeping a record and having a readable name are two separate conditions.

Contents

The previous lesson computed which file a switch touches from the difference between two branches, and this computation never looked at the branches’ names. Switching from the identity branch to the report branch wrote three files; if the branches had been named new and trial, it would have written the same three files. As far as the tool is concerned, a name is a label and changes no behavior.

A name serves a different question. With thirty branches in the same repository, which one belongs to which piece of work, which one can be closed, which one will live for a month — all of this is read from names alone. In the course’s measure, this corresponds to the first question: which commits belong to which piece of work? This lesson shows that this question has two separate conditions. History has to keep a branch record — and the name that record carries has to say something. Neither substitutes for the other, and the measurement counts this.

A Name’s Two Jobs

A branch name is, first, a path. The name of the refs/heads/metrics reference is metrics, and the name determines where the reference is looked for. The slash is a separator in this path: the name feature/metrics corresponds to the refs/heads/feature/metrics reference, and feature becomes a hierarchy level. The tool recognizes this level — any command that matches patterns can use the slash as a boundary.

Second, a name is a declaration. It is a field the tool does not read, but a human does. Like a commit message, it is written — or not — to carry answers to questions that will be asked later. A branch naming convention makes this second job orderly: it determines which fields the name carries, and in what order.

The fields are typically three. Type gives the work’s class (feature, fix, maintenance) and sits before the slash. Ticket ties the name to an external record; it is a number or a short code. Short name names the work itself in a word or two. The measurement counts these three fields separately, because each answers a different question, and a convention is not obliged to carry all three at once.

A Name’s Validity

Because a name is written as a file path, its shape is not free. The tool tests validity with a separate subcommand.

# taught command and example dump — not executed

$ git check-ref-format --branch 'feature/metrics'
feature/metrics
$ git check-ref-format --branch 'feature metrics'
fatal: 'feature metrics' is not a valid branch name

$ git branch --list 'feature/*'
  feature/214-metrics
  feature/215-report
  feature/216-identity

Most of the rules come from two concerns. The first is the shell and the command line: space, *, [, ?, and the backslash carry meaning in the shell and turn into something else when written unquoted. The second is the tool’s own reference syntax: the marks ~, ^, :, .., and @{ are operators used when resolving a commit by name. If feature/metrics~1 were a valid branch name, the expression “this branch’s previous commit” would become unreadable. The .lock extension, meanwhile, clashes with the lock files used when writing a reference.

There is one more rule, and it comes from a different place than the others. A name under refs/heads cannot be both a file and a directory; this is why a branch named feature cannot be opened while a branch named feature/metrics exists.

# taught command and example dump — not executed

$ git branch feature
fatal: cannot lock ref 'refs/heads/feature': 'refs/heads/feature/metrics' exists

$ git branch --merged main
  feature/214-metrics
$ git branch -d feature/214-metrics
Deleted branch feature/214-metrics (was 6e2d70b).

This is a direct consequence of the structure: using a prefix as a branch name too blocks every name under that prefix. A prefix has to stay only a prefix.

The Jobs a Convention Pays For

There are three everyday jobs a naming convention pays back, and all three are done with listing commands.

The first is filtering. git branch --list 'feature/*' lists only the branches carrying that prefix. With disorderly names, this pattern selects nothing; in a repository of thirty branches, you would only learn which branch belongs to which type by reading them one by one.

The second is closing. git branch --merged main lists branches that have been brought into main; these are the ones that can be deleted. git branch -d deletes only a merged branch and refuses if it is not merged. The forcing form skips this check, and the previous lesson’s measurement gave its cost: when a branch reference is deleted, the four commits reachable from that branch become reachable from no reference at all. A naming convention makes an indirect but real contribution here — if which branch is short-lived can be read from its name, the list of branches to delete stops being a guess.

The third is distinguishing lifespan. A long-lived branch stands for as long as the repository lives, is never deleted, and does not belong to a single piece of work; its name is fixed and carries no type prefix. A short-lived branch is opened for a single piece of work and deleted once that work is integrated; its name carries the type prefix and the ticket. When the two classes’ name shapes are kept apart, reading the list shows which one will close someday. The distinction here is set up only at the naming level; comparing branching strategies is left to the Merging topic’s final lesson.

The measurement’s assumptions:

  • BR15 — The setup is the shared definition’s twelve commits and is not changed. A naming convention is a layer added on top of the setup; it does not touch the definition of the six questions.
  • BR16 — The measurement uses the first question with the shared definition’s question1_grouping function: it checks whether every commit in history carries a branch record. This lesson does not measure the other five questions; they are measured in the Merging topic.
  • BR17 — Two integration formats are compared: merge commit keeps the branch record, fast-forward does not. The formats’ definitions are taken from the shared definition and not changed.
  • BR18 — Four naming conventions are tried: free-form, short name, by type, and by type and ticket. Each convention gives three names to three branches.
  • BR19 — The work readable from name criterion is a comparison against the oracle: it counts as readable if the setup’s name for the work appears inside the name. The type readable from name criterion looks for the part before the slash to be in the known type list; a word with no separator does not count as a type. The ticket criterion looks for a numeric field among the name’s parts.
  • BR20 — The group is readable criterion is the union of two conditions: history keeps the branch record, and all three of the three names give the work. If either condition fails, the group is not readable.
  • BR21 — Name validity is tested with a subset of the tool’s rule set; the measurement is not a parser, it is a count showing which candidate the rules reject, and why.

Measurement

"""Branch naming convention: keeping a branch record and having a readable name are separate conditions.

Part 1 - four naming conventions, four criteria: what is readable from a name.
Part 2 - format x convention cross: is the first question actually readable.
Part 3 - name validity and prefix clashes.
"""
SEED = 20260813
BRANCHES = ("metrics", "report", "identity")
FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"}
BUGGY = ("report", 2)
SHARED_FILE = "config.py"
TYPES = ("feature", "fix", "maintenance")
CONVENTIONS = {
    "free-form": {"metrics": "new", "report": "trial", "identity": "fix"},
    "short name": {"metrics": "metrics", "report": "report", "identity": "identity"},
    "by type": {"metrics": "feature/metrics", "report": "feature/report",
                "identity": "feature/identity"},
    "by type and ticket": {"metrics": "feature/214-metrics",
                           "report": "feature/215-report",
                           "identity": "feature/216-identity"}}
FORMATS = ("merge commit", "fast-forward")


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})
    elif fmt == "fast-forward":
        for k in sorted(record, key=lambda x: x["time"]):
            t.append({**k, "branch_record": None, "time_kept": True})
    return t


def question1_grouping(t):
    """The shared definition's first question; its definition is not changed."""
    return all(x.get("branch_record") for x in t if not x.get("merge"))


def gives_work(name, branch):
    return branch in name


def gives_type(name):
    return "/" in name and name.split("/")[0] in TYPES


def gives_ticket(name):
    return any(p.isdigit() for p in name.replace("/", "-").split("-"))


def filters(name, prefix="feature/"):
    return name.startswith(prefix)


FORBIDDEN = set(" ~^:?*[\\")


def valid(name):
    if not name or name.startswith("/") or name.endswith("/") or "//" in name:
        return "slash position"
    if name.startswith("-"):
        return "starts with a dash"
    if any(c in FORBIDDEN for c in name):
        return "forbidden character"
    if ".." in name or "@{" in name or name.endswith(".lock"):
        return "forbidden sequence"
    if any(p.startswith(".") for p in name.split("/")):
        return "part starting with a dot"
    return "valid"


def prefix_clash(names):
    return [(a, b) for a in names for b in names
            if a != b and b.startswith(a + "/")]


record = development()
T = {b: integrate(record, b) for b in FORMATS}
print(f"setup: {len(record)} commits, {len(BRANCHES)} branches; a naming convention "
      f"changes neither the setup nor the definition of the six questions")
print()
print(f"{'convention':<22s}{'work in name':>13s}{'type in name':>13s}"
      f"{'ticket':>9s}{'filtered by prefix':>19s}")
for name, mapping in CONVENTIONS.items():
    print(f"  {name:<20s}{sum(gives_work(mapping[d], d) for d in BRANCHES):9d}/3"
          f"{sum(gives_type(mapping[d]) for d in BRANCHES):9d}/3"
          f"{sum(gives_ticket(mapping[d]) for d in BRANCHES):7d}/3"
          f"{sum(filters(mapping[d]) for d in BRANCHES):19d}")
print()
print(f"{'format':<24s}{'convention':<20s}{'branch record':<14s}{'work in name':>13s}"
      f"{'group readable':>16s}")
readable = 0
for f in FORMATS:
    has_record = question1_grouping(T[f])
    for name, mapping in CONVENTIONS.items():
        n = sum(gives_work(mapping[d], d) for d in BRANCHES)
        g = has_record and n == len(BRANCHES)
        readable += g
        print(f"  {f:<22s}{name:<20s}{('yes' if has_record else 'no'):<14s}"
              f"{(str(n) + '/3') if has_record else '-':>13s}"
              f"{('yes' if g else 'no'):>16s}")
print(f"first question: branch record present in "
      f"{sum(question1_grouping(T[f]) for f in FORMATS)} / {len(FORMATS)} formats; "
      f"group readable in {readable} / {len(FORMATS) * len(CONVENTIONS)} rows")
print()
CANDIDATES = ("feature/metrics", "feature metrics", "feature/metrics~1",
              "feature/..metrics", "feature/metrics.lock", "feature//metrics",
              "-metrics", "feature/metrics@{1}")
print(f"{'candidate name':<26s}result")
for a in CANDIDATES:
    print(f"  {a:<24s}{valid(a)}")
NAMES = ("feature", "feature/metrics", "feature/report", "feature/identity")
print(f"\nprefix clash: {len(prefix_clash(NAMES))} pairs — "
      f"a branch named feature cannot coexist with the three branches under feature/")
setup: 12 commits, 3 branches; a naming convention changes neither the setup nor the definition of the six questions

convention             work in name type in name   ticket filtered by prefix
  free-form                   0/3        0/3      0/3                  0
  short name                  3/3        0/3      0/3                  0
  by type                     3/3        3/3      0/3                  3
  by type and ticket          3/3        3/3      3/3                  3

format                  convention          branch record  work in name  group readable
  merge commit          free-form           yes                     0/3              no
  merge commit          short name          yes                     3/3             yes
  merge commit          by type             yes                     3/3             yes
  merge commit          by type and ticket  yes                     3/3             yes
  fast-forward          free-form           no                        -              no
  fast-forward          short name          no                        -              no
  fast-forward          by type             no                        -              no
  fast-forward          by type and ticket  no                        -              no
first question: branch record present in 1 / 2 formats; group readable in 3 / 8 rows

candidate name            result
  feature/metrics         valid
  feature metrics         forbidden character
  feature/metrics~1       forbidden character
  feature/..metrics       forbidden sequence
  feature/metrics.lock    forbidden sequence
  feature//metrics        slash position
  -metrics                starts with a dash
  feature/metrics@{1}     forbidden sequence

prefix clash: 3 pairs — a branch named feature cannot coexist with the three branches under feature/

Reading the Numbers

The top table compares the four conventions field by field. Free-form names give zero on all four criteria: which of the names new, trial, and fix belongs to which piece of work cannot be worked out. The third name was chosen deliberately — fix looks like a type name but does not count as one because it carries no separator, and the work it stands for is identity, meaning the name does not just stay silent, it misdirects.

The short name convention takes the first column to 3/3, and this alone earns the biggest jump. The type prefix opens the second column and raises the filtered by prefix column from 0 to 3; the condition for filtering is not that the name gives the work, but that it carries a shared prefix. The ticket field exists only in the fourth convention, and it opens only the third column. Each field opens one column, none opens two: the fields do not substitute for each other.

The middle table is the lesson’s main measure. The group is readable in 3 of the eight rows, and the five unreadable rows are unreadable for two separate reasons.

Four rows drop out from the absence of a branch record. In the fast-forward format, all four naming conventions give the same result, because the name never enters history at all: a branch name only exists as long as the branch lives, it vanishes when the branch is deleted, and no trace of it remains in history. Even the most careful naming convention makes zero contribution in a format that keeps no record.

The fifth row drops out from the name saying nothing. In the merge commit format, a branch record exists; history distinguishes the three groups, and question1_grouping says yes on this row too. But the name the record carries is new, trial, and fix. The record’s existence gives the group’s boundary, its name gives the group’s name; without the second, history says “these twelve commits split into three groups” and does not say what the groups are. This is exactly what the measurement says: a branch record exists in 1/2 formats, but the group is readable in 3/8 rows.

In an eight-row set, the smallest measurable difference is 1/8. The cost of losing the record is 4/8, the cost of losing the name is 1/8; the two are not the same size, and their ranking cannot be reversed. In a format that keeps no record, arguing about a naming convention has no counterpart; in a format that keeps one, the convention’s cost is a single row, and if that row is not paid, the record itself goes half to waste.

The bottom table gives the name’s format rules. 1 of eight candidates is valid, 7 are rejected for four separate reasons. None of the rejections is a typo; each sits at a point where the name clashes with either the shell or the tool’s own reference syntax. The 3 clashes in the last line are the only constraint that comes from inside the convention itself: using a prefix as a branch name makes every name under that prefix impossible.

Summary

  • A branch name is both a path under refs/heads and a declaration for a human; the slash sets up a hierarchy level in this path and makes filtering by pattern possible.
  • A naming convention can carry three fields — type, ticket, short name — and in the measurement each field opens only one criterion: the fields do not substitute for each other.
  • The group is readable in 3 of the eight combinations; of the five rows that drop out, 4 drop from the absence of a branch record, 1 from the name saying nothing.
  • In an integration format that keeps no record, the name never enters history and a naming convention’s contribution is 0; in a format that keeps one, a disorderly name wastes half of the record.
  • 1 of eight candidate names is valid; the rejected ones clash with shell marks and the tool’s own reference syntax, and when a prefix is used as a branch name, the 3 names under it are blocked at once.

Next Step

A name is what ties a set of commits to a piece of work; the record carries its boundary, the convention carries its meaning. Both depend on HEAD pointing to a branch name — the chain built in the previous lessons had three links: HEAD points to a branch name, the branch name points to a commit ID, the commit points to its own parent. What if the middle link is taken out? When HEAD points directly to a commit ID instead of a branch name, commits made there attach to no branch. The next lesson measures how this state arises, which of the six questions a commit made there falls outside of, and how history keeps answering even though it does not know what it has lost.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close