Create chbr (change branch) ruby-gem

September 28, 2024

chbr ruby ruby-gem how-to git

A small introduction


I had been using for some months a simple zsh alias, that utilized fzf to checkout between branches.

The alias is the following:

alias chbr='git checkout $(git branch | fzf | tr -d "*[:space:]|+[:space:]")'

Let’s add some details on this command:

  1. git branch returns all the local branches of current repository.
  2. fzf opens a UI in the terminal where you can type part of a branch name, and it filters the list based on your input.
  3. tr -d "*[:space:]|+[:space:]" is used to delete specific characters from the selected branch name. E.g. currently checked out branch has an asterisk (*) before each name, so in that case we want to remove the asterisk and keep only the name of the branch.
  4. Finally git checkout checks out to the selected branch.

Also, I had another alias to delete all of the local branches on current repository, because every now and then there are a lot of them. 😄

alias purgebr="git branch | grep -v 'master' | xargs git branch -D"

This one is a bit simpler:

  1. git branch lists all branches.
  2. grep -v 'master' means “invert match”. In simple words it filters out the line that contains master.
  3. xargs takes the output and runs git branch -D command on each branch.
  4. git branch -D <branch_name> force-deletes a branch.

This workflow had the pros that it was quite simple and helped me checkout between branches fast, but it had some cons as well. The main concerns I had are:

  • I did not have the automation to just delete one single branch.
  • I always forgot how the purgebr alias was named. I know, this one is silly, but that’s me! 🤣
  • I did not have confirmation when I was purging all the branches of the repository!

Also, let’s put in the equation, the fact that I had never built a ruby gem! And, of course, this was something that I really wanted to do! So, I had the thought to just create a simple chbr CLI tool that will do exactly what I wanted.

What I wanted to build exactly?


My requirements were simple. When users type chbr to the console a fzf panel opens. Users will be able to fuzzily search the branch they want and:

  • By pressing [ENTER] they will be able to checkout to the selected branch.
  • By pressing [X] they will be able to delete the selected branch.
  • By pressing [P] they will be able to delete all branches, except for the selected one.

Some extra (but important) notes:

  • When users try to delete the currently checked out branch, their action will be prevented and a nice message should appear on the screen to inform them.
  • Make sure, before purging all the branches to checkout users to the selected one!

Of course the language on which the tool was written is ruby! 😄

Implementation


First of all let’s talk about the contents of a ruby project, which we want to build as a gem! The project structure should be as:

chbr/
  bin/
    chbr
  lib/
    chbr.rb
  test/
    chbr_test.rb
  Gemfile
  Rakefile
  chbr.gemspec

All the core logic of the gem should exist in lib folder. On our occasion we have only one chbr.rb file. Which contains Chbr class that encapsulates all the logic of our program.

At the top of the file we require all the dependencies of our tool. Here we will just use:

  • git a nice wrapper of git actions with nice error handling.
  • optparse a terminal args parser that will make our lifes easier.
require 'git'
require 'optparse'

Now, let’s create our base class step by step. First of all, we want our class to load in memory the currently open repository! This can be easily done like this:

class Chbr
  DEFAULT_TIMEOUT = 5

  attr_accessor :repo_path, :repo

  def run
    Git.config.timeout = DEFAULT_TIMEOUT

    @repo_path = Dir.pwd

    begin
      @repo = Git.open(@repo_path)
    rescue ArgumentError => e
      puts "Error: #{e.message}"

      return
    end
  end
end

