September 28, 2024
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:
git branch
returns all the local branches of current repository.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.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.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:
git branch
lists all branches.grep -v 'master'
means “invert match”. In simple words it filters out the line that contains master
.xargs
takes the output and runs git branch -D
command on each branch.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:
purgebr
alias was named. I know, this one is silly, but that’s me! 🤣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.
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:
[ENTER]
they will be able to checkout to the selected branch.[X]
they will be able to delete the selected branch.[P]
they will be able to delete all branches, except for the selected one.Some extra (but important) notes:
Of course the language on which the tool was written is ruby
! 😄
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:
--height 40%
just makes sure we only utilize 40% of the space of the terminal, instead of the whole screen.--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! 😄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 = '[email protected]' # 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'
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
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! 😄
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!