September 29, 2024

My git Workflow for Refactoring

edit

If you’ve followed me for any significant amount of time, then you might know that Refactoring by Martin Fowler is the most influential book on writing software I have ever read. It fundamentally changed my approach towards writing code.

The book influenced me so greatly that it led to a change in my git workflow. Adopting the strategy I’m about to show you made it easier for me to refactor code, build upon that change, and get it reviewed quicker than ever. If you work in an organization that uses pull requests and code reviews, I believe this workflow will help you do the same.

The gist of my workflow is this:

  • Make a branch for only the refactoring work, submit the PR
  • Then, make a branch off of the refactoring branch, not main for your feature work, and set the base to the refactoring branch when you submit the PR

Some groundwork first

Before we get into the finer details of this workflow, I want to provide a little more context as to why I think it’s a good idea to make changes this way.

First, I think it’s a really bad habit to refactor and make changes in the same pull request. This practice is extremely common, but it comes with some major downsides:

  • It’s not really a refactor, by definition
  • It’s harder to differentiate between what was refactored vs. what was added, what was necessary to change and what wasn’t
  • It creates large PRs, which are more likely to be “rubber stamped” and therefore, more error prone

Second, I recognize that many organizations’ processes are designed to incentivize large PRs. Anything from compliance requiring separate tickets for every change (so you just stuff it all into one ticket), to the overwhelming pressure to pump out features. I understand there are factors working against you sometimes. My hope is that this strategy fits in that system, as it has for me.

So without further ado, let’s break this bad habit and explore this workflow now.

Our example

I figured it would be best to work through an example to demonstrate the process. Our codebase has a rudimentary Button component. It currently has two implicit variants, a primary and secondary style. Let’s look at how it was implemented.

const SHARED_STYLES = {
  borderRadius: 9999,
  fontFamily: 'sans-serif',
  padding: '0.35em 1em',
}

const PRIMARY_STYLES = { backgroundColor: 'blue', color: 'white' }
const SECONDARY_STYLES = { backgroundColor: 'lightGray', color: 'black' }

type Props = {
  children: React.ReactNode
  onClick: () => void
  isSecondary?: boolean
  type?: 'button' | 'submit' | 'reset'
}

function Button({
  children,
  onClick,
  isSecondary = false,
  type = 'button',
}: Props) {
  const variantStyles = isSecondary ? SECONDARY_STYLES : PRIMARY_STYLES

  return (
    <button
      onClick={onClick}
      style={{ ...SHARED_STYLES, ...variantStyles }}
      type={type}
    >
      {children}
    </button>
  )
}

We can use our Button component like so:

<Button onClick={() => {}}>Primary Button</Button>
<Button onClick={() => {}} isSecondary>Secondary Button</Button>

And it looks like:

Now that we know our starting point, let’s consider our task. We need to add a third variant, the danger button, without regressions. How should we do this?

Step 0: Write some tests

In the Refactoring book, Fowler makes it clear that a proper refactor changes the organization and structure of the code without changing its functionality. In order to verify that no functionality has changed, we need tests in place that can assert the correct behavior.

In the case of our buttons, we’d have functional tests ensuring the button worked when clicked, or didn’t when disabled, etc. We’d also be well served to have visual regression tests, perhaps with something like Storybook. So before we go refactoring, make sure you can verify that everything works exactly as it did before.

If you do need to write some tests, I recommend making this PR first. You can then build your refactor branch off of the testing branch, similar to how we’re going to make our feature branch off of our refactoring branch later on.

Step 1: Refactor

Assuming we have tests ready to go in our main branch, we’ll start by creating a branch off of main for our refactor:

git checkout -b refactor-button

Now that we’re on new branch, we can make our changes. Adding a danger variant will be very cumbersome if we have to deal with booleans. Booleans are a poor choice for representing finite states and could lead to weird situations that leak implementation details.

A better choice than an isSecondary prop, is a variant prop that takes a string. We’ll use TypeScript to define this string as a union of our variants, like so:

type Variant = 'primary' | 'secondary'

type Props = {
  children: React.ReactNode
  onClick: () => void
  type?: 'button' | 'submit' | 'reset'
  variant?: Variant
}

Now that we’ve added Variant to our types, we can make objects that use Variant as a key to create maps for our styles, like so:

