Home Blog

I Wrote a Version Control System in Golang

Table of Contents

1. Introduction

Search for "Quick Links" to jump to Golang code samples and documentation.

I've tried a few different methods for managing dotfiles over the years. I was frustrated that they either required git and/or system links. I saw an opportunity to create a version control and file sharing system for single files with an emphasis on being user friendly.

Initially I spent some time learning how git worked. In summary: it models a directory of files as a tree of commits. A commit stores the changes between versions. This works well for a directory of related files where changes across multiple files should be bundled into the same commit.

For single unrelated files this isn't needed. If I only cared about a single file's history then I could make simpler system that avoided common pitfalls.

In summary I wrote a CLI tool and a web front end for sharing files between machines.

Introducing: dotfile and https://dotfilehub.com

2. Requirements

Here are the features that I wanted:

  • Save and restore past versions of files
  • Push and pull files via remote server
  • No merge conflicts
  • Easily install files on a fresh or foreign system without dependencies
  • Web application for finding other peoples files

3. CLI

Quick Links

The first step was designing a data structure for a tracked file. I decided to store the following:

  • The file's path
  • The current revision
  • A list of commits
  • An alias for the file

Commits are stored as list in chronological order. Each commit points to a full compressed revision of the file instead of the delta. This means commits are not dependent on each other, which avoids problems with merging unrelated histories. The downside is that is uses more disk space, though with small text files the difference is negligible.

I mapped aliases to files so that the user wouldn't have to memorize paths. When a file is initialized the user can choose to set an alias or take the default.

If ~/.config/nvim/init.vim was aliased to vim then the equivalent to the git command

git commit -am ~/.config/nvim/init.vim "<commit message>"

would be

dotfile commit vim

Much simpler!

4. Web

Quick Links

When it was time to implement push and pull I started on a web server.

I decided against using any major frameworks. At my work we use gin and gorm which I like, but for this project I wanted to explore the standard library further.

One of my goals was to make the UI simple and accessible enough that it would be possible to browse from basic browsers that don't support JavaScript. That way users could still find files in an environment without graphics using something like lynx. This led me to learn more about using semantic http and how server side rendering can be leveraged.

Another goal was to make files always available without dependencies. I added a special case to the file page to return plain text depending on the Accept header. So if you visit https://dotfilehub.com/knoebber/vim in a browser it will return HTML, but if you pass that url to CURL it will return the raw file.

This allows me to download my vimrc anywhere:

curl https://dotfilehub.com/knoebber/vim > ~/.vimrc

Database

I chose sqlite because of its excellent documentation, performance, and ease of use. Honestly I couldn't recommend it enough.

I wanted the database to use the same interface that the CLI uses on local file systems for modifying files. This would let it reuse code for operations like init, commit, checkout, diff, etc. I made interfaces Reverter, Commiter, Getter in package dotfile to accomplish this.

Next I designed the schema. The tables were users, reserved_usernames, sessions, files, temp_files, and commits.

I built some functions up to make common database tasks easier. The main component is the Executor interface:

// Executor is an interface for executing SQL.
type Executor interface {
        Exec(string, ...interface{}) (sql.Result, error)
        Query(string, ...interface{}) (*sql.Rows, error)
        QueryRow(string, ...interface{}) *sql.Row
}

Then I made every database function use a signature like:

// File retrieves a file record.
func File(e Executor, username string, alias string) (*FileRecord, error)

The advantage is that e can be both a plain database connection or a transaction.

Finally I made a function to generalize inserting records:

type inserter interface {
        insertStmt(Executor) (sql.Result, error)
}

type checker interface {
        check(Executor) error
}

func insert(e Executor, i inserter) (id int64, err error) {
        if err = validate.Struct(i); err != nil {
                log.Print(err)
                return 0, usererror.Invalid("Values are missing or improperly formatted.")
        }

        if c, ok := i.(checker); ok {
                if err := c.check(e); err != nil {
                        return 0, err
                }
        }

        res, err := i.insertStmt(e)
        if err != nil {
                return 0, err
        }

        id, err = res.LastInsertId()

        if err != nil {
                return 0, err
        }

        return id, nil
}

This validates the struct's data, optionally does a check, inserts the record, and returns the id of the new record.

Router

I considered building my own router, but after some research I decided that it would take too much time to implement the features that I wanted. I went with gorilla mux for routing and gorilla handlers for logging middleware. I like these because they use the net/http.HandlerFunc signature.

Templates

I made all the views with go templates. I used something close to a MVC style. First I made a struct for passing data to templates:

// Page renders pages and tracks request state.
// Exported fields/methods may be used within templates.
type Page struct {
        Title          string
        SuccessMessage string
        ErrorMessage   string
        Links          []Link
        Vars           map[string]string
        Data           map[string]interface{}

        Table        *db.HTMLTable
        Session      *db.UserSession
        templateName string
        htmlFile     string
        // Page access is restricted to their owners when true.
        protected bool
}

With this setup individual handlers look like:

func loadCommits(w http.ResponseWriter, r *http.Request, p *Page) (done bool) {
        alias := p.Vars["alias"]
        commits, err := db.CommitList(db.Connection, p.Vars["username"], alias, p.Timezone())
        if err != nil {
                return p.setError(w, err)
        }

        p.Data["commits"] = commits
        p.Title = "commits"
        return
}

Setting p.Data["commits"] = commits makes it available to the go template. In Ruby on Rails this might read: @commits = User.commit_list.

5. Conclusion

I'm happy with Dotfile overall. I find it to be useful for more than just dotfiles. It's sort of like pastebin with a CLI and versioning. I like being able to track and share any random file without headache. Here are the files that I've pushed: https://dotfilehub.com/knoebber

Obviously I have bias - lot's of people create their own system for managing their files, and I'm not suggesting that anyone move to this. It works for me, but I encourage everyone to find their own way.

  generated with    29.0.50 9.5.2 on 05/12/22