Free lesson

Implement trunk-based development for AI projects

You will set up a Git branching strategy optimized for AI/ML projects on GKE. Configure a trunk-based workflow where main is always deployable. Create short-lived feature branches with naming conventions: feature/add-prompt-template, fix/embedding-pipeline, config/model-parameters. Implement branch lifecycle: create from main, push commits, open PR, squash merge back to main, delete branch. Configure git to use rebase by default (git config pull.rebase true). Demonstrate the workflow end-to-end with a prompt template change.

~25 min read · Free to read — no subscription required.

Implement trunk-based development with short-lived feature branches for AI codebases

Introduction

When you ship prompts, training JSONL, and model configs from feature branches that drift for a week, you discover at merge time that Git cannot reconcile two engineers who appended training rows to the same file or bumped the same prompt version on different branches. The conflict surface is unmergeable: every JSONL line is semantically independent, and prompt registry entries are point-in-time facts, not text to be three-way-merged. Teams that let branches age past 48 hours pay this cost in lost sprint days and silent regressions in eval scores after botched merges.

By the end of this lesson you will be able to enforce trunk-based discipline on an AI codebase: name branches by intent (feat, data, fix, infra, deps), validate names and ages at push time with a Git hook, and configure merge strategy per branch type so that prompt and data changes stay atomic and bisectable.

Key Terminology

  • Trunk — the single shared branch (typically main) that is always deployable; every commit triggers schema, evaluation, and infrastructure validation, so trunk is the source of truth for prompts, models, and configs.
  • Short-lived feature branch — a branch that lives at most 48 hours before merging back to trunk; the time bound is what keeps prompt and JSONL drift small enough to mechanically resolve.
  • Squash merge — collapses every commit on a feature branch into one atomic commit on trunk, so git bisect lands on semantically meaningful units like "add summarization prompt v3" instead of WIP saves.
  • Branch protection rule — a server-side policy on the remote (GitHub/GitLab) that gates merges on required checks and merge strategy per branch pattern, so the policy holds even when a local hook is bypassed.
  • Prompt registry — a tracked JSON file (e.g. prompt_registry.json) on trunk mapping prompt IDs to version numbers and file paths; the rule "one version bump per branch" makes regressions trivially attributable.

Concepts

The goal is to make integration continuous and prompt/data changes atomic. Three ideas combine to get there: a branch taxonomy that encodes intent, a 48-hour lifetime that bounds drift, and a per-category merge strategy that preserves auditability. The branch validator and pre-push hook that operationalize these ideas are demonstrated in code (see Code Walkthrough).

Why short-lived branches matter for AI codebases

AI repositories carry three artifact classes that break long-lived branches. Prompt and config files are shared blocks where parallel edits collide. JSONL training files grow by append, and two branches appending different rows produce overlapping diffs Git flags but cannot resolve correctly — line order matters for reproducibility. Dependency graphs are dense: serving config depends on preprocessing depends on feature-store schema, so any of the three evolving in isolation guarantees a painful rebase.

Capping branch life at 48 hours shrinks the conflict window. Work-in-progress sits behind feature flags on trunk rather than on parked branches. Renovate and Dependabot PRs land cleanly because no human branch is stale enough to require a deep rebase before they apply.

Branch taxonomy encodes intent

