Clean slate refactoring

October 1st, 2021
git

Clean slate refactoring

a slab of slate, clean of course

Thanks to my tinkering instincts and chronic indecisions, I’m terrible at is picking a technology and sticking with it. Which means that I’m quite prone to utter those dread words: “screw it, I’m going to rewrite it. FROM SCRATCHES!“.

But it’s rare that “from scratches” really means “from scratches scratches”. I’m likely to want to keep some files as-is. Most will be modified, sure, but I might still want to at least be inspired by the code I wrote before.

What to do, then?

One option is to create a new empty repository beside the old one, and manually copy (and alter) files from the old to the new.

Another is to create a new branch, and progressively convert the files within that branch.

Both options are… not optimal. With the side-by-side repos, as the transition goes on keeping track of what already has been done and what remains to be addresses gets harder and harder. As for doing in situ migrations, we end up with the old and the new all intermixed in what can easily turn into a huge confusing mess.

But wait, there is a third option! Leveraging some of Git’s features, we can follow a “clean slate refactoring” procedure that will keep for us a living laundry list of all that is left to do. Here, let me show you.

The use case: git-vaudeville

It’s better to show than to tell, so let’s pick a concrete example: git-vaudeville. This package is currently written in TypeScript. For giggles, let’s say I want to change it back to pure JavaScript. How would I go about it?

First step: ANNIHILATION!

I’m calling this “clean slate refactoring”, so let’s give ourselves a fresh new start. First by creating a work branch for our rewrite.

Earmarking the beginning of the rewrite branch. It’ll will come in handy later on.

$ git cob js-rewrite-start main

And now creating the refactoring branch proper.

$ git cob js-rewrite main

Oh yeah, small note: git cob is one of my aliases, here standing for checkout -b. I debated putting the long form of the commands instead. Unarguably be more pedagogical, but not as fun. In any case, I’ll point out when I’m using one of those yenzie specials, and all aliases used in this articles will be listed at the end — I might be a vain showoff, but I’m not a monster.

Back to our example. Let’s now remove everything from the working tree.

Remove everything. Spare no one.

$ git rm -fr .

Commit what will become our todo list.

$ git commit -m wip-clean-slate

Welcome to Tabula Rasa!

$ ls

<behold the void!>

That wip-clean-slate commit is going to be our todo list of files yet to be processed, and our end goal is going to be to turn it into an empty commit. Until then, at any time we can peek at it to see what is left to do.

