Version control & branching
Every practice covered later in this course — CI/CD, deployment automation, even infrastructure as code — assumes a single, trustworthy record of what changed, when, and why. This page covers why git is that record, how the three dominant branching strategies trade off release speed against safety, and how feature flags and commit discipline turn a repository from a filing cabinet into an operational tool.
A shared Google Doc with full revision history is a decent model for what git gives a team: anyone can see exactly who changed what and when, nobody can quietly overwrite someone else's paragraph, and you can always jump back to yesterday's version if today's edit turns out to be wrong. Branching strategy is just the house rule for how many people are allowed to edit their own private copy before merging it back into the one document everyone reads from.
Why version control is DevOps' foundation
DevOps is often described as culture and automation, but both rest on one artifact: a version-controlled repository that is the single source of truth for the system's code and, increasingly, its configuration. Before distributed version control was standard, teams routinely lost track of which build was actually running in production, coordinated changes over email or shared drives, and had no reliable way to answer "what changed between last week's deploy and this one." Git — and the hosting layer around it (GitHub, GitLab, Bitbucket) — solves that by making every change a signed, timestamped, attributable commit with a full ancestry graph.
That ancestry graph is also the audit trail DevOps pipelines depend on operationally, not just historically. A CI system triggers off commits and tags; a deployment tool records exactly which commit SHA is live in each environment; an incident responder runs git log or git blame against the affected file to find the change and the author within seconds, not hours. Regulated environments (SOC 2, PCI-DSS, HIPAA) lean on this directly — reviewable pull requests and immutable commit history are frequently the literal evidence auditors ask for to demonstrate change control. None of this works if "the repo" is one of several conflicting copies; it works because the remote is authoritative and everyone's local clone is disposable.
This single-source-of-truth idea does not stop at application code. Configuration, infrastructure definitions, and pipeline definitions all move into git for the same reason code did: a diffable, reviewable, revertible record beats a wiki page or a person's memory. That extension is the subject of infrastructure as code, and the GitOps preview at the end of this page is the fullest expression of it.
Branching strategies: trunk-based, GitFlow, and GitHub Flow
A branching strategy is a team's answer to one question: how much work sits outside the main line before it merges back in? The three strategies below answer it very differently, and the right choice tracks a team's deployment frequency more than its size.
- Trunk-based development. Everyone commits to (or merges into) a single branch —
main— at least daily, often several times a day. Feature branches, when used at all, live for hours, not days, and are always small enough to review quickly. Work that isn't ready for users is shipped anyway, hidden behind a feature flag (see below). This is the strategy Google and Meta run internally at massive scale, and it's the strategy the DORA research (see measuring success) correlates most strongly with elite delivery performance — short-lived branches mean small diffs, which mean fast reviews and rare, easy-to-resolve merge conflicts. - GitFlow. Vincent Driessen's 2010 model, built around long-lived
developandmainbranches plus supportingfeature/*,release/*, andhotfix/*branches. A release branch is cut fromdevelop, stabilized independently, then merged into bothmainand back intodevelop. It was designed for software shipped in discrete, infrequent versions — think desktop installers or firmware with a formal release calendar. Applied to a service that deploys multiple times a day, GitFlow's long-lived branches accumulate drift, produce large, conflict-prone merges, and add process for a release concept (a "version") that continuous deployment doesn't really have. - GitHub Flow. The middle ground: one long-lived branch (
main, always deployable), short-lived feature branches cut from it, and a pull request that gets reviewed, tested by CI, and merged back — typically within a day or two. Nodevelopbranch, no scheduled release branches. It's simpler to teach than GitFlow and more structured than pure trunk-based work, which is why it's the default for most GitHub-hosted open source and web-service teams.
In practice the line between GitHub Flow and trunk-based development is a matter of branch lifetime and flag discipline, not tooling — a team practicing GitHub Flow with same-day merges and flags around incomplete work is doing trunk-based development. GitFlow's costs show up specifically at deploy frequency: it remains a defensible choice for shrink-wrapped software with real version numbers customers install, but it actively fights a team trying to deploy several times a day, which is why most cloud-native teams have moved away from it.
Branch lifetime is a proxy for merge risk. A branch open for two hours diverges from main by a handful of commits; a branch open for two weeks diverges by everything anyone else merged in that window. Trunk-based development's real claim isn't "no branches" — it's "branches short enough that merge conflicts stay rare and small."
Feature flags: decoupling deploy from release
Trunk-based development only works if incomplete features can live in main without being visible to users, and that's exactly what a feature flag (also called a feature toggle) does: a runtime conditional, usually backed by a config service or a flag-management platform (LaunchDarkly, Unleash, Flagsmith, or a homegrown table), that decides whether a given code path executes for a given user, cohort, or environment.
The distinction this creates — deploy versus release — is one of the more useful ideas in modern delivery. Deploying means the new code is running in production; releasing means users can see or use it. Without flags, those two events are forced to happen at the same moment, which is exactly why teams historically batched changes into big, risky releases. With flags, you can deploy dark code continuously — merged, tested, running, but switched off — and release it later with a config change instead of a deploy: flip the flag for 1% of traffic, watch error rates and latency, ramp to 100%, or roll back instantly by flipping it back off. No redeploy, no revert commit, no waiting on a build pipeline for the "undo."
Flags aren't free. Every flag is a live branch in the code that has to be tested (ideally both states), and flags left in place after a feature is fully rolled out become dead conditionals that make the code harder to read — teams that adopt flags heavily need a deliberate flag-cleanup habit, not just a way to create them. Used well, though, flags are what makes canary and progressive-delivery deployment strategies possible at the application level, on top of whatever the infrastructure is doing at the routing level.
A flag that's never removed is technical debt with a switch on it. Treat "ship the feature" and "clean up the flag" as two tickets, not one — a flag audited a year later, still gating logic nobody remembers the other branch of, is a common source of confusing production incidents.
Commit hygiene and Conventional Commits
A branching strategy governs how work merges; commit hygiene governs whether the resulting history is actually useful once it's merged. Two habits do most of the work. First, commits should be atomic — one logical change per commit, each one leaving the codebase in a working state, so git bisect can binary-search history to find the exact commit that introduced a regression without landing on a broken intermediate state. Second, commit messages should say why, not just restate the diff — "fix off-by-one in pagination cursor causing last page to drop" is useful in a blame six months from now; "fix bug" is not.
The Conventional Commits specification standardizes the first line into <type>(<optional scope>): <description>, with types like feat, fix, docs, refactor, test, and chore, plus a ! or a BREAKING CHANGE: footer for breaking changes. This isn't just style: tools parse it directly. semantic-release and similar tools read the commit types since the last tag to compute the next semantic version automatically (a fix bumps patch, a feat bumps minor, a breaking change bumps major) and to generate the changelog, removing a manual step from every release. It also pays off in the branching strategies above — a reviewer scanning a pull request's commit list gets a structured summary of intent before reading a single diff.
Trunk-based development in practice
The commands below show the shape of a real trunk-based change: a short-lived branch, one focused commit, a pull request opened and merged the same day, with the new code path sitting behind a feature flag so merging it changes nothing user-visible yet.
git checkout main && git pull
git checkout -b jt/checkout-retry-flag
# ... implement the change, gated by a flag ...
git add src/checkout/retry.ts
git commit -m "feat(checkout): add payment retry behind checkout-retry-v2 flag"
git push -u origin jt/checkout-retry-flag
gh pr create --fill --base main
# CI passes, one reviewer approves — merged same day
gh pr merge --squash --delete-branch
# code is deployed, flag stays OFF until the team is ready to release it
Notice what the flag buys here: the merge to main, the build, and the production deploy can all happen today, hours after the branch was created, with zero user-facing change — because checkout-retry-v2 is off. Release is a separate, later decision made in the flag system, not in git.
A preview of GitOps
Everything above treats git as the source of truth for application code. GitOps extends the exact same idea to infrastructure and deployment state: the desired state of a cluster or environment — which container images run, how many replicas, which config values — is described declaratively and stored in a git repository, and a reconciling agent (Argo CD and Flux are the two dominant tools) continuously compares that declared state against the live state of the cluster and converges the two, applying changes automatically or flagging drift when they diverge.
The practical effect is that a kubectl apply or a console click stops being how production changes — a pull request against the state repository is, which means every infrastructure change inherits the same review, audit trail, and revert-by-git revert properties this whole page has been describing for application code. This is the topic infrastructure as code picks up in full, including how declarative tools like Terraform and Kubernetes manifests fit into a GitOps-managed repository.
1. Why does git's commit history function as an audit trail, and what real-world requirement (beyond convenience) does that satisfy? 2. What is the core difference in branch lifetime between trunk-based development, GitHub Flow, and GitFlow, and why does that difference matter as deploy frequency increases? 3. How does a feature flag decouple "deploy" from "release," and what is the specific maintenance cost of leaving flags in place too long? 4. In GitOps, what is the reconciling agent actually doing, and what does it compare against what?
Check your answers
- Every commit is attributed, timestamped, and linked to its parent, so the full history of who changed what and why is preserved and reviewable. Regulated environments (SOC 2, PCI-DSS, HIPAA audits) frequently rely on reviewable pull requests and immutable commit history as literal evidence of change control.
- Trunk-based branches live hours; GitHub Flow branches live roughly a day or two behind a pull request; GitFlow keeps long-lived
develop,release/*, andhotfix/*branches around a formal release cycle. Shorter-lived branches diverge less frommain, so merges stay small and conflict-free — which is why GitFlow's overhead becomes a liability as teams move toward deploying multiple times a day. - A flag wraps new code in a runtime conditional, so the code can be merged and deployed to production while switched off, and released later by flipping the flag rather than shipping a new deploy. Left in place after full rollout, a flag becomes a dead conditional that clutters the code and can gate logic nobody remembers, which is why flag cleanup needs to be its own deliberate step.
- The reconciling agent (e.g. Argo CD or Flux) continuously compares the declared desired state stored in the git repository against the live state of the cluster, and applies changes (or flags drift) to converge the live state to match what's in git.