git rebase (not) --interactive

Eytan Manor
git rebase (not) --interactive - The Guild Blog

tl;dr: How to build a Node.JS script to re-write history. pre-requisites: Familiarity with git rebase --interacitve.

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’s 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,
    function (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 [].concat(dropOperations).concat(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).toString()
    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 bit of creativity.