Now, let’s add some logic in our class to be able to checkout and delete branches (which are the 2 basic action that our program should perform.

class Chbr
 ...
  def delete_branch(branch)
    if @repo.current_branch == branch
      puts 'Cannot delete checked out branch'

      return false
    end

    repo.branch(branch).delete

    true
  end

  def checkout_branch(branch)
    @repo.branch(branch).checkout
  end
  ...
end

Now, that we have the ability to delete branches we can create a purge functions which will iterate through the branches of the repo and delete all of them, except for the one that was currently selected.

class Chbr
  ...
  def purge_branches(except)
    checkout_branch(except) if @repo.current_branch != except

    @repo.branches.each do |branch|
      next if branch.name == except

      delete_branch(branch.name)
    end
  end
  ...
end

Note: On all these 3 functions the input is the name of the branch (string).

Now, that we are able to perform all the actions that we want, we just want to create a open_panel function, that opens the panel and performs the selected action to the branch that users selected.

class Chbr
  ...
  def open_panel
    puts '[ENTER] to checkout branch, [X] to delete branch, [P] to purge branches'

    result = `
      git branch | fzf --bind "enter:accept,X:become(echo {}'%%%delete')+abort,P:accept+become(echo {}'%%%purge')+abort" --height 40% --layout reverse|
      tr -d "*[:space:]|+[:space:]"
    `

    return puts 'No branch selected' if result.empty?

    branch, action = result.split('%%%')

    begin
      case action
      when 'delete'
        puts "Are you sure you want to delete branch \"#{branch}\"? (y/n)"

        deleted = false

        deleted = delete_branch(branch) if gets.chomp == 'y'

        open_panel if @reopen_after_action && deleted
      when 'purge'
        puts "This action will delete all branches on this repository except for \"#{branch}\". Are you sure? (y/n)"

        purge_branches(branch) if gets.chomp == 'y'
      else
        checkout_branch(result)
      end
    rescue StandardError => e
      puts "Error: #{e.message}"
    end
  end
  ...
end

All magic is executed on this part:

result = `
      git branch | fzf --bind "enter:accept,X:become(echo {}'%%%delete')+abort,P:accept+become(echo {}'%%%purge')+abort" --height 40% --layout reverse |
      tr -d "*[:space:]|+[:space:]"
    `

So let’s add some context to it!

Backticks (``) call a system program and return its output.

>> `date`
=> Wed Sep 4 22:22:51 CEST 2013 

With backticks you can also make use of string interpolation.

Note: Using %x is an alternative to the backticks style. It will return the output, too. Like its relatives %w and %q (among others), any delimiter will suffice as long as bracket-style delimiters match. This means %x(date), %x{date} and %x-date- are all synonyms. Like backticks %x can make use of string interpolation.

So this means that what’s inside the backticks is executed to the terminal and we receive its’ result in the ruby context! :wow:

The only thing that changes here when comparing this bash command with the initial one (see introduction of the thread), is that here we are using some flags on fzf command. In more depth:

  1. --height 40% just makes sure we only utilize 40% of the space of the terminal, instead of the whole screen.
  2. --bind "enter:accept,X:become(echo {}'%%%delete')+abort,P:accept+become(echo {}'%%%purge')+abort". Here we are setting the key bindings in fzf panel. [ENTER] just accepts users’ selection, which means that the program outputs to ruby context just the name of the selected branch. On the other 2 occasions (for deleting and purging), instead of accepting the input of the user, we just change it by using become() function. In this function we just echo the name of the selected branch and then we add %%%<action_name> string to it. We use %%%, because this ensures that later when we try to split the branch of the name and the action that was selected we do not have any crazy bugs. Again if a branch name contains the string %%% it could create issues, but I believe that (initially) we are ok with it as it is! 😄
  3. abort ensures that at the end the program returns. If we do not add it, the fzf panel will never submit!

Then we create a parse_options function that parses terminals args that are passed in the program.

  def parse_options
    OptionParser.new do |opts|
      opts.banner = 'Usage: chbr [path_to_repository]'

      opts.on('-p PATH', '--path PATH', 'Path to the git repository') do |path|
        @repo_path = path
      end

      opts.on(
        '--disable-reopen',
        'Disable reopening the panel after an action is performed (Defaults to true)'
      ) do
        @reopen_after_action = false
      end
    end.parse!
  end

At first we have:

  • -p or --path which can set another dir. If none selected then by default current directory is selected.
  • '--disable-reopen', which disables reopening the panel after an action is performed (Defaults to true).

Now, we can put everything together in the run function. Also, we would like to create a class method run that would make easier the usage of the lib.

class Chbr
  DEFAULT_TIMEOUT = 5

  attr_accessor :repo_path, :reopen_after_action, :repo

  def self.run
    new.run
  end

  def run
    Git.config.timeout = DEFAULT_TIMEOUT

    @repo_path = Dir.pwd

    @reopen_after_action = true

    parse_options

    another_repo = @repo_path != Dir.pwd
    current_path = Dir.pwd

    go_to_repo(@repo_path) if another_repo

    begin
      @repo = Git.open(@repo_path)
    rescue ArgumentError => e
      puts "Error: #{e.message}"

      return
    end

    open_panel

    go_to_repo(current_path) if another_repo
  end

  def go_to_repo(path)
    Dir.chdir(path)
  end
  ...
end

Now the whole logic of the lib is done! We need to create under `bin/` folder our executable named `chbr`. It will just call our `chbr` library!

```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'chbr'

Chbr.run

While being inside the project folder run in terminal:

chmod a+x bin/chbr

Then create (or open) chbr.gemspec file and add the gem specs!

# frozen_string_literal: true

Gem::Specification.new do |spec|
  spec.name        = 'chbr' # The name of the gem
  spec.version     = '0.2.1' # The version of the gem
  spec.executables << 'chbr' # The executables of the gem (if any exist)
  spec.summary     = 'Change branch tool' # A simple summary of the functionality of the gem
  spec.description = 'A simple CLI tool to change branches in a git repositoy. ' \
                     'You can also delete local branches.' # A brief description of the functionality of the gem. This is visible from rubygems.org!
  spec.authors     = ['thestbar'] # The username of the author of the gem
  spec.email       = 'stavrosbarousis@gmail.com' # The email of the author of the gem
  spec.files       = ['lib/chbr.rb'] # Any files that should be included! Here we only have one lib file that should be included. You cannot add folders. You have to explicitly add files here!
  spec.homepage    = 'http://github.com/thestbar/chbr' # The homepage of the gem
  spec.license     = 'MIT' # The license of the gem
  spec.required_ruby_version = '>= 3.0.0' # The minimum supported ruby version

  # Here you can add some metadata for the gem
  spec.metadata['homepage_uri'] = spec.homepage
  spec.metadata['source_code_uri'] = spec.homepage

  # Here you can add some dependencies for the gem
  spec.add_dependency 'git', '~> 2.3'
  spec.add_dependency 'optparse', '~> 0.4'
end

Last thing, before building and pushing to rubygems your gem is to create the Gemfile

# frozen_string_literal: true

source 'https://rubygems.org'

gemspec name: 'chbr'

Build your gem


It cannot be easier! 😄

First you have to build the gem

gem build chbr.gemspec

Then you have to install the gem

gem install ./chbr-0.2.1.gem

Push your gem to ruby gems


First, you have to create an account on rubygems.org!

Then you should run from console:

gem signin

Here you should add your credentials and follow the instructions on the console!

Finally, you just push the gem!

gem push chbr-0.2.1.gem

Then, you can see your gem on rubygems.org, here! 😄

Verdict


chbr_preview

It’s really easy to build a ruby gem and then share it with other people! It’s also really important to write specs for the programs you create 😄

On this part we did not cover the specs, but in the future I will come back with an article about specs!

You can find the repository with the code here.

If you want more detailed information about creating/managing ruby gems you should read this documentation!