A naming convention turns branch names into routable metadata for hooks and CI. Each branch type triggers a different validation lane: feat/prompt-* runs eval suites, data/* runs schema and row-count checks, fix/* runs full regression, infra/* runs IaC plan/apply, deps/* runs build + dependency audit.

Loading diagram...

Three short-lived branches merge back to trunk within a single sprint cycle. No branch coexists with another past one workday, which is what prevents the drift that causes painful merge conflicts in JSONL and prompt files.

Squash merge vs merge commit

Squash merge is the default for feat, data, fix, and infra branches because trunk should read as a series of atomic, bisectable changes — "add summarization prompt v3", not "wip" → "fix typo" → "actually fix it". Branches under deps/* are the explicit exception: Renovate and Dependabot embed structured changelog metadata (package name, version range, changelog URL) in commit messages that downstream audit tooling parses, so those branches merge with a true merge commit. Configure branch protection so squash is required for the first four prefixes and merge commits are allowed exclusively for deps/*.

One prompt bump per branch

Every feature branch increments exactly one prompt version in prompt_registry.json and ships its eval results alongside. Because branches are short and scope is tight, registry conflicts reduce to concurrent increments on different keys — a 30-second reconcile, not a half-day investigation. When an eval metric drops after a deploy, git log --oneline on trunk points at exactly one prompt change.

Code Walkthrough

The two snippets below operationalize the branch taxonomy, the 48-hour lifetime, and the local enforcement layer from the Concepts section. The first defines the validator with regex patterns and an age check; the second wires it into a pre-push hook so the policy fires before a stale or off-pattern branch reaches the remote.

Code snippetpython
1import re 2import subprocess 3from datetime import datetime, timezone 4from dataclasses import dataclass 5from typing import Optional 6 7BRANCH_PATTERNS = { 8 "feature": re.compile(r"^feat/(prompt|model|pipeline|serve)-[\w-]{3,50}$"), 9 "data": re.compile(r"^data/(training|eval|validation)-[\w-]{3,50}$"), 10 "fix": re.compile(r"^fix/[\w-]{3,50}$"), 11 "infra": re.compile(r"^infra/(gke|helm|terraform)-[\w-]{3,50}$"), 12 "deps": re.compile(r"^deps/(renovate|dependabot)-[\w-]{3,50}$"), 13} 14MAX_BRANCH_AGE_HOURS = 48 15 16@dataclass 17class BranchMetadata: 18 category: str 19 is_valid: bool 20 age_hours: Optional[float] = None 21 is_stale: bool = False 22 23def validate_branch_name(branch: str) -> BranchMetadata: 24 for category, pattern in BRANCH_PATTERNS.items(): 25 if pattern.match(branch): 26 return BranchMetadata(category=category, is_valid=True) 27 return BranchMetadata(category="unknown", is_valid=False) 28 29def get_branch_age_hours(branch: str) -> Optional[float]: 30 result = subprocess.run( 31 ["git", "log", "-1", "--format=%cI", branch], 32 capture_output=True, text=True, 33 ) 34 if result.returncode != 0: 35 return None 36 created = datetime.fromisoformat(result.stdout.strip()) 37 return (datetime.now(timezone.utc) - created).total_seconds() / 3600 38 39def enforce_branch_policy(branch: str) -> BranchMetadata: 40 metadata = validate_branch_name(branch) 41 if not metadata.is_valid: 42 return metadata 43 metadata.age_hours = get_branch_age_hours(branch) 44 if metadata.age_hours is not None and metadata.age_hours > MAX_BRANCH_AGE_HOURS: 45 metadata.is_stale = True 46 return metadata

BRANCH_PATTERNS encodes the taxonomy from the Concepts section: the subcategory tokens (prompt, training, gke, etc.) force engineers to declare intent in the branch name. MAX_BRANCH_AGE_HOURS = 48 is the policy knob — raising it relaxes the short-lived discipline. enforce_branch_policy composes the two checks; get_branch_age_hours reads the branch tip's committer date (ISO 8601) and returns hours as a float, returning None rather than raising if the branch is missing.

Code snippetpython
1import sys 2import subprocess 3from branch_validator import enforce_branch_policy 4 5def get_current_branch() -> str: 6 result = subprocess.run( 7 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 8 capture_output=True, text=True, 9 ) 10 return result.stdout.strip() 11 12def main() -> int: 13 branch = get_current_branch() 14 if branch == "main": 15 return 0 16 metadata = enforce_branch_policy(branch) 17 if not metadata.is_valid: 18 print(f"ERROR: '{branch}' does not match any allowed pattern.") 19 print("Allowed: feat/(prompt|model|pipeline|serve)-..., data/(training|eval|validation)-...,") 20 print(" fix/..., infra/(gke|helm|terraform)-..., deps/(renovate|dependabot)-...") 21 return 1 22 if metadata.is_stale: 23 print(f"WARNING: '{branch}' is {metadata.age_hours:.1f}h old; merge or delete.") 24 return 1 25 print(f"Branch policy OK: [{metadata.category}] {branch}") 26 return 0 27 28if __name__ == "__main__": 29 sys.exit(main())

The hook resolves the current branch with git rev-parse --abbrev-ref HEAD, short-circuits on main so CI merge commits to trunk are unaffected, and returns exit code 1 for either invalid names or stale branches. Install it as .git/hooks/pre-push (or via the pre-commit framework under the pre-push stage), and pair it with server-side branch protection rules that require squash merges for feat/*, data/*, fix/*, infra/* and allow merge commits exclusively for deps/*.

You'll know it works when (1) pushing wip-stuff is rejected with the allowed-patterns help text, (2) pushing a 50-hour-old feat/prompt-foo is rejected with the age in hours, (3) pushing a valid, fresh feat/prompt-foo prints the OK line, and (4) prompt_registry.json on trunk shows exactly one version bump per merged branch.

Do's and Don'ts

Do's

  1. Do squash-merge feat/data/fix/infra branches — collapses WIP commits into one bisectable change on trunk, so eval regressions point at a single prompt or data delta.
  2. Do allow merge commits exclusively for deps/* branches — Renovate and Dependabot embed structured changelog metadata in commit messages that audit tooling parses; squashing destroys it.
  3. Do bump exactly one prompt version per branch — keeps prompt_registry.json conflicts trivial and makes git log --oneline on trunk an accurate change history.

Don'ts

  1. Don't let any branch live past 48 hours — JSONL appends and prompt edits accumulate drift Git cannot auto-resolve; rebase and merge, or delete the branch.
  2. Don't force-push shared branches — rewriting history under teammates breaks their rebases and destroys the audit trail trunk-based development depends on.
  3. Don't bypass the pre-push hook with --no-verify on shared branches — the hook is the local enforcement layer; skip it and stale or off-pattern names reach the remote, leaving server-side branch protection as the only defense.

Keep going with GenAI Platform Engineering

Create a free account to track your progress and open this lesson in the full learning view. Subscribe to unlock the entire path — every goal, the hands-on labs, quizzes, and your verifiable skill graph — from . Cancel anytime.