Roll you own git in go part 1: argument parsing using cobra
Since I don’t have the full picture of how git is structured in detail and what libraries in go are useful,gogito init this series will be step by step. In my opinion that’s the best way to tackle a topic that’s a little more complex than your average todo app. Get in there, pick the smallest thing and solve that. Once that’s done solve the next biggest thing and keep going. Let’s start at the basics: argument parsing.
First things first: my life story
jk… luckily this is not a food recipe blog.
Actually just wanted to talk about all the great resources that I am going to make ample use of. We’re standing on the shoulder of giants, as people keep reminding us.
I’m massively relying on the great resource that is Thibault Polge’s write yourself a git web book that covers the exact same topic in python. If you prefer python over go, head over there. It looks really well made so far. Big shout out!
There’s a great YouTube video Git Internals - How Git Works - Fear Not The SHA! that explains how git works in great detail. The aha! moment is indeed quite eye-opening.
Another great resource is this PDF that inspired the YouTube video above.
Let’s not forget the stellar git documentation under git-scm.com. You can also find a git web book there but I haven’t checked that out yet. Probably good, as it’s co-authored by Scott Chacon who wrote the fantastic Git internals book above.
Also I’m going to use ChatGPT for stuff like library recommendations and getting ideas for idiomatic go patterns while obviously comparing that against open source go code and the documentation. The go documentation in general seems really good, which is a big plus.
Enough with the chit chat. The project is not going to set itself up. I know it’s stupid, but I’m calling it gogito for the obvious Dragon Ball reference.
Let’s get it
mkdir gogito
cd gogito
go mod init github.com/pheuberger/gogito
I looked at the flag
package in the standard library and it looks pretty good but I’d like to do a little less work and get free support for subcommands like add
, commit
, status
etc. flag
seems to only allow --
and -
prefixed flags out of the box. So, I decided to use spf13/cobra
instead.
Let’s install the cobra package and CLI to get some code generation for free.
go install github.com/spf13/cobra/cobra
go get -u github.com/spf13/cobra
go install github.com/spf13/cobra-cli@latest
Now that that’s done we can initialize the project to use cobra argument parsing and even get a license setup for free.
cobra-cli init --license MIT
All we need to do now is to add a command. Let’s start in chronological order and add the init
command. init
has a bunch of extra parameters but I’m only going to support gogito init
without any arguments or gogito init path/to/repo
to keep it simple. Again, the goal of this is not to implement a complete git client, no. The goal is to implement enough to understand how it works.
cobra-cli add init
At this point the folder structure looks like this:
├── cmd
│ ├── init.go
│ └── root.go
├── go.mod
├── go.sum
├── LICENSE
├── main.go
└── NOTES.md
Okay nice, we have a cmd/
directory that will contain all of our commands. Now, I don’t want to mix command boilerplate with business logic so I’m going to put all of the code that delivers subcommand functionality into an internal/
directory. Specifically internal/subcommands/
.
Why internal/
? I don’t want to expose any of the subcommands as a separate package, so it wouldn’t make sense to put them in a pkg/
directory which has the expectation that these could be public at some point.
Let’s create init.go
inside of internal/subcommands/
.
package subcommands
import (
"fmt"
"github.com/pheuberger/gogito/internal/paths"
)
func Init(path string) {
panic("not implemented")
}
With that out of the way we can call this file from cmd/init.go
and also enforce parameters on the command.
- no parameters: assume the current directory
- one parameter: this must be a directory, otherwise our subcommand will panic
- more than one parameter: this is not how this subcommand is supposed to be run. Show them the usage hint.
package cmd
// cmd/init.go
import (
"github.com/pheuberger/gogito/internal/subcommands"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize a new git repository",
Long: `
description:
This command creates an empty Git repository - basically a .git
directory with subdirectories for objects, refs/heads, refs/tags, and
template files. An initial branch without any commits will be created.
Running gogito init in an existing repository is safe. It will not overwrite
things that are already there.`,
Run: func(cmd *cobra.Command, args []string) {
switch len(args) {
case 0:
subcommands.Init(".")
case 1:
subcommands.Init(args[0])
default:
cmd.Usage()
}
},
}
func init() {
rootCmd.AddCommand(initCmd)
initCmd.SetUsageTemplate("usage: gogito init [<directory>]\n")
}
git init path/to/repo
is actually pretty cool. It understands if you’re using an absolute path or if the path should be relative to the current directory you’re in. Let’s implement that as well and put the code into internal/paths/paths.go
.
package paths
import (
"os"
"path/filepath"
"strings"
)
// Converts any path to an absolute path. If the path is already absolute it
// will return the path as is. If it's a relative path to the user's home
// directory ~ will be expanded. Otherwise the path will be appended to the
// current working directory.
//
// Panics if user home directory cannot be inferred.
//
// Examples:
//
// input -> output
// path/to/thing -> working/dir/path/to/thing
// /absolute/path -> /absolute/path
// ~/foo/bar -> /home/username/foo/bar
func AbsFrom(path string) string {
if strings.HasPrefix(path, "~") {
if home, err := os.UserHomeDir(); err == nil {
return strings.Replace(path, "~", home, 1)
}
panic("could not get user home directory. is $HOME not set?")
}
// is there a way to directly pass the success path to the return without the assignment?
result, _ := filepath.Abs(path)
return result
}
filepath.Abs()
is fantastic. It pretty much single handedly solves our problem. It’s really just the expansion of the ~
that we need to take care of ourselves.
Time to whip out some tests. By the way, this challenge seems great for having unit tests and also end-to-end tests. Given a couple of commands in a certain order, we know exactly which files should be created and what they’re going to look like. Perfect!
package paths
import (
"os"
"path/filepath"
"testing"
)
func TestAbsFrom(t *testing.T) {
workingDir, err := os.Getwd()
if err != nil {
t.Fatalf("could not get current working directory: %v", err)
}
userHomeDir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("could not get user home directory: %v", err)
}
tests := []struct {
input string
expected string
}{
{"/absolute/path", "/absolute/path"},
{"relative/path", filepath.Join(workingDir, "relative/path")},
{"", workingDir},
{"./", workingDir},
{".", workingDir},
{"~", userHomeDir},
{"~/test", filepath.Join(userHomeDir, "test")},
}
for _, test := range tests {
result := AbsFrom(test.input)
if result != test.expected {
t.Errorf("AbsFrom(%q) = expected %q; got %q", test.input, test.expected, result)
}
}
}
Oooookay, this post runs pretty long already. Let’s give your wee eyes some rest and continue in part 2 where I’m going to implement a repository abstraction, config reading and writing using viper to finish up the init
command.
See you in the next one. Maybe (hide-the-pain-harold.jpg)