Building a Git CLI tool using Go
I recently ventured into a new area for me… building my own CLI tool😱. My inspiration for this was that I wanted to learn how to build one from scratch, but also to solve a specific, small problem 🤏. I currently alias my git commands in a bashrc file (read my article Git Aliasing in your terminal) and having multiple versions across different machines means they easily go out of sync. To solve this, I needed to consider:
- How to keep the files across machines synchronised 🔗
- How to maintain a single source of truth instead of multiple 🗃️
- How to make it fairly easy to access 🗝️
Planning
External Libraries 📚
I could have utilised Cobra or Chalk for this project; however, I wanted the extra challenge of not relying on external libraries, as I wanted to deepen my understanding of how colourising happens in the terminal, or being able to have selection-based properties. Having said this, I highly recommend using a library to enrich the experience for yourself if you decide to build your own CLI tool, provided you understand how colourising works in the terminal etc.
Tech Used 🛠️
After a bit of research into this, I found that a recommended language for developing a CLI tool was Go and since I had never developed using it I thought it would be fun.
Building The Tool🏗️
File Structure 📂
Below is the tree view of the folders/files created for this project:
Root
│ .gwarc.json
│ go.mod
│ Makefile
│ README
│
├───bin
│ gwa
│ gwa-linux
│ gwa-mac-amd64
│ gwa-mac-arm64
│ gwa-windows.exe
│ gwa.exe
│
├───cmd
│ └───gwa
│ main.go
│
└───internal
├───cli
│ flags.go
│
├───config
│ config.go
│
├───git
│ git.go
│
└───ui
colors.go
input.goThe breakdown goes somewhat like this:
bin directory:
This is where the pre-compiled outputs reside from running the go buildcommands on the project, allowing you, as the user, to plug and play with this repository on your machine.
cmd directory:
This is where the main application's entry point lives, and from here, the tool calls upon one of many commands that I can build up, currently it has only four:
| Command | Description |
| ---------------- | ------------------------------------------ |
| `feature <name>` | Create a new feature branch |
| `commit` | Create a conventional (interactive) commit |
| `status` | Show enhanced git status |
| `help` | Display help and usage information |and looks a bit like this — you open the terminal and run a command such as gwa status which will look at the CLI tool, parse the input using os.args[1] which is looking at the first argument passed to the command (in this case it is status from the gwa status input). It will then go through a switch statement that defines what function to call on based on the command passed, and provides the rest of the arguments passed in (the [2:] means give me everything from the 2nd arg, to the end of the list). We have a backup, which is if an unknown command is passed in, then print an error for the command the user input and call the usage command to display instructions for using this CLI tool.
command:= os.Args[1]
switch command {
case "feature":
handleFeature(os.Args[2:])
case "commit":
handleCommit(os.Args[2:])
case "status":
handleStatus(os.Args[2:])
case "help", "--help", "-h":
printUsage()
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
os.Exit(1)
}with the handleStatus command being called, we are now diving over to the imported git.go from internal/git in the above tree view, and along with checks to ensure we are in a git repo, we call a function as shown below:
func GetStatus() (string, error) {
return ExecuteGitCommand("status", "--short")
}we can see that this is returning a call to ExecuteGitCommand with the arguments status and --short which is a function designed to make Git requests:
func ExecuteGitCommand(args ...string) (string, error) {
cmd := exec.Command("git", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err:= cmd.Run()
if err != nil {
return "", fmt.Errorf("git error: %s", stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}The ExecuteGitCommandfunction runs Git commands from Go and returns their output.
What do I mean, well:
- It takes Git arguments (“status”, “ — short” in our example) and runs them as a git command.
- Captures both standard output and error messages
- Returns the output on success, or an error with the error message on failure
Get Scott Maitland’s stories in your inbox
Join Medium for free to get updates from this writer.
Why it’s useful:
Instead of repeating command execution and error handling, other functions can call this wrapper (such as the one to create a new branch or make a commit). It centralises Git command execution, making the code easier to maintain and test.
The Result ✅
The end result was that I built a CLI tool that captures the essence of assistance with my common day-to-day Git commands, ensuring it is versionable and easily maintainable with a single, highly accessible source of truth located right in my GitHub. Will I use it day-to-day? yeah I think I could but in all honesty, I will most likely continue to use and learn the git commands more intricately and build upon this tool as I become more comfortable with GIT itself.
Next Steps For Me
This was a really fun dive into building a CLI tool, my plan will be to:
- Ensure it has robust unit testing (I admit, I went rogue and just had fun with this with taking a TDD approach).
- Integrate Cobra and explore how adding a third party library will enhance this project.
- Refining the UI, again more third party libraries do it much better out there and it will be an educational and interesting dive into these tools such as
Chalkor another one I recently heard of wasbubbletea - I would like to remove the user having to enter a number to make a selection, and look at interactive list selection stuff which I hope the third party libraries above will help with, and I may look into how to build this myself for the fun of it.
Thanks for reading! 😃👋