$ git show --compact-summary :/wip-clean-slate
commit 98a520965f220e7c0a14b1c42f7573747fa86ace (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

 .gitignore (gone)                       |   4 --
 Changes (gone)                          |  24 -------
 README.md (gone)                        | 138 --------------------------------------
 dist/cli.js (gone)                      |   8 ---
 dist/colors.js (gone)                   |  12 ----
 dist/command.js (gone)                  |  38 -----------
 dist/commands/info.js (gone)            |  39 -----------
 dist/commands/install.js (gone)         |  56 ----------------
 dist/commands/run.js (gone)             |  31 ---------
 dist/commands/run.test.js (gone)        |  16 -----
 dist/hook.js (gone)                     |  67 ------------------
 dist/hook.test.js (gone)                |  12 ----
 dist/index.js (gone)                    |   8 ---
 dist/vaudeville.js (gone)               |  68 -------------------
 jest.config.js (gone)                   |   9 ---
 package.json (gone)                     |  55 ---------------
 src/@types/simple-git/index.d.ts (gone) |   3 -
 src/@types/yurnalist/index.d.ts (gone)  |   4 --
 src/cli.ts (gone)                       |   6 --
 src/colors.ts (gone)                    |  23 -------
 src/command.ts (gone)                   |  40 -----------
 src/commands/info.ts (gone)             |  43 ------------
 src/commands/install.ts (gone)          |  72 --------------------
 src/commands/run.test.ts (gone)         |  16 -----
 src/commands/run.ts (gone)              |  50 --------------
 src/hook.test.ts (gone)                 |   7 --
 src/hook.ts (gone)                      | 127 -----------------------------------
 src/index.ts (gone)                     |   2 -
 src/vaudeville.ts (gone)                |  91 -------------------------
 tsconfig.json (gone)                    |  66 ------------------
 30 files changed, 1135 deletions(-)

Keeping files wholesale

There are some files we want to keep from the old incarnation unchanged. Here, that’d be README.md and .gitignore.

There are a few ways to skin that cat. Here’s a simple one:

Add back the files we want to keep wholesale.

$ git checkout js-rewrite-start -- README.md .gitignore

fix is an alias for commit --fixup.

$ git fix :/wip-clean-slate

The big trick I’m using is the fix variant of the commit. Because with that, next time we do an interactive rebase, the fix will be merged with the original wip-clean-slate commit and it’ll be just like we never deleted those files at all.

After the commit we have two commits in our rewrite branch.

$ git log main...
commit 90f4ccfac68342e255ba2349d7728da935a412ea (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 15:45:10 2021 -0400

    fixup! wip-clean-slate

commit 98a520965f220e7c0a14b1c42f7573747fa86ace
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

We rebase interactively, retcon is my alias for rebase -i.

$ git retcon js-rewrite-start

The fix is now part of the (now altered) original commit.

$ git log main...
commit 68d13c32d97ad3de24dedf6a2cc4cb3075d0ef13 (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

And that new clean slate commit actually spared our two files.

$ git show --compact-summary :/wip-clean-slate
commit 68d13c32d97ad3de24dedf6a2cc4cb3075d0ef13 (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

 README.md (gone)                        | 138 --------------------------------------
 dist/cli.js (gone)                      |   8 ---
 dist/colors.js (gone)                   |  12 ----
 dist/command.js (gone)                  |  38 -----------
 dist/commands/info.js (gone)            |  39 -----------
 dist/commands/install.js (gone)         |  56 ----------------
 dist/commands/run.js (gone)             |  31 ---------
 dist/commands/run.test.js (gone)        |  16 -----
 dist/hook.js (gone)                     |  67 ------------------
 dist/hook.test.js (gone)                |  12 ----
 dist/index.js (gone)                    |   8 ---
 dist/vaudeville.js (gone)               |  68 -------------------
 jest.config.js (gone)                   |   9 ---
 package.json (gone)                     |  55 ---------------
 src/@types/simple-git/index.d.ts (gone) |   3 -
 src/@types/yurnalist/index.d.ts (gone)  |   4 --
 src/cli.ts (gone)                       |   6 --
 src/colors.ts (gone)                    |  23 -------
 src/command.ts (gone)                   |  40 -----------
 src/commands/info.ts (gone)             |  43 ------------
 src/commands/install.ts (gone)          |  72 --------------------
 src/commands/run.test.ts (gone)         |  16 -----
 src/commands/run.ts (gone)              |  50 --------------
 src/hook.test.ts (gone)                 |   7 --
 src/hook.ts (gone)                      | 127 -----------------------------------
 src/index.ts (gone)                     |   2 -
 src/vaudeville.ts (gone)                |  91 -------------------------
 tsconfig.json (gone)                    |  66 ------------------
 28 files changed, 1107 deletions(-)

$ ls -a
.git  .gitignore  Changes

Throwing files in the oubliettes

For files that we want to really discard, we want to extract their removal from the wip-clean-slate commit so that we know it’s not part of the todo list but a deliberate kill.

To do so, we can do a two-step dance where we bring the file back like in the previous section, and then kill it dead.

Bring the file back from the dead.

$ git checkout js-rewrite-start -- src/@types/simple-git/index.d.ts

Set that as an edit on the clean slate commit.

$ git fix :/wip-clean-slate

Now kill it fo’ realz.

$ git rm src/@types/simple-git/index.d.ts

$ git commit -m "don't need types anymore"

Rebase to merge the fix with the clean slate commit.

$ git retcon js-rewrite-start

The removal of the file is now in its own commit.

$ git log main...
commit 420c39c72e9f20e78e6cb5641345f99f4124b03e (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 16:34:38 2021 -0400

    don't need types anymore

commit 49c1d151ab3dcfec832243c3e963622ddccb8b59
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

A different approach would be to use something like my git resplit custom command (included at the end of the article), which does a rebase where all changes done to specified paths are extracted from the bigger commits.

Run the magic command.

$ git resplit js-rewrite-start src/@types

At this point the removal of src/@types/* is extracted from wip-clean-slate.

$ git log main...
commit 0f55dbb052b79bcf11453823b65431c6cc947dff (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 16:34:38 2021 -0400

    don't need types anymore

commit 9a3f41446a5995cb70c467e92a4340f6109d6afc
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 17:10:18 2021 -0400

    wip-clean-slate  (split src/@types)

commit d80c051c3e69992953f8953e433c595a5ecccefc
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

Now I can do an interactive rebase and, say, merge the split commit with the “don’t need types anymore” commit.

$ git retcon js-rewrite-start

We’re back to two commits.

$ git log js-rewrite-start...
commit 197e096d239c50b08ee07c08d024dcf37ecfa006 (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 16:34:38 2021 -0400

    don't need types anymore

commit d80c051c3e69992953f8953e433c595a5ecccefc
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 14:06:53 2021 -0400

    wip-clean-slate

And the type deletes are indeed together.

$ git show --compact-summary :/don
commit 197e096d239c50b08ee07c08d024dcf37ecfa006 (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Wed Sep 29 16:34:38 2021 -0400

    don't need types anymore

 src/@types/simple-git/index.d.ts (gone) | 3 ---
 src/@types/yurnalist/index.d.ts (gone)  | 4 ----
 2 files changed, 7 deletions(-)

Everything else in-between

Between the two extremes of keeping as-is and deleting, we have all the other files we want to keep with modifications. For those we’ll follow the same procedure: pluck them from the wip-clean-slate commit via a fix, then do the edits proper.

Pluck from wip-clean-slate.

$ git checkout js-rewrite-start -- package.json
$ git fix :/wip-clean-slate

Edit the file.

$ nvim package.json

Commit in the edits. And yeah, cm is another of my aliases.

$ git add package.json
$ git cm remove all typescript dependencies

Alternate picking using the stash

So far what we’ve done is starting from an empty plate, and incrementally picking the files from the wip-clean-slate. If you prefer to still have all files presents, we can also do that via our old friend the stash.

Bring all the files to the working tree, but don’t commit any of them.

$ git diff :/wip-clean-slate..js-rewrite-start | git apply

All the files are there, and yes, s is an alias.

$ git s
On branch js-rewrite
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	Changes
	jest.config.js
	package.json
	src/
	tsconfig.json

First make the ‘fix’ commit for wip-clean-slate.

$ git add src/commands
$ git fix :/wip-clean-slate

Do whatever you wanna do.

$ nvim src/commands/*

Commit those changes.

$ git add src/commands/*

$ git cm convert the commands to js

Before we rebase, we have to stash the untracked files.

$ git stash -a

$ git retcon js-rewrite-start

Now the files are out of wip-clean-slate and their changes are all in the last commit.

$ git log --summary -1
commit cfe0bf44f17cdbde9cda764ed70a2d23b605de4e (HEAD -> js-rewrite)
Author: Yanick Champoux <yanick@babyl.ca>
Date:   Fri Oct 1 11:02:59 2021 -0400

    convert the commands to js

 rename src/commands/{info.ts => info.js} (100%)
 rename src/commands/{install.ts => install.js} (100%)
 rename src/commands/{run.ts => run.js} (100%)
 rename src/commands/{run.test.ts => run.test.js} (100%)

Finally re-apply the stash to continue the good work…

$ git stash apply

Addendum: git aliases and commands

Git aliases

cob    = checkout -b
fix    = !f() { git commit --fixup ${1:-HEAD};  }; f
retcon = rebase -i
s      = status

git-resplit

#!/usr/bin/env fish

set -l base $argv[1]
set -e argv[1]

git rebase $base --exec "git split $argv"

git-split

#!/usr/bin/env fish

if test -z $argv
    echo "must provide at least one path"
    exit 1
end

set -l sha1 ( git rev-parse HEAD )

git reset HEAD^ -- $argv

if test -n ( echo ( git diff --name-only --cached ) )
    git commit --amend --no-edit
end

git add $argv

if test -n ( echo ( git diff --name-only --cached ) )
    echo (git log -1 $sha1 --pretty=%B) "(split $argv)" | git commit -F -
end

git-cm

#!/usr/bin/env perl

use 5.24.0;
use strict;
use warnings;

use List::MoreUtils qw/ first_index /;

my $index = first_index { $_ eq '--' } @ARGV;

$index = 0 if $index== -1;

push @ARGV, '--message', join " ", grep { $_ ne '--' } splice @ARGV, $index;

exec 'git', 'commit', @ARGV;
Seen a typo or an error? Submit an edit on GitHub!