git rebase (not) --interactive
tl;dr: How to build a Node.JS script to re-write history. pre-requisites: Familiarity with
git rebase --interactive
.
Once upon a time, there was a Whatsapp clone tutorial was born. Since then, it has been through different incantations, but they all shared a common principle — the tutorial used git as a version control.
What’s special about this tutorial is its commits’ history — every commit represents a step in the tutorial; this way it’s very easy to navigate through it and reference a specific part of it.
For the end user it was all cakes and ale, but maintenance was hell. To put things simple, imagine you had the following git-log:
Step 100: description of step 100th
.
.
.
Step 3: description of third step
Step 2: description of second step
Step 1: description of first step
Now let’s say that you would like to remove step 2. The only solution for that would be using a
git-rebase --interactive
starting step 2 and save the following file:
reword xxxxxxx Step 100: description of step 100th
.
.
.
reword xxxxxxx Step 3: description of third step
This means that the editor process would have to be opened and closed 98 times (100 - 3 included), each time it does so we would manually have to change step n to step (n + 1). Do you understand now why it was a maintenance hell? I’ll save the explanation for myself.
The obvious question is — what if a script could do that for me? Followed by — how can I implement such a script?
Following that, I have wandered across git’s documentation and Stack Overflow and have found an
answer. Here’s the method which starts the editing process written in git-rebase--interactive.sh
,
a file in git’s implementation:
git_sequence_editor() {
if test -z "$GIT_SEQUENCE_EDITOR"; then
GIT_SEQUENCE_EDITOR="$(git config sequence.editor)"
if [ -z "$GIT_SEQUENCE_EDITOR" ]; then
GIT_SEQUENCE_EDITOR="$(git var GIT_EDITOR)" || return $?
fi
fi
eval "$GIT_SEQUENCE_EDITOR" '"$@"'
}
As you can see (or not), git looks for the editor’s file path in a global var named
GIT_SEQUENCE_EDITOR
and executes it with all the given arguments. Without getting into more of the
implementation, knowing nano
and vim
which are the most commonly used git-editors, the first
argument that their process accepts the edited file’s path, which makes total sense.
BUT! Why does the GIT_SEQUENCE_EDITOR
environment variable has to reference an actual text editor?
What if we set that to reference Node.js’ executable? Aha! JACKPOT!
Now, hypothetically instead of opening nano
or vim
and editing the file manually we can run
whatever manipulation we want on the file using a script and then once the process exists with no
errors (code 0) git will just proceed with the rebase as usual.
Using this principle, here’s a cool script that will remove a range of commits from the middle of the commits-stack:
#!/usr/bin/env node
const execa = require('execa')
const fs = require('fs')
const tmp = require('tmp')
// Main
const [anchor, amount = 1] = process.argv.slice(-2).map(Number)
gitRebaseInteractive(
anchor,
(operations, amount) => {
operations = operations
// Replace comments
.replace(/#.*/g, '')
// Each line would be a cell
.split('\n')
// Get rid of empty lines
.filter(Boolean)
// Commits we would like to drop
const dropOperations = operations
.slice(0, amount)
.map(operation => operation.replace('pick', 'drop'))
// Commits we would like to pick
const pickOperations = operations.slice(amount)
// Composing final rebase file
return [...dropOperations, ...pickOperations].join('\n')
},
[amount]
)
console.log(`Removed ${amount} commits starting ${anchor}`)
// Runs a git-rebase-interactive in a non-interactive manner by providing a script
// which will handle things automatically
function gitRebaseInteractive(head, fn, args) {
execa.sync('git', ['rebase', '-i', head], {
env: {
GIT_SEQUENCE_EDITOR: gitEdit(fn, args)
}
})
}
// Evaluates a script in a new process which should edit a git file.
// The input of the provided function should be the contents of the file and the output
// should be the new contents of the file
function gitEdit(fn, args) {
args = args.map(arg => `'${arg}'`).join(', ')
const body = fn.toString().replace(/\\/g, '\\\\').replace(/`/g, '\\`')
const scriptFile = tmp.fileSync({ unsafeCleanup: true })
fs.writeFileSync(
scriptFile.name,
`
const fs = require('fs')
const file = process.argv[process.argv.length - 1]
let content = fs.readFileSync(file, 'utf8')
content = new Function(\`return (${body}).apply(this, arguments)\`)(content, ${args})
fs.writeFileSync(file, content)
fs.unlinkSync('${scriptFile.name}')
`
)
return `node ${scriptFile.name}`
}
Using the code snippet above we can take an initial step towards solving the problem presented at
the beginning of this article by simply running $ git-remove.js
where anchor
represents a git
object and amount
represents the amount of commits that we would like to remove.
Sure, we still need to figure out which step we would like to remove by its index, and we need to take care of automatic rewording, but at least now you have the idea behind such method where you can solve problems like these as well as far more complex ones, with a little of creativity.
Join our newsletter
Want to hear from us when there's something new?
Sign up and stay up to date!
*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.