const VARIANT_TO_STYLES: Record<Variant, React.CSSProperties> = {
  primary: { backgroundColor: 'blue', color: 'white' },
  secondary: { backgroundColor: 'lightGray', color: 'black' },
}

And we can now use that map in our component:

function Button({
  children,
  onClick,
  type = 'button',
  variant = 'primary',
}: Props) {
  const variantStyles = VARIANT_TO_STYLES[variant]

  return (
    <button
      onClick={onClick}
      style={{ ...SHARED_STYLES, ...variantStyles }}
      type={type}
    >
      {children}
    </button>
  )
}

From this point, we can git add our changes and commit them.

git add .
git commit -m "refactor button with variants"
git push origin refactor-button

After pushing our branch to remote, we can make a PR for our changes.

Step 2: Adding our danger variant

Before we make our additional changes, we’re going to make a new branch off of our refactoring branch. So ensuring we are currently on the refactor-button branch, we can then make our new on:

git checkout -b update-button-with-danger-variant

Our work is fairly straightforward from here. We’ll add 'danger' to our Variant type:

type Variant = 'danger' | 'primary' | 'secondary'

We’ll add danger styles to our style map:

const VARIANT_TO_STYLES: Record<Variant, React.CSSProperties> = {
  danger: { backgroundColor: 'red', color: 'white' },
  primary: { backgroundColor: 'blue', color: 'white' },
  secondary: { backgroundColor: 'lightGray', color: 'black' },
}

And we’re done! That’s literally all we have to do to add our new variant.

From here, we can add, commit and push our changes:

git add .
git commit -m "add danger variant to button"
git push origin update-button-with-danger-variant

Up to now, all of this work has been the same, but we need to make a key change with our PR. We need to change the base from the main branch to our refactor-button branch.

The Github PR interface with a circle and arrow pointing to the dropdown you would use to change the base for a PR.
Change the base to your refactoring branch

Take a look at the “Files Changed” tab when we do. Notice, only the changes we’ve made since the refactor are there.

The git diff in the PR of our addition
Notice the small diff because we changed the base

If we had kept the main branch as base, then we’d see changes from both the refactoring and our danger addition. This makes sense because we made our updates on a branch that was made from the refactor-button branch. But by changing the base, we’re able to only see the diff between the refactor-button branch and our current branch. We’ve shrunk the git diff considerably, and hopefully shrunk the time to review each PR considerably, too.

I should be clear, if there are any tests we need to add for our new feature, we should add them now as well.

Step 3: Review & Merge

At this point, we’ve submitted two PRs, which may seem like twice the work for code reviewers, but I would say it’s actually less than the amount of work of a single PR.

If both of these changes were in a single PR, you’d have to verify that both the refactor was done correctly, and that the addition worked correctly. There’s additional work of review buried in this integration.

When we split them into 2 PRs, they become simple verifications. The refactoring PR simply asks us to verify the tests continue to pass. If they do, then we’re ok (assuming the quality of the tests).

The second PR simply asks if we’ve implemented the new feature. It would be easy to spot if we made an unrelated or additional changes at this stage that need to be addressed.

In the review process, you may realize that there are updates you need to make to the refactoring branch. This is not a problem. Checkout your refactoring branch, make your changes, commit and push. Once you’ve done that, you can switch to your feature branch, and git rebase the refactoring branch on to it. This will move the commits of your feature branch to after the commits of the refactoring branch, maintaining the correct history. You can fix any conflicts that arise here, though they should be relatively small and few.

Once your PRs have been reviewed, merge the refactoring branch first. This will automatically update the base of your feature branch back to main, allowing you to easily merge that branch in as well.

Summary

Refactoring is a part of writing quality code, and while our processes might not make it as easy to do as we like, there are strategies we can take to make them as easy on ourselves and others to review.

If we can build the habit of making our refactoring PR first, and building our feature on top of that branch, we can start to make code changes and reviews easier for everyone we work with.

I hope you find this git workflow useful. Let me know if you or your team starts to adopt something like this.

Additional resources

While I believe they have recently changed their product offering a bit, their is a tool out there that can do a lot of this automatically for you. It’s called Graphite and I encourage you to check it out. I’m not the most persuasive person, so I’ve never tried to convince a team to try it, but I think it could be very useful to a team willing to adopt it.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Logo for Just Enough Functional Programming
Just Enough Functional Programming
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.