Five features, one PR: how an iOS budget app ships concurrent phase work without a merge queue

BBPalette.current was a static let. Changing it to nonisolated(unsafe) static var is not a trivial edit. You're moving from compile-time-constant to process-start-mutable, and nonisolated(unsafe) is Swift's way of saying "I accept responsibility for the concurrency contract here." That change is Phase E, PE-004. It landed in an iOS budget app on 2026-06-14, alongside a Phase D motion layer, a Phase D streak-milestone overlay, a Phase C quest type, and a Phase E save-target preference. Five features, three phases, one PR #10.

That wasn't batching. It was a deliberate branching choice.

The problem the side-branch pattern solves

Standard concurrent feature work uses one branch per feature, each targeting main. Each feature owns its PR lifecycle: open, CI, review, merge. For a solo developer across multiple roadmap phases, that model serialises everything. Branch B waits for branch A. If A and B are independent but touch overlapping infrastructure, conflict resolution enforces the sequence rather than design. The merge queue grows; phases stall while waiting for the previous one to clear.

The side-branch pattern inverts the unit. The PR is the merge event; the commit is the concurrency primitive. One PR branch accumulates commits from multiple work sessions across multiple phases until the batch is ready. Then one merge, one CI run, one diff in the review log.

PR #10 is what that looks like in practice.

What landed

Phase D — motion layer (B-014)

The Cursed Energy DNA gained its animation layer. Sigil rotation, energy pulse, claim spark, level-up celebration — all gated by @Environment(\.accessibilityReduceMotion). The gate is structural, not advisory. No surprise motion fires for users who have set the system-wide accessibility toggle. The policy is in the code, not in a comment.

Phase D extension — streak milestones

StreakMilestones is a pure type. crossed(previous:current:) -> Int? returns the highest milestone the user crossed in a single streak update; thresholds at 7, 14, 30, 60, and 100 days. When it returns non-nil, a flame-themed celebration overlay fires. This extends Phase D rather than opening a new phase because it uses the same motion infrastructure; there was no case for a separate branch.

Phase C — category-budget quest

QuestGenerator gains categoryBudgetQuest. Firing condition: any spending category with three or more outflow transactions in the last seven days, total at or above £20. The generator picks the category with the largest spend. Phase C placement because QuestGenerator already lives there; no new surface required.

Phase E, PE-004 — palette picker

That static let to nonisolated(unsafe) static var change. BBPalette.current is now initialised from UserDefaults at process start; PaletteChoice backs the enum. Amethyst and Inferno are the two shipped palettes. This is the UI customisation surface from B-013.

Phase E, PE-005 — save target

UserProfileStore gains dailySaveTarget: Decimal, a @Published property, default £15, persisted as a Decimal-string in UserDefaults. Closes PE-005. The SaveX quest's target is now a real per-user preference rather than a hardcoded constant.

Why one PR worked here

Three conditions held, and the pattern depended on all three.

File independence. StreakMilestones doesn't touch QuestGenerator. The palette initialisation path doesn't intersect UserProfileStore. The motion layer lives in the animation layer, not the quest engine. None of these features conflict at the file level. If categoryBudgetQuest had needed to write into BBPalette, the pattern would have required sequencing regardless of the branching model. Commit-level parallelism works only when features are genuinely independent at the file level. It doesn't hide dependencies; it stops creating artificial ones where none exist.

A stable PR branch. Mid-sprint rebases break the side-branch pattern. Every commit in the batch has the PR branch as its base. Rewrite the base and you force a cascade across everything built on it. PR #10's branch didn't move while the commits accumulated.

Phase tagging on commits. The diff spans Phase C quest logic, Phase D animation, and Phase E settings. Without feat(game) and feat(ui) scoping and explicit phase labels in the commit messages, a diff this wide is opaque in review. The tags are what make it navigable. A reviewer (or future-me six months from now in git log) can go straight to Phase E without context-switching through D first.

The trade-off

The diff is wide. That's a real cost. A wide diff asks the reviewer to hold Phase C, D and E context simultaneously. On a solo project, the reviewer is future-me and the review happens at git blame time, months after the merge. Naming the cost doesn't eliminate it.

The offset is atomicity. All five features land or none do. That matters here because the motion layer and the streak milestone overlay share an accessibility gate. Splitting them into separate PRs would have created two separate merge events, each of which could land while the gate was in a half-assembled state. The motion layer live but the milestone overlay waiting, both touching @Environment(\.accessibilityReduceMotion) in subtly different ways. One merge closes that window. The wide diff is the price.

What this isn't

It's not a recommendation to batch unrelated work into fat PRs. The five features in PR #10 are independent at the file level but thematically coherent: all Phase C/D/E deliverables, same sprint, same release target. Five unrelated fixes from five different weeks shouldn't share a branch. The pattern requires concurrent intent. These features were planned to ship together.

It also doesn't scale to features with file-level dependencies. If any two of these had shared a write target, the side-branch model would have needed an explicit ordering regardless. That's a sequencing problem; the solution there is dependency tracking, not a different branch naming scheme.

Out of scope: what happens when the PR branch needs to rebase mid-sprint. That's a different article.

Where an iOS budget app stands

Phase C gained smart-quest variety. Phase D added a motion layer and streak milestones. Phase E shipped palette choice and save-target preference. Three phases, five features, one PR. The roadmap advanced in parallel without a merge queue. The receipt is PR #10, 2026-06-14, no conflicts.

All writing