A solid Git workflow makes you a better developer. Your work stays more organized. It both teaches and reinforces proper software architecture. And it also makes bringing on new team members really easy.

It comes down to three things: branches, commits, and merges. Branches give every piece of work its own dedicated space so nothing interferes with anything else. Commits are a record of every change you make and why you made it. Merges are how finished work becomes part of the project. This article covers all three.

Thanks to Malek elsady, p4songer, and Loten from the Brainfart Studio Discord for the conversation that sparked this article.

Branch with Purpose

A branching strategy gives every piece of work a designated place to live. Instead of everything existing in one pile, work is separated by purpose: what’s shipped, what’s integrated, and what’s still being built.

The system I use has three levels: main, development, and feature branches. It’s intentionally minimal. Three levels is enough to get the benefits of a professional branching strategy without adding overhead that slows down a solo workflow.

Main: Merges Only

Main is your production branch. It holds the version of your project that is live and in the hands of your players. If you haven’t shipped yet, main stays empty until you’re ready.

You never work directly on main. Main should only change through a merge, and ideally that merge is always coming from development. Other branches can merge into main in specific situations (such as a bugfix). But every change still goes through the merge process. You are never writing code directly on main under any circumstances.

When main is merge-only, you always have a version of the project you can return to with confidence. It’s a clean line between “finished and shipped” and “in progress.”

Development: The Current State of the Project

Development is the most current, integrated version of everything in progress. It’s where finished feature branches land before they’re ready to ship. Think of it as the staging ground between active work and production.

Like main, you never work directly on development. Development is an integration point, not a workspace. Finished work gets merged here from feature branches. That discipline is what keeps the branch trustworthy as a stable foundation for new work.

Feature Branches: Where the Actual Work Happens

Feature branches are the only place you should be writing code or making changes. Every piece of work gets its own branch, created intentionally from whichever branch makes sense as its base, and merged back when it’s done.

Think about branches in terms of major systems or distinct pieces of work, not individual commits. A save system is a branch. A laser refactor is a branch. A bug in the player controller is a branch. You don’t need a new branch for every change you make inside a feature. The branch represents the scope of a piece of work, not each step within it.

Naming matters here. A good branch name has two parts: a prefix that categorizes the type of work, and a short description of what specifically is being done. That combination means the branch tells you what’s inside it before you ever open it.

Common prefixes:

  • feature/ — new functionality being added
  • bugfix/ — fixing something broken
  • refactor/ — restructuring existing code without changing behavior
  • art/ — asset work, if you’re on a team with a dedicated artist (also great for giving them a designated workspace)
  • docs/ — documentation updates

A well-named branch list is a live picture of what’s being worked on. You can look at it and immediately understand the state of the project without opening a single file.

Laser Beast: Actual Branch Structure

Here’s the full branch list from my game Laser Beast. Each branch represents a major system or piece of work, nothing smaller.

main
development
feature/save-system
feature/bootstrap
feature/pause-menu
feature/level-migration
feature/laser-button
feature/player-movement
audio/fmod
art/post-processing
art/player
art/tilemaps
art/game-objects

You can read the entire development arc of the project from that list. Core systems, art passes, audio integration, UI work. No branch name requires explanation.

Why This Structure Works

Here’s a concrete scenario. You’re mid-feature, something breaks, and you don’t know if the break came from your new work or something that was already there. Without branch separation, everything is in the same place and you’re guessing. With this structure, you’re not. Main is clean. Development reflects the last known working state. Your feature branch is where the new work lives, isolated from everything else.

That isolation also trains good habits. When every system gets its own branch, you’re forced to think about where one system ends and another begins before you write a single line. It both teaches and reinforces good software architecture.

On a team, that separation means multiple people can work in parallel without interfering with each other. A developer and an artist can work simultaneously without one blocking the other. And if you bring someone on mid-project, the workflow is already there. No cleanup, no retrofitting. The branches tell the story on their own.

Write Commits That Actually Mean Something

A commit is a snapshot of your project at a specific moment. But it’s also a record of a decision. The message attached to it is your chance to explain what changed and why it changed that way.

When that record is clear and consistent, your commit history becomes something you can actually use. You can scan it to understand the arc of a feature. You can find the exact moment a bug was introduced. You can roll back a specific decision cleanly, without affecting unrelated work.

Commit One Thing at a Time

One commit should represent one change. A logical unit is a single piece of work that can stand on its own. A new component. A bug fix. A refactor of one system. If you could describe it in one sentence, it’s probably one commit.

Building a laser system doesn’t ship as one commit. LaserCore comes first. Then the visual layer. Then each movement type as its own component. Then materials and color support. Each step is contained, labeled, and self-sufficient.

This keeps your history readable and your rollbacks precise. If one piece breaks something downstream, you can revert exactly that commit without touching anything unrelated.

The Prefix: What Kind of Change Is This?

Every commit starts with a prefix that categorizes the type of change. It makes your history scannable at a glance, before you even read the message itself.

The most common ones:

  • feat — a new feature or addition
  • fix — a bug fix
  • refactor — code restructured without changing behavior
  • docs — documentation only
  • chore — maintenance tasks, version bumps, dependency updates

When you’re reading back through your history, fix and feat entries tell a story. You can see the shape of development without opening any of the actual code.

The Message: What Did You Actually Do?

After the prefix comes a colon and a short message. One line. Specific enough that you know exactly what the commit contains without opening it.

Here’s the difference between a message that helps and one that doesn’t:

  • Vague: feat: add laser component
  • Specific: feat: add SweepingLaser component for dynamic direction control of LaserCore

