Learn Your Tools

  • Home
  • Back
  • Vim is a component among other components. Don’t turn it into a massive application, but have it work well together with other programs.

    Vim Reference Manual

    Git Rebasing

    I’m always working with Git. I use it at work, I use it for my personal projects, I even use it for this site. Git has become a part of my everyday life. One Git feature that I use quite often is Git-Rebase, which when invoked with the -i flag allows you to reorder-, combine-, and delete commits interactively.

    Let’s say you make two commits called ‘Fix various typos’ and ‘Add new blog post’ in that order. Now imagine you found another typo that you forgot to fix in your first commit. You might end up with a history like so:

    commit 1893588f4f57024098a538d471caeecd01fbe88d
    Author: Thomas Voss <[email protected]>
    Date:   Tue Nov 14 10:36:33 2023 +0100
    
        Fix another typo
    
    commit b592402d702379d14415c1becf18f6f40c2d6d76
    Author: Thomas Voss <[email protected]>
    Date:   Tue Nov 14 10:12:05 2023 +0100
    
        Add new blog post
    
    commit 6ae48a4d7a84fd4c76a08a110514754dd6949d46
    Author: Thomas Voss <[email protected]>
    Date:   Tue Nov 14 10:10:53 2023 +0100
    
        Fix various typos

    While for many people this might be fine, I personally find it much more clean to have the first and third commits merged into one commit, as they’re two parts of the same task. This is where Git-Rebase comes in. We can run git rebase -i HEAD~N where N is the number of commits back we want to include, which in this case would be 3. Running that command will open the following buffer in your text editor. In my case, Neovim.

    pick d620266 Fix various typos
    pick 59fa2b6 Add new blog post
    pick 4c45214 Fix another typo
    
    # Rebase 10c3013..4c45214 onto 10c3013 (3 commands)
    #
    # Commands:
    # p, pick <commit> = use commit
    # r, reword <commit> = use commit, but edit the commit message
    # e, edit <commit> = use commit, but stop for amending
    # s, squash <commit> = use commit, but meld into previous commit
    # f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
    #                    commit's log message, unless -C is used, in which case
    #                    keep only this commit's message; -c is same as -C but
    #                    opens the editor
    # x, exec <command> = run command (the rest of the line) using shell
    # b, break = stop here (continue rebase later with 'git rebase --continue')
    # d, drop <commit> = remove commit
    # l, label <label> = label current HEAD with a name
    # t, reset <label> = reset HEAD to a label
    # m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
    #         create a merge commit using the original merge commit's
    #         message (or the oneline, if no original merge commit was
    #         specified); use -c <commit> to reword the commit message
    # u, update-ref <ref> = track a placeholder for the <ref> to be updated
    #                       to this position in the new commits. The <ref> is
    #                       updated at the end of the rebase
    #
    # These lines can be re-ordered; they are executed from top to bottom.
    #
    # If you remove a line here THAT COMMIT WILL BE LOST.
    #
    # However, if you remove everything, the rebase will be aborted.
    #

    As suggested by the comments added to the bottom of the opened buffer, we can combine the two typo-fixing commits by simply swapping the 2nd- and 3rd lines, and then changing the second typo-fixing commit from a pick to a fixup:

    pick d620266 Fix various typos
    fixup 4c45214 Fix another typo
    pick 59fa2b6 Add new blog post

    After saving and exiting from your editor, the Git log should now only have two entries, which looks a lot cleaner.

    commit 74ba5ba2b8bdb7708a25283f03542811706072bd
    Author: Thomas Voss <[email protected]>
    Date:   Tue Nov 14 10:12:05 2023 +0100
    
        Add new blog post
    
    commit a893a1752b1365ec6437095506e6e63979b9236d
    Author: Thomas Voss <[email protected]>
    Date:   Tue Nov 14 10:10:53 2023 +0100
    
        Fix various typos

    The Problem

    This is fine and all, but it could be better. Specifically, it would be nice if instead of having to navigate to the front of the line, delete the word, and replace it with something new (such as fixup), you could just hit ‘f’ on your keyboard with your cursor on the right line and have it edit the command for you. Along with fixups, it would also be nice to be able to press ‘s’ for squash, ‘r’ for reword, etc.

    Seeing as the Git-Rebase interface is line-based with a simple syntax, you could probably easily do this with a regular-expression-based solution. I’m going to use Tree-Sitter though because it’s cooler, and I want to show off how easy it is to use.

    Writing The Plugin

    The first thing to figure out is where to put the plugin. Seeing as we only want it active when we’re performing a rebase, we can make use of the after/ftplugin directory. Configurations placed in this directory will only be applied when working in a buffer whose filetype corresponds to the filename. By running :set ft? in a Git-Rebase buffer we can see that the filetype is ‘gitrebase’, so with all that information we know that we can put our plugin in after/ftplugin/gitrebase.lua.

    The basic skeleton of the plugin is going to look like so:

    after/ftplugin/gitrebase.lua
    local function map(lhs, rhs)
    	vim.keymap.set('n', lhs, function()
    		--- TODO
    	end, {
    		buffer = true,
    		noremap = true,
    		silent = true,
    	})
    end
    
    map('p', 'pick')
    map('r', 'reword')
    map('s', 'squash')
    map('f', 'fixup')

    The map function defined here will create a normal-mode keybinding where pressing the key combination provided as the first argument will replace the Git-Rebase command of the current line with the string provided in the second argument. The actual function to perform this replacement isn’t implemented yet, so in its current state it will bind these keys to an empty function. We also pass a few options to vim.keymap.set; you can read more about these in :help vim.keymap.set if you’re interested.

    The first step to implementing the actual functionality of the plugin is to figure out where we are. We can do this very easily with the Neovim Tree-Sitter API:

    +local ts_utils = require('nvim-treesitter.ts_utils')
    +
     local function map(lhs, rhs)
     	vim.keymap.set('n', lhs, function()
    -		-- TODO
    +		local node = ts_utils.get_node_at_cursor()
    +		if node == nil then
    +			error('No Tree-Sitter parser found.')
    +		end
     	end, {
     		buffer = true,
     		noremap = true,

    The ts_utils.get_node_at_cursor() function will return to us the current node in the Tree-Sitter parse tree that our cursor is located at. In the case that we don’t have a Tree-Sitter parser available, the node will be nil and we can just issue an error.

    Before making any more progress, it’s a good idea to make sure you have a proper understanding of the structure of the gitrebase AST. You can view the AST by opening a new gitrebase buffer and running :vim.treesitter.inspect_tree(). I implore you to do this yourself, you’ll learn from it. The AST will end up looking something like this, followed by a bunch of (comment)s:

    (operation) ; [1:1 - 30]
     (command) ; [1:1 - 4]
     (label) ; [1:6 - 12]
     (message) ; [1:14 - 30]
    (operation) ; [2:1 - 30]
     (command) ; [2:1 - 4]
     (label) ; [2:6 - 12]
     (message) ; [2:14 - 30]
    (operation) ; [3:1 - 29]
     (command) ; [3:1 - 4]
     (label) ; [3:6 - 12]
     (message) ; [3:14 - 29]

    As you can see, each line is represented by an operation node which has three child nodes: a command, a label, and a message. You can probably begin to realize now that we’re going to want to get- and modify the command node on the line that our cursor is on.

    In the code above we got the node at our cursor, now we need to traverse the AST to the operation node. We can call the :parent() method on our node in a loop to traverse up the tree until we reach our target node. If our cursor isn’t on a valid line such as on a comment or a blank line we won’t ever hit an operation node and will instead get nil, so we need to handle that case too.

     		if node == nil then
     			error('No Tree-Sitter parser found.')
     		end
    +
    +		while node ~= nil and node:type() ~= 'operation' do
    +			node = node:parent()
    +		end
    +
    +		if node ~= nil then
    +			-- TODO
    +		end
     	end, {
     		buffer = true,
     		noremap = true,

    Now that we have the operation node, we simply have to get the child command node (which we know is the first child node), find out where in the buffer it is, and replace it. We can call the :child(0) method on our node to get the first child, and then call the :range() method on the child to get position in our buffer of the command node. The :range() method returns 4 values: the start row, start column, end row, and end column. We can then pass these positions to vim.api.nvim_buf_set_text() to set the text at the given position.

     		end
     
     		if node ~= nil then
    -			-- TODO
    +			local sr, sc, er, ec = node:child(0):range()
    +			vim.api.nvim_buf_set_text(0, sr, sc, er, ec, { rhs })
     		end
     	end, {
     		buffer = true,

    And that is the entire plugin! In just 28 lines of code (including whitespace) we implemented a plugin using Tree-Sitter to allow you to modify a Git-Rebase command with a single keystroke. The completed product looks like so:

    after/ftplugin/gitrebase.lua
    local ts_utils = require('nvim-treesitter.ts_utils')
    
    local function map(lhs, rhs)
    	vim.keymap.set('n', lhs, function()
    		local node = ts_utils.get_node_at_cursor()
    		if node == nil then
    			error('No tree-sitter parser found.')
    		end
    
    		while node ~= nil and node:type() ~= 'operation' do
    			node = node:parent()
    		end
    
    		if node ~= nil then
    			local sr, sc, er, ec = node:child(0):range()
    			vim.api.nvim_buf_set_text(0, sr, sc, er, ec, { rhs })
    		end
    	end, {
    		buffer = true,
    		noremap = true,
    		silent = true,
    	})
    end
    
    map('p', 'pick')
    map('r', 'reword')
    map('s', 'squash')
    map('f', 'fixup')
    Example Usage