Created: 5/15/2023
For about 2 months now, I've been using 1 development branch at work.
Yes, one. "git checkout jack
" -- that's it.
How did I get here, why the hell am I doing this, how does it work, and is it worth it?
jdevries3133/one_branch_workflow_demo is a tutorial for the git workflow I'll present here.
Git is a great thing, but there are some hard problems that used to seem inescapable;
This is how I got here. I wasn't satisfied with how any of this stuff was going.
I had to work on a big feature. Many thousands of lines when all was said and done. Lots of moving parts. Tight deadlines. Gotta go fast.
In the past, I used the feature branching strategy that we all know and love:
This works all right, after all, it is what most developers do on a day-to-day basis, so it can't be a totally broken workflow, but I'm sure everyone feels this pain:
But that's not so bad, you say, you need to finish the first form before starting the second one anyway! Incrementalism is good; this is fine.
Except reality actually looks like this:
This double-red time, where code review is happening, and you are also not wanting to continue work is the real killer. There are a few common places that developers go from here, but I'd argue they are all bad solutions, for different reasons.
Here's the easiest and most straightforward solution - when you have a unit of changes ready to ship, switch to the next task. The problem here is that if you practice incrementalism, you probably have a delivery-ready unit of changes every 1 hour on average. Nonetheless, you probably don't enter into the code review process once each hour because it's heavy and slow, and you know that if you do so, you'll be slowed down, so you don't.
A further problem is that I'd argue it's not healthy to conflate the process of spewing code from your brain and organizing code into change-sets. As programmers, we need to take steps to harness our mind's flow state. Distractions are poison, and even though you might not realize it, feature-branching is poisoning your flow!
Hm, I'm totally in the zone, but I'm starting to accumulate a lot of unstaged changes. Should I really start this refactor now, or should I wait for later. Oh look, there's a bug here - um, well, I won't fix it now, I'll just add it to my task list to die-- uh, I mean, I'm sure I'll get to it. Oh look, this formatting is messed up. Well, I don't want to bloat the diff, I'll just do that later.
That's a flow state about as smooth as black tar... What if, instead, you could forget about these concerns, and fearlessly change whatever the hell you want without screwing everything up?
This sort of works, but there should be some obvious drawbacks here. First, in the spirit of true incrementalism, every pull request should be as small as possible. As soon as you add 1 commit into one pull request that could have been separated, I'd argue you're making a mistake and undermining the likelihood that the change will be bug-free and successful.
More importantly, your reviewer is probably not a fan of this. It's possible to add commits to a certain extent, but at some point the reviewer does need to look at a stable set of changes, test them, and think about them. In other words, when you're in the midst of the code review process, that branch really doesn't belong to you, and you should keep it stable. Again, humans make tons of mistakes and this is about giving everyone the best possible chance of success. Your reviewer will miss more things if they're trying to review a moving target.
This is the best bad solution of them all. With a branching workflow, we can
have feature_part_1
, feature_part_2
, etc. If you add a commit to part 1
(code review feedback), you can simply merge it into part 2. When part 1 is
merged into master, you can merge master into part 2, and you'll be in a good
place.
The problem with branching strategies in git, though, is that they essentially treat history as immutable, and I make too many mistakes for history to be immutable. In the end, you're going to have tons of broken commits in your git history, because every little fix is going to require a follow-up commit, and the parent of that fix is a broken commit that will live on forever. If I did this, I'd have more bad commits in git history than good ones, at which point the history itself becomes useless. Not only that, but git history on the main branch will include tons of revisions that were never in production. Do those commits really matter for anyone after the code review is over?
If you want to be able to change history on your development branches, this approach does not work.
The good solution is to just use one branch. First, I'll do a deep dive on the mechanics in case you're skeptical that this is possible. Then, I'll go through the benefits. Here is what a day in the life of the one-branch workflow looks like:
grbpr
grbpr
is an alias I have in my shell; it maps to:
# "git rebase for a pull request"
alias grbpr='git rebase -i $(git merge-base main HEAD)'
If that's confusing, check out jdevries3133/one_branch_workflow_demo, where it's broken down in detail.
You are now free to work as chaotically as you always wanted. You can make temporary commits to save your work under a name, or, don't commit at all and just work! The process of code writing is now decoupled from the actual changes that are going to end up in code review, so it doesn't matter anymore, just have fun! Fix docs, refactor as you go, and of course work on new features. The unprecedented amount of freedom here is a main advantage to this workflow (but not the only one).
Whether you've made small commits along the way or built up a glob of unstaged changes, at some point you'll know it's time to take a step back and organize your thoughts into commits. This time, the commits you're making are headed for prod, so you might write some detailed commit messages and make final fixups as needed.
To get a commit into code review is a simple process;
grbpr
)Often, in response to code review, you'll need to make some additional changes. In this workflow;
grbpr
to reorder commits so that the fixup is adjacent to the commit
it's associated with, and squash the fixups into the feature commitThis step is a bit tedious, so I have some shell aliases to help me;
# for squashing fixups back into the commit they belong to
alias gfix='git add -A; git commit -m "fixup"; grbpr'
# "git sync" - for syncing the feature branch with your cannonical branch
function gs!() {
git reset --hard jack # insert your dev branch name here
grbpr
}
main
Technically, this happens continuously throughout the last 4 steps. To sync
your development branch with main
, just run git rebase main
. I do this
pretty much every time I blink.
Here, we encounter some more massive advantages to this workflow: visibility
over your own in-progress changes. Git will notify you when your commits have
merged into the main branch during rebase; it'll say something like, warning: skipped previously applied commit f899409
. You can git show f899409
to see
what that commit is. More importantly, if you do git log --oneline --graph
,
you can see all your in-progress work at a glance.
Now that the mechanics of the workflow have been spelled out, let's look back at the problems stated in the beginning, and review the advantages of this workflow.
git log --oneline --graph
allows you to quickly visualize the commits on your
dev branch, which encapsulates all of your in-progress changes
* 9173a83 (HEAD -> jack) feat: support for mermaid charts
* ef94456 fix: missing react key in navbar
* 3bb1a47 (origin/main, main) Merge pull request #63 from Code-the-Dream-School/newerRails
|\
| * ef5c1ed prettier on newer md files
| * c06b434 get correct rails files
|/
* c5a16f1 Merge pull request #61 from Code-the-Dream-School/fixRailsAgain
At a glance, I can see that ef94456
and 9173a83
are in-progress by looking
at the commits on my dev branch after main
.
Feature branches that you're not working on quickly become stale, and getting a
random ping from a coworker to deal with a merge conflict breaks you out of
your flow. With this workflow, every time you git rebase main
, you integrate
all of your changes with the main branch and immediately deal with any merge
conflicts that might have occurred. True, sometimes you'll forget to actually
update the downstream branch under code review, but that's now a quick and
simple process since you've already resolved the merge conflict.
Here's probably the raison d'etre for this workflow. You can send commits as small as you want as frequently as you want to code review without ever slowing down.
This was discussed in "Step 1: Absolute Chaos." You can think of your dev branch like a "work bench." It belongs to you, no one else will see it, and you can put whatever you want on there. This decoupling of your working state and the code review process means that you can stay focused on whatever you need to be doing without worrying about how it impacts the code review process.
If you work smaller you will move faster. I am an unapologetic CI/CD evangelist. There is research like the State of DevOps Report to support that high-performing engineering teams engage in CI/CD best practices, and that shipping small changes as frequently as possible yields compounding benefits.
More concretely, small and atomic commits are way easier to review. If you send your documentation changes in one commit, refactors in a few small patch commits, then the backend for your feature with integration tests, then the frontend for your feature with additional integration tests, each of those commits are going to be very easy and straightforward to review. If you did them all in one commit, it would be a nightmare.
Plus, if you did them all in one commit, the amount of time to get the changes into prod is the time it takes to author the change PLUS the time it takes to review. On the other hand, in the one-branch workflow, authorship and review mostly happens concurrently. The documentation changes are reviewed and shipped while you do the first refactoring. The first refactoring goes out the door while as you're finishing up the second, etc.
Sometimes you can almost separate a feature into 2 MRs, except that there's
some shared change that couples them together, and you're unable to achieve
separation, so you create a big heavy code review. Let's consider changes A
,
B
, and C
, where B
, and C
depends on A
;
A -> B -> C
B depends on A
C depends on A
In this workflow, A
goes into code review as soon as it's done, and you can
continue working to start B
. Certainly, until A
merges, you can't open
merge requests for B
or C
, but you can continuously work on top of your
previous changes, such that building up complicated interdependent sequences of
patches becomes a breeze.
Not only that, but if you then author D
having no dependencies, you can send
that to code review even though the interdependent cluster of A -> B
and A -> C
is in your dev branch.
In this workflow, I've had some staggering moments of understanding how awesome git can be. If I see a change that I don't want to pursue, I can just drop the commit. If I find a bug in a change, I can create a fix and squash back into a historical commit. If I need to, I can reorder commits. I can go back to an old commit and revise it, including splitting one old commit into 2 pieces! In this workflow, git's power as a version control system benefits you personally in your day-to-day work to control the chaos of your own in-flight changes.
You dev branch represents all of the code you've authored that is in a pending state. You can see it, and you can feel it. You can easily measure it's size. You can take action to make it smaller if it starts to become too big.
Surprisingly, the one-branch workflow is amazing albeit extremely counter-intuitive. Whether or not you adopt this specific workflow, be creative with your git branching strategies, and find the strategy that works for you. If you want to get more hands-on with the one-branch workflow, check out the companion repo to this blog post: jdevries3133/one_branch_workflow_demo is a tutorial for the git workflow I'll present here.