Posted Aug 16, 2026 at 2:06 PM
I’ve been wanting to add git integration to my site here for a long time so I could host my projects and use it as more of a project portfolio. I’m happy to say that I’ve finally done it. There is now a third link on that navbar on the top of the screen (or in the hamburger menu if you’re on mobile) which will take you to a page with a list of repos. From there you can click on any of the repos and browse around the code in the main branch. It’s obviously not complete yet, but it’s in a state where I felt comfortable deploying it to the public version of the site and doing this write-up about it. So without further ado, let me tell you how I did it.
I was surprised to learn that server-side Git is literally the same program you would install locally. There’s no special “server” binary or anything. You just use the same Git to create “bare” repositories which you can then push to/pull from via ssh. A bare repository is basically a repository without a work tree, and a work tree is all the files that you interact with when you’re working with Git; it’s all the files and stuff that Git tracks. This means that a bare repo is just the stuff in the .git directory. It doesn’t have your uncompressed project files, it has them in compressed form and all the revisions it needs to reconstruct past versions.
Neat! So I just set that up with a directory to store bare repos in and all the ssh permissions I need and… wait. If bare repos don’t have the project files how do I create a feature that lets you browse the project files?
Have you ever used commands like git ls-tree or git show? Well it turns out that those commands work perfectly fine in bare repos and are actually completely sufficient for building the limited repo browser currently running on this site. For those who haven’t used them before, here’s how they work and what they do:
git ls-tree is like the normal ls command but for Git repos. You can pass it a Git tree object such as a branch or a commit and it will show you a list of the files and directories in it. This is how I use it to let you browse my repos:
First I have a struct to represent Git objects. This just has the object type, either “tree” or “blob” for my uses, and the path to that object.
type GitObject struct {
ObjectType string
Path string
}
Then I have this function to get all the Git objects in a given tree and in a given path in that tree:
func (m *RepoModel) GetTreeObjects(repoName string, tree string, path string) ([]GitObject, error) {
It’s a receiver function because the RepoModel struct is what stores the directory the repos are kept in once it’s read from the environment variables.
The most important part of this function is here:
cmd := exec.Command("git", "ls-tree", "--format=%(objecttype):%(path)", treePath)
cmd.Dir = filepath.Join(m.RepoDir, repoName)
out, err := cmd.Output()
if err != nil {
return nil, err
}
This runs the command git ls-tree --format=%(objecttype):%(path) [treePath] in the repo’s base directory where treePath is just the tree and path parameters given default values if needed and combined with the correct syntax. The output of this command will look something like this (this is what I get when I run in the base directory of coffey.dad):
$ git ls-tree --format="%(objecttype):%(path)" main
blob:.gitignore
tree:cmd
tree:data
blob:go.mod
blob:go.sum
tree:internal
tree:migrations
tree:ui
As you can see, it tells me whether the object is a tree or a blob and then lists the relative path to that object. I formatted it in a way that makes it easy to parse, because by default the output looks like this:
$ git ls-tree main
100644 blob 862a7de62e52477a9b4f9862747db7634cc0b725 .gitignore
040000 tree 5d7cf9b478c5d1084c312e3ccddd2c371efea060 cmd
040000 tree 23a015fe13e8c83b8926571d930558eb9d643805 data
100644 blob 87b394fe838096f743df77e4af15d9c09a354e4b go.mod
100644 blob 08a636efcbbee8dd586048db265ee406bf3dc217 go.sum
040000 tree d73299f5535877ac33cf6a972fdc8626b09caa93 internal
040000 tree 5a46a89f057cdb19c671bfe5fa9f16a3d6884cca migrations
040000 tree eacee658f869542af9a7b5723615a12adadfd9e6 ui
Which has extra information I don’t need and uses a mix of tabs and spaces that I didn’t feel like dealing with. Fun fact: the option to use custom formatting was added sometime around Git version 2.36 and Debian 11 provides version 2.30, which caused me some headaches when I first deployed this feature. Had I known this in advance I would have just dealt with the default formatting.
Anyway, from there I can just process the output into a slice of GitObjects and then sort that slice with a custom function that sorts alphabetically with trees before blobs and return it for display:
objs := []GitObject{}
lines := strings.SplitSeq(string(out), "\n")
for line := range lines {
if strings.Contains(line, ":") {
parts := strings.Split(line, ":")
objs = append(objs, GitObject{ObjectType: parts[0], Path: parts[1]})
}
}
sort.Sort(ObjectSort(objs))
return objs, nil
}
That’s where git show comes into play. This command will output the contents of the specified blob object. Kind of like using cat in the incorrect way that we all do.
Here’s how that looks in the code, note that it’s very similar to how we use ls-tree:
func (m *RepoModel) GetBlobObjectText(repoName string, tree string, path string) (string, error) {
treePath := strings.TrimSpace(tree)
if treePath == "" {
treePath += "HEAD"
}
if strings.TrimSpace(path) == "" {
return "", errors.New("No path provided")
}
treePath += ":" + path
cmd := exec.Command("git", "show", treePath)
fmt.Println("Command: ", cmd.String())
cmd.Dir = filepath.Join(m.RepoDir, repoName)
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
This will usually run something like git show main:cmd/web/main.go which is a little more verbose than how you would normally use it – if you use it at all – but it’s important that I do it this way so that in the future I can do something like git show a1b2c3d:cmd/web/main.go to view the file at a specific commit. I’d show you the output, but it’s exactly what you expect. In fact, you can see it here.
That’s basically it for the repo browser back end. I also have a function so that I can create the bare repos from a web interface but you can imagine how that works and you can find the CreateRepo function here if you really want to see me call git init --bare [repoName].
I realize I neglected to talk about the front end and front end adjacent code in this article but the truth is that it’s not all that interesting. If you want to take a look you can find it in the repo. You’ll find the front end adjacent code if you do a case insensitive search for “repo” in this file and the actual HTML templates are in this directory. I had a lot of fun working on this feature on and off for the past couple weeks, and I’m glad to finally be able to use this site as somewhat of a portfolio. Thanks for reading!