DEV Community

Cover image for git rebase (not) --interactive
TheGuildBot for The Guild

Posted on • Edited on • Originally published at the-guild.dev

git rebase (not) --interactive

This article was published on Monday, September 3, 2018 by Eytan Manor @ The Guild Blog

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" '"$@"'
}
Enter fullscreen mode Exit fullscreen mode

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}`
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)