The vague version tells you a laser component was added. The specific version tells you what type, what it does, and what system it extends. Six months from now, only one of those is actually useful.

Laser Beast: Actual Commit Log

Here’s the full commit history for the laser system branch in my game Laser Beast. This is what a focused, well-labeled feature branch looks like from first commit to merge.

* feat: add modular LaserCore system for scalable architecture
* feat: add LaserVisuals component with jagged line rendering for lasers
* refactor: custom offset snapping in GridAligner and prefab update for custom offsets
* feat: add SlidingLaser component and initial laser prefabs
* feat: add SweepingLaser component for dynamic direction control of LaserCore
* feat: add PulseLaser component for timed activation of LaserCore
* feat: add laser material and color support to visuals and prefabs
* feat: add end-point particle effects to laser visuals
* fix: ensure lasers render above background by adjusting material render queue
* feat: add directional laser prefabs with manual offset tuning
* fix: toggle laser particle effects alongside laser state
* chore: bump version number for laser system merge into development
* Merge feature/laser-system into development

Each commit is one thing. The prefix tells you what kind of change it was before you read the message. The message tells you exactly what was added or fixed. The whole history of the system is readable in fifteen lines.

When One Line Is Not Enough

Ideally, most commits are one line. But some decisions are complex enough that the message alone doesn’t capture the full picture. That’s when you add a body.

The body is not a summary of what the code does. The message handles that. The body is for why you made a specific call, especially when the reasoning isn’t obvious and the alternative might look like the simpler choice to someone reading it later.

Here’s an example from my game, Laser Beast. The pulse laser’s reset behavior has a non-obvious reason behind it:

feat: restart pulse cycle from delay on button release

When a button disables a pulse laser and the player releases it,
the laser restarts from the beginning of its start delay rather
than resuming mid-cycle. This preserves the stagger between
multiple pulse lasers in the same room. Resuming mid-cycle would
sync them up and collapse the pattern.

Without that body, the reset behavior looks like it could be simplified. With it, the reasoning is part of the record. Future contributors, or future me, don’t have to reverse-engineer the decision.

Merging and Pull Requests

Merging is how finished work moves from one branch to another. When a feature branch is done, you merge it into development. When development is ready to ship, you merge it into main. The code from one branch becomes part of another.

A pull request is the review step that happens before that merge. Instead of merging immediately, you open a request that shows exactly what’s about to change. You review it, confirm it looks right, then merge.

GitHub vs. Merging Directly

Git gives you multiple ways to merge a branch. You can do it from a terminal, from a desktop client, or through a platform like GitHub. The method you choose determines how much you can see before the merge happens.

Merging directly from a terminal or desktop client works. But you don’t get a review step before it happens. A stray change in a file you didn’t mean to touch becomes part of the project history. Reversing it later costs more time than catching it before the merge would have.

A pull request gives you that review step. And it’s worth doing even if you’re working alone.

What Is a Pull Request?

Pull requests are a platform feature, not a Git feature. GitHub, GitLab, and Bitbucket all support them. Git itself doesn’t.

A pull request is a proposal to merge one branch into another. Before the merge happens, you get a full diff: every file, every line added, every line removed. Conflicts are flagged. You can confirm the branch does exactly what the commits say it does before anything is finalized.

Most people think of pull requests as a team tool. One person writes code, another reviews it. A second set of eyes catches what the first one missed. That’s the common use case. But the value is in the review step itself, not in who does it.

Solo Code Review: Why It Works

Reviewing your own code sounds redundant. But it works because it forces a shift in perspective.

When you open a pull request, you stop writing code and start evaluating it. That shift is where you catch what you missed: a function that grew too large, a name that doesn’t hold up outside of context, a cleaner approach you couldn’t see while you were in it.

Doing that regularly reinforces clean code principles. You start writing better code the first time because you know you’ll have to evaluate it later.

Why This Workflow

This workflow is intentionally minimal. The branching structure and commit format follow the same logic: categorize the type of work, describe what it is, keep it focused. One system to learn, not two.

That simplicity is also what makes it scalable. Bring on another developer, an artist, or a composer, and the structure already supports parallel work. Each person works in their own branch. Nothing collides until it’s ready to merge. No cleanup, no retrofitting.

The habits it builds carry further than the project itself. Focused branches and intentional commits make you think like a software architect, not just a developer writing code.

It also sets you up for automation. Most tools that run tests or trigger builds automatically are designed to watch specific branches. Main gets a merge, a build triggers. Development gets a merge, tests run. The workflow you build now is the foundation that tooling builds on later.

This is a simplified version of a well-established branching model called Gitflow. Atlassian has a thorough breakdown of the full model if you want to go deeper. [Gitflow Workflow]

Start with branches. Write commits that mean something. Review your merges before they happen. That’s the whole system.

If you want to see this workflow applied from the first commit to the final merge, I’m walking through a full Pong project from scratch next Saturday. Every branch, commit, and pull request explained as it happens. [Link coming April 18]

Categories: Tutorials

2 Comments

Paul · April 14, 2026 at 4:33 pm

I think this is a very cool idea for a structure. The part about tooling and automation relying on separate branches in particular was a good wake up call since I keep thinking of more ways to automate with my current process. These are all great points that require strong arguments to contradict. Great job, and thanks for writing this!

    Mitchell · April 14, 2026 at 4:42 pm

    The automation point is actually the central reason to why I work this way. It’s easy to think of branching as an organizational habit (and it is). But it’s also the structure CI/CD tools are built around. Nice that it’s useful outside just keeping the project tidy.

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *