I spent six months on a firmware project where the Git workflow was the single biggest productivity killer. Not the compiler, not the debugger, not the hardware — the version control process. Merges took hours. Binary files bloated the repository. The CI pipeline ran for forty-five minutes on every push. We had four long-lived branches that diverged to the point where a clean merge was statistically unlikely.
I have since worked on over a dozen firmware projects across different team sizes, MCU families, and toolchains. The Git workflows I see most often in embedded are either cargo-culted from web development (GitFlow with long-lived release branches that make no sense for firmware) or completely ad-hoc ("just push to main when it compiles"). Neither works well in practice.
Here is the workflow I have settled on after years of trial and error. It is built for the specific constraints of firmware: binary assets, hardware dependencies, long CI cycles, and the need to reproduce an exact build for certification or field debugging.
The Branch Model: Trunk-Based with Short-Lived Feature Branches
The core of the workflow is a simplified trunk-based model with a single long-lived branch (main) and very short-lived feature branches. No develop, no release, no hotfix branches — all of those create merge overhead without meaningful benefits in firmware projects.
main ──●─────●─────────●──────────●────────●──
\ / / / /
f1●──● f2●────────● /
\ / /
f3●─────────●───────────●
Every feature branch lives for at most two to three days. If a feature takes longer, it is broken into smaller units. This is the single most impactful rule: no branch lives longer than 48 hours. Short branches merge cleanly, review quickly, and keep the team's mental model of the codebase accurate.
The rule I enforce with every team I join: if your branch is older than three days, you rebase it onto main, squash to a single commit, and push for review within the hour. No exceptions.
Binary File Strategy: Git LFS with a Strict Policy
Firmware repositories are uniquely bad at binary file management. Hex files, compiled libraries, CAD models, PDF datasheets, and bootloader images end up in the repo and turn every clone and fetch into a slow, painful operation. I have seen a firmware repo grow to 4.7 GB in under a year because someone committed compiled STM32Cube firmware packs.
Here is the policy I use:
- Binaries that change regularly (firmware release binaries, compiled test artifacts) — store in Git LFS with a
.gitattributesrule for every relevant extension:*.hex filter=lfs diff=lfs merge=lfs -text,*.bin filter=lfs diff=lfs merge=lfs -text,*.elf filter=lfs diff=lfs merge=lfs -text. - Binaries that should not be in the repo at all (datasheets, reference manuals, CAD files, compiled libraries from vendors) — store outside the repo in a shared drive or artifact store. Add an explicit
.gitignoreentry with a comment explaining why:# Vendor libraries — download from ST website. - Pre-compiled toolchain binaries — never commit them. Pin the toolchain version in a
CMakeLists.txt,Makefile, or Dockerfile instead.
I also add a CI job that fails if any commit introduces a binary file over 1 MB that is not tracked by LFS. This prevents the "someone committed a PDF by accident" problem that every firmware team encounters at least once.
Commit Convention: One Concern Per Commit
Firmware teams tend to commit everything at once — "added feature X, fixed bug Y, cleaned up formatting, updated linker script." This makes bisecting nearly useless and code review exhausting.
The convention I push for is simple: one concern per commit. If you touch a driver file and a linker script in the same commit, they should be part of the same logical change. If they are not, split the commit.
The commit message format I use:
component: brief description of the change Longer explanation if needed — what problem this solves, why this approach was chosen over alternatives. Fixes: #ISSUE_ID (if applicable) See also: SHA_OF_RELATED_COMMIT (if applicable)
Concrete examples from a recent STM32U5 project:
adc: add oversampling configuration for U5 series The STM32U5 ADC supports hardware oversampling up to 256x. This commit exposes the oversampling ratio and shift through LL_ADC_Init. The DMA buffer size is automatically adjusted when oversampling is enabled. See also: a3f8e21 linker: increase SRAM2 size to 64 KB for U5A5ZJ The default linker script allocated only 32 KB to SRAM2, which caused heap exhaustion during FreeRTOS startup on boards with 2 MB flash configurations.
CI Pipeline: Smart Triggers, Not Build-Everything
In web development, CI runs in under a minute. In firmware, a full build of a multi-target project can take fifteen to thirty minutes. Running the full suite on every push is wasteful and trains the team to ignore CI failures.
I structure the CI pipeline in stages with escalating triggers:
Stage 1 — Static analysis (always, ~2 minutes) ├── cppcheck on changed files ├── clang-format diff on changed files └── check for binary files >1 MB without LFS Stage 2 — Compile changed targets (on push to feature branches, ~5-10 min) ├── build only the targets affected by changed source files └── using a dependency analysis script (modified-files → affected targets) Stage 3 — Full build + tests (on merge to main, ~20-30 min) ├── build all targets ├── run unit tests on host (Ceedling/CMock) └── run integration tests on hardware if available
The key insight: Stage 2 uses a simple Python script that maps modified files to build targets by parsing the Makefile or CMake dependency graph. If you only changed an STM32G4 UART driver, the CI does not rebuild the STM32U5 target. This cuts the average CI time on feature branches from twenty-five minutes to under eight.
Release Tagging: Build-Reproducible Tags
Firmware releases are not like web deployments. You cannot roll back to a container image — you need the exact source + toolchain + configuration that produced the binary running in the field. This is especially critical for medical, automotive, and industrial applications where certification traceability is mandatory.
My tagging convention:
v1.2.3+build20260705-gcc12.3-cm4 │ │ │ │ │ │ │ └─ target architecture │ │ └─────────── toolchain version │ └───────────────────────────── build date └──────────────────────────────────── semantic version
Each release tag is accompanied by a release-notes-v1.2.3.md file that documents:
- Exact git hash and tag
- Toolchain version (including GCC ARM, newlib version, and any patches)
- MCU configuration (flash size, RAM config, option bytes)
- SHA256 of the produced .hex and .bin files
- Known issues and testing coverage
I also tag the build artifacts in Git LFS with the same release tag so they can be fetched later without rebuilding: git fetch --tags && git checkout v1.2.3+build20260705-gcc12.3-cm4 -- artifacts/.
Practical checklist
- Keep feature branches under 48 hours — rebase and squash older branches immediately
- Set up Git LFS for .hex, .bin, .elf files with explicit .gitattributes rules
- Add a CI gate that rejects untracked binaries larger than 1 MB
- Write commit messages in "component: action" format — one concern per commit
- Split CI into static analysis, changed-target-only, and full build stages
- Use build-reproducible release tags with toolchain and target info
- Document release artifacts with hash, toolchain, and MCU config
- Pin toolchain version explicitly — never assume "the latest GCC"
- Run automated bisects on regression:
git bisect start --first-parentavoids merge-commit noise
The framework I use
I have codified this workflow into a firmware-git-init script that I run at the start of every project. It creates the .gitattributes, .gitignore, CI configuration skeleton, and a CONTRIBUTING.md that explains the branching and commit conventions to everyone joining the team. Setting it up takes ten minutes; the time it saves in prevented merge conflicts alone pays for itself in the first week.
What has worked for me
The short-lived branch rule is the one that teams resist the most and that delivers the most value. Every time I have joined a team that said "our branches last two weeks because the feature is complex," the actual reason was that the feature was not decomposed into independent units. Once we enforced the 48-hour limit, the team naturally learned to split work better — and code quality improved because reviews were smaller, faster, and more focused.
The binary policy is a close second. Removing vendor blobs from the repository reduced clone time on one project from twenty-two minutes to under two. The team went from avoiding git clone to running it regularly for CI setup, which improved build reproducibility across the board.
Embedded firmware has unique constraints, but Git is not one of them. The workflows designed for web and mobile teams work in firmware too — you just need to account for the longer CI cycles and the binary files. The principles are the same: short branches, clear commits, automated quality gates, and reproducible releases.
📬 Comments / discussion
Prefer email: comments@carrese.eu — include the article URL so I can follow up. For corrections or deeper questions, I typically reply within 48 hours.