Lesson 10 / 15
Branch Strategies
How long a branch lives determines what gets written to history: the same twelve commits leave 12 with no branch opened, 15 under a pattern that returns every four commits, and 24 under a pattern that returns every commit — and once the divergence window closes, a merge conflict never arises at all.
Contents
In the previous lesson, the format choice was made for a single merge: how this branch should be taken into main. Teams do not make this decision anew each time; they make it once and turn it into a rule.
The rule does not only cover the moment of merge. It also determines how long branches will live, how many there will be, and how often they will return to main. This lesson’s question is this: when the same twelve-commit development is run under different branch patterns, what remains in history — and which question does shortening branch lifetime remove.
The discussion of continuous integration’s build, test, and deployment pipeline is not this course’s subject; it is built in the Continuous Integration and Delivery course and is not repeated here. The only thing measured is what the strategy leaves in history.
Branch Lifetime Is a Number
The difference between branch strategies reduces to a single quantity: how many commits does a branch accumulate before returning to main? This number determines every other property of the pattern.
Under the long-lived branch pattern the number is large. The branch stays open until an entire piece of work is finished; it can live for weeks and carry dozens of commits. Under the short-lived branch pattern the number is small; the branch lives less than a day, carries a few commits, and closes. There is a further pattern at the extreme: no branch is opened at all, and everyone commits directly to the main line.
All three patterns get the same work done and produce the same code. Where they differ is the record they leave in history and how far apart the branches drift from each other.
The short-lived pattern’s daily cycle reduces to a few commands and repeats identically every round.
# taught command and example dump — not executed $ git switch -c metrics/threshold main $ git commit -am "metrics: print threshold value" $ git switch main $ git merge --no-ff metrics/threshold $ git branch -d metrics/threshold
The fifth line is the pattern’s distinguishing part: the branch is closed right after the work enters main. Short branches that go unclosed pile up, and the branch list turns into a pointer junkyard — since the record already sits in history as a closing commit, the pointer’s presence answers no question.
Long-lived branches are not a single class. A feature branch carrying one piece of work closes when the work is done; a branch carrying a published release does not close, it lives for months and takes only fixes. In the second case, divergence is by design — that branch drifting away from main is not a flaw, it is the purpose. Because of this, fixes have to go separately to that branch and to the development line; the single-commit copy from the fourth lesson is used most often here, and the duplication its copy demands piles up here too.
The naming pattern becomes decisive here. Under the short-lived pattern, branch count grows not with the size of the work but with the frequency of returns; a piece of work’s successive rounds become separate branches. If branch names are not set up to tie these rounds to the same piece of work, branch records in history multiply but none of them shows the work as a whole. Naming’s contribution to history’s first question was established in this course’s third lesson; here that same contribution is multiplied by branch count.
Divergence and the Conflict Window
The second lesson’s conflict was born on the setup’s third step: metrics and report
touched the same file. But touching the same file is not enough for a conflict to be
born. The two changes have to have been developed in parallel — meaning, when the
second one started, the first must not yet have entered main.
If the first has already entered, the second branch sees it as its base. There is then no two-sided change; there is a one-sided change, and the tool makes the decision on its own. The same line changed twice, but in sequence, not in conflict.
From this comes branch lifetime’s second effect: the longer a branch lives, the more commits it diverges from main by, and the greater the chance that two pieces of work touch the same file in parallel. Divergence is not a duration, it is a commit count; and it equals the pattern’s number.
This is the real reason for the short-lived branch pattern. The reason is not “resolving a conflict becomes easier”; it is that the conflict never arises at all. In exchange, a short branch requires unfinished work to sit on main; for unfinished work to sit there unseen, it has to be able to be turned off at run time, and that mechanism itself is the subject of the Continuous Integration and Delivery course.
The measurement’s assumptions:
- MR36 — A branch pattern is represented by a single number: how many commits a branch accumulates before returning to main. Zero means the branch is never opened.
- MR37 — Every return writes one closing commit. If the pattern returns every four commits, this is exactly the merge commit format measured in the first lesson.
- MR38 — Under the short-lived pattern, every round is a separate branch and carries a separate branch record; there is no field linking one piece of work’s rounds to each other.
- MR39 — Divergence is the largest number of commits a branch stays away from main, and it equals the pattern’s number.
- MR40 — Two touches count as parallel if, when the second one’s branch opened, the first had not yet entered main. A merge conflict is born only from parallel touches.
- MR41 — Time values are the setup’s own clock, not an environment-dependent timestamp; they are used only for ordering.
- MR42 — The six questions’ definitions are not changed; they are run separately for each pattern.
- MR43 — The set’s resolution is 1/12 at twelve commits and 1/6 at six questions.
Measurement
"""Branch strategy: what branch lifetime leaves in history. Part 1 - four branch patterns: no branch, returning every one, two, and four commits. Part 2 - whether two commits touching the same shared file stay parallel. """ SEED = 20260813 BRANCHES = ("metrics", "report", "identity") FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"} BUGGY = ("report", 2) SHARED_FILE = "config.py" PATTERNS = ((0, "no branch"), (1, "return every commit"), (2, "return every two commits"), (4, "return every four commits")) 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 close_out(group, branch, round_num): """A round's commits and its closing commit.""" name = f"{branch}#{round_num}" y = [{**x, "branch": name, "work": branch, "branch_record": name, "time_preserved": True} for x in group] y.append({"branch": name, "work": branch, "step": 0, "file": None, "buggy": False, "time": group[-1]["time"], "branch_record": name, "merge_commit": True, "time_preserved": True}) return y def history(record, k): """Branch pattern returning to main every k commits. k=0: branch never opened.""" ordered = sorted(record, key=lambda y: y["time"]) if k == 0: return [{**x, "work": x["branch"], "branch_record": None, "time_preserved": True} for x in ordered] t, buffer, round_count = [], {d: [] for d in BRANCHES}, {d: 0 for d in BRANCHES} for x in ordered: buffer[x["branch"]].append(x) if len(buffer[x["branch"]]) == k: round_count[x["branch"]] += 1 t.extend(close_out(buffer[x["branch"]], x["branch"], round_count[x["branch"]])) buffer[x["branch"]] = [] for d in BRANCHES: if buffer[d]: round_count[d] += 1 t.extend(close_out(buffer[d], d, round_count[d])) return t def q1_grouping(t): return all(x.get("branch_record") for x in t if not x.get("merge_commit")) def q2_bug(t): buggy = [x for x in t if x["buggy"]] return len(buggy) == 1 and not buggy[0].get("squashed") def q3_order(t): return all(x.get("time_preserved") for x in t) def q4_file(t): return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t) def q5_integrity(t): positions = {} for i, x in enumerate(t): if x.get("merge_commit"): continue positions.setdefault(x["branch"], []).append(i) return all(y[-1] - y[0] == len(y) - 1 for y in positions.values()) def q6_conflict(t): return any("chosen" in x for x in t) QUESTIONS = (("feature grouping", q1_grouping), ("bug isolation", q2_bug), ("true order", q3_order), ("file trail", q4_file), ("branch integrity", q5_integrity), ("conflict decision", q6_conflict)) def entry_time(record, k, branch, step): """The moment that commit entered main.""" group = [x for x in record if x["branch"] == branch] if k == 0: return group[step - 1]["time"] return group[min(((step - 1) // k + 1) * k, len(group)) - 1]["time"] def round_start(record, k, branch, step): """The moment the branch holding that commit's round was opened.""" group = [x for x in record if x["branch"] == branch] if k == 0: return group[step - 1]["time"] return group[((step - 1) // k) * k]["time"] record = development() touches = [(k["branch"], k["step"]) for k in record if k["file"] == SHARED_FILE] first, second = touches[0], touches[1] print(f"real development: {len(record)} commits | commits touching {SHARED_FILE}: " f"{touches}") print() print(f"{'branch pattern':<24s} {'commits':>7s} {'closings':>8s} {'branch records':>14s}" f" {'branches/work':>13s} {'answered':>8s}") for k, name in PATTERNS: t = history(record, k) closings = sum(1 for x in t if x.get("merge_commit")) records = {x["branch_record"] for x in t if x["branch_record"]} print(f"{name:<24s} {len(t):7d} {closings:8d} {len(records):14d}" f" {(len(records) / len(BRANCHES) if records else 0):13.1f}" f" {sum(s(t) for _, s in QUESTIONS):8d}") print() print(f"{'branch pattern':<24s} {'divergence':>10s} {'first touch enters':>19s}" f" {'second branch opens':>20s} {'parallel':>8s}") for k, name in PATTERNS: enters = entry_time(record, k, first[0], first[1]) opens = round_start(record, k, second[0], second[1]) print(f"{name:<24s} {k:10d} {enters:19d} {opens:20d}" f" {('yes' if enters > opens else 'no'):>8s}")
real development: 12 commits | commits touching config.py: [('metrics', 3), ('report', 3)]
branch pattern commits closings branch records branches/work answered
no branch 12 0 0 0.0 3
return every commit 24 12 12 4.0 5
return every two commits 18 6 6 2.0 5
return every four commits 15 3 3 1.0 5
branch pattern divergence first touch enters second branch opens parallel
no branch 0 17 19 no
return every commit 1 17 19 no
return every two commits 2 23 19 yes
return every four commits 4 23 4 yes
What They Left in History
The top table’s last column is surprising at first glance: three of the four branch patterns answer 5 of the six questions. The pattern that returns every four commits is exactly the merge commit format from the first lesson and gives the expected number; but the pattern that returns every commit and the pattern that returns every two commits give the same 5. Six questions do not tell branch patterns apart. The no-branch pattern, meanwhile, stays at 3 — since no branch record is ever written, feature grouping and branch integrity drop, and the result matches fast-forward’s result.
What tells the patterns apart is the columns on the left. The same twelve-commit development leaves 15 commits under the pattern returning every four commits, 18 under every two commits, 24 under every commit. The closing-commit count is 3, 6, and 12 respectively. Under the last pattern, half of history is closing commits: twelve of twenty-four records touch no file at all. This is four times the 3/12 closing price measured in the previous lesson, and it grows linearly with branch count — when return frequency doubles, closing count doubles too.
The branch-record column gives the second cost. Under the pattern returning every commit, there are 12 separate branch records, and split across three pieces of work that comes to 4.0 branches per piece of work. This number means history does not show one piece of work as a single record: the feature grouping question says yes, but the group it shows is not a piece of work, it is a round. The question is answered at the branch level, and a branch has shrunk below a piece of work.
A warning follows from this. The six questions were set up to measure an integration format, and they do that job; on their own they are not enough to measure a branch pattern. The pattern’s cost sits outside the questions, in the closing ratio and the branches-per-work count. Knowing how far a measure holds matters as much as the measure itself.
What Divergence Costs
The bottom table looks the other way. The two commits touching config.py are the same
two commits in every pattern; what changes is whether the first one is on main by the
time the second one’s branch opens.
Under the pattern returning every four commits, the metrics branch’s third commit
enters main at moment 23, while the report branch was opened at moment 4. The
second branch opened so much earlier that it never sees the first one’s change: the two
touches are parallel, and a conflict is born. Under the pattern returning every two
commits, divergence is cut in half but the result does not change; entry moment is
23, branch opening is 19, and the touches are still parallel.
Under the pattern returning every commit, the table turns. The metrics branch’s third
commit enters main at the moment it is written, 17; the report branch’s round
opens at 19. The second branch sees the first one’s change as its base, and
parallel is no. Even though the same file is touched, there is no conflict left to
resolve. Under the no-branch pattern, divergence is 0 and the result is the same.
The pattern is this: once the divergence window closes, conflict does not arise. What the short-lived branch buys is not conflicts that are easy to resolve, it is conflicts that never arise. What it pays is in the top table: twelve closing commits and four branch records per piece of work.
A third cost sits outside the measurement and needs to be stated. Keeping divergence low depends on a branch being able to return to main quickly; if the return goes through an approval step, divergence stretches by the length of that step. So branch lifetime is not only a writing habit, it is a review speed. The review flow’s mechanics are set up in this course’s next topic; the review axis and culture are a separate course’s subject.
Read together, the two tables settle the strategy decision. A long-lived branch keeps history short and readable at the level of a piece of work, and accumulates divergence and conflict. A short-lived branch removes conflict, inflates history with closing commits, and splits work into rounds. There is no single correct point between the two; which cost is affordable is the team’s question. The measurement only shows that the two costs cannot be reduced at the same time.
Summary
- The difference between branch strategies reduces to a single quantity: the number of commits a branch accumulates before returning to main. This number determines both history’s length and its divergence.
- The same twelve-commit development leaves 12 commits under the no-branch pattern, 15 under every four commits, 18 under every two commits, 24 under every commit; the closing counts are 0, 3, 6, and 12.
- Three of the four branch patterns answer 5 of the six questions: six questions do not tell branch patterns apart. The no-branch pattern stays at 3.
- Under the pattern returning every commit, 4.0 branch records fall to each piece of work; feature grouping says yes, but the group it shows is a round, not a piece of work.
- Conflict is born only from parallel touches; when divergence is 1 or 0, the second branch sees the first one’s change as its base, and conflict does not arise at all.
Next Step
Everything this topic has built — four integration formats, resolving a conflict, branch lifetime, and the strategy decision — was measured under a single-repository assumption. History sat in one place, merges were done there, branch records were written there.
In a real team, this assumption does not hold: history lives in multiple copies. Each person has their own repository, the copies advance separately from each other, and every question measured is asked once more — this time asking “in which copy.” The next topic starts from here and first answers the most basic question: how does one repository recognize another, and how are multiple copies named and kept separate.
To keep your progress and take notes, Log in
My notes
Log in to take notes.