Five sprints inside one PR: what an iOS budget app's side-branch pattern reveals about tracking

The crossed(previous:current:) method on StreakMilestones is three lines. It takes a previous streak count and a current streak count, returns the highest milestone boundary crossed between them (7, 14, 30, 60, or 100 days), or nil if none was crossed. Pure function. No side effects. Its tests are self-contained. The commit that ships it lands inside PR #10 alongside four other things that have no technical relationship to streaks: a category-budget quest, a daily save target preference, a palette picker, and an animation layer for the Cursed Energy UI.

Five complete, shippable features. One PR.

Improving on the shipping mechanics I covered in “Five features, one PR,” what PR #10 actually reveals is a tracking problem. The PR is one unit. The work is five. Those are different numbers, and conflating them hides both.

What self-contained looks like

Each of the five entries on PR #10 reads as a sprint brief: a problem statement, an implementation with named types and specific thresholds, and a clear completion signal.

StreakMilestones is a pure type. The boundary logic is isolated in crossed(previous:current:). The flame-themed celebration overlay fires on that method’s return value. You can reason about it without reading any of the other four features. Completion signal: the overlay exists, the milestones are 7, 14, 30, 60, and 100 days, the tests pass.

categoryBudgetQuest fires when a category accumulates three or more outflow transactions in the last seven days with total spend at or above £20. The slot lives in QuestGenerator. The threshold logic is explicit. The category chosen is the one with the largest spend in the window. Completion signal: the quest type exists, the thresholds are encoded, the slot is wired.

dailySaveTarget closes PE-005. UserProfileStore gains a dailySaveTarget: Decimal property: Published, defaulting to £15, persisted as a Decimal-string in UserDefaults. The SaveX quest’s target was a hardcoded constant; it’s now a real per-user preference. Completion signal: PE-005 closed, property exists, default is £15.

The palette picker changes BBPalette.current from a static let to a nonisolated(unsafe) static var and introduces a PaletteChoice enum covering the Amethyst and Inferno options, initialised from UserDefaults at process start. The mutation point is deliberate. nonisolated(unsafe) is required because global state in Swift’s concurrency model needs an explicit opt-out when initialising from a stored preference at launch. Completion signal: the picker surface exists, both palettes are selectable, the preference persists across launches.

The motion layer lands the animation layer for Cursed Energy (B-014). Five additions (sigil rotation, energy pulse, claim spark, and level-up celebration among them), all gated by @Environment(\.accessibilityReduceMotion). No surprise motion for users who disabled it at the system level. None of this touches the quest generator or the streak logic.

Five entries. Each one tells you what changed, where the boundary sits, and when it’s done. Each one could be reviewed in isolation. None of them depend on the others.

The orthogonality

A PR is a unit of deployment risk, and a feature is a unit of product value. Different things. The two don’t have to align.

PR #10 is proof. One review, one merge, one rollback surface. But five completion events, five closure points, five things that could each land independently in a sprint retrospective as shipped.

If your tracking system is PR-centric (GitHub’s default merge view, most CI/CD dashboards, the merge-count velocity metrics built into most planning tools), PR #10 registers as one unit of work. If your tracking system is feature-centric, it registers as five. Neither view is wrong. They’re measuring different things.

The problem is conflation. A sprint that ships five features inside one PR looks, in the PR view, identical to a sprint that ships one feature inside one PR. Merge-based velocity undercounts the first sprint by a factor of five. The signal is wrong, not the work.

The same conflation runs in the other direction. Five features in one PR is a different deployment risk profile from five features in five PRs. One merge point, one potential rollback, five things to untangle if something goes wrong. Feature-only tracking misses that entirely. You know how many things shipped; you don’t know whether they arrived together.

What the “side branch” label is doing

Each of the five devlog entries carries the note: “Side branch — same PR #10.” That’s doing more than orientation work. It marks a distinction: this feature was complete before the PR defined the delivery boundary. Development and shipping are two separate events, noted separately.

“Side branch” isn’t standard vocabulary. Sprint, story, ticket, PR: all of those conflate development and deployment in different ways. A story closes when the PR merges. A ticket transitions to done when the review passes. Neither preserves the distinction between “this was done as work” and “this was shipped as a unit.”

“Side branch” does. It says: here is a unit of work that happened to travel with others. The taxonomy is quiet, but it’s precise.

The phase tags do similar work at a different level. feat(game), feat(ui): the scope in the commit message subject is the feature scope, not the PR scope. PR #10 doesn’t appear in the commit subjects; it appears in the body notes. The two granularities are separated deliberately, not accidentally.

The practical implication

Track both or lose one.

PR tracking gives you shipping cadence and deployment risk context. Feature tracking gives you throughput and a cleaner completion signal. Neither replaces the other.

The minimum viable approach costs nothing to add: keep the commit message scope at the feature level and note the PR reference separately. That’s already what the iOS budget app’s devlog does. The two views come from reading the same record differently: group by PR for the deployment view, group by commit for the feature view.

The harder question is what you do with the data. Reporting velocity to yourself: the feature view gives a truer number in weeks like this one. Managing deployment risk: the PR view tells you what’s coupled. Post-mortems on what shipped when: you need both.

PR #10 surfaces the distinction because five distinct features happened to land together. Most PRs don’t make it visible; a single-feature PR makes the two views coincide, and you never notice the gap. The pattern only becomes apparent when the numbers diverge. At that point, if you haven’t already built the habit of recording both, you’re inferring one from the other and getting it wrong.

The devlog entries for PR #10 are the simpler version of that infrastructure. One entry per feature, not per PR. What shipped, at what scope, in which PR. That distinction (feature scope recorded at commit time, PR reference noted but not load-bearing) is the thing that makes both views recoverable from the same record.

PR scope and feature scope are orthogonal. Record them that way.

All writing