Skip to article content Blogs

Building a GitHub Backed Pastebin CLI in Go

Why I stopped using bloated note apps and built a local-first, autosaving terminal tool for my daily snippets.

Hands feeding colorful code cards through a compact versioning machine

Summary

pb is a small CLI I built in Go to save terminal snippets as plain files and sync them through a private GitHub repository. It works offline, autosaves without filling the history with junk commits, and keeps both copies when two machines disagree.

Why I built it

There is a specific frustration that shows up when you are deep in the terminal and need somewhere to drop a snippet. My day at ValueLabs is distributed systems, tracing logs, and debugging backend logic, and when I am knee-deep in Python and Django configuration, switching context is the enemy.

Opening a browser to paste a JSON payload or a stack trace into some web pastebin breaks that flow. It is also a privacy risk for proprietary code. Note-taking apps have the same problems for this job: they make you leave the terminal, they eat memory, and they lock your text into their own format.

So I wrote down what I actually wanted. It had to work offline. It had to autosave, so closing the terminal by accident never costs a draft. It needed a history of changes. And it had to store everything on infrastructure I already trust.

Go for the binary, GitHub for the storage

My daily stack is Python and Next.js, but I wanted this to be fast and to ship as a single executable, and Go is the right tool for that.

Instead of spinning up a database to hold a few text files, I used something I already had: GitHub, a free, version-controlled backend. pb uses the official GitHub CLI for authentication, so I did not have to build token storage. The first time you initialise it, it creates a private repository called pastebin-cli-store on your account, and that repository is the backend for every snippet.

The local-first snippet lifecycleEditing and autosave stay local. An explicit save creates a durable version and an explicit sync sends it to a private GitHub repository.explicit saveno clashboth sides changed

Terminal editor

Recovery snapshot (autosave)

Local journal

Durable version

Explicit sync

Private GitHub repository

Keep a conflict copy

The local-first snippet lifecycleEditing and autosave stay local. An explicit save creates a durable version and an explicit sync sends it to a private GitHub repository.explicit saveno clashboth sides changed

Terminal editor

Recovery snapshot (autosave)

Local journal

Durable version

Explicit sync

Private GitHub repository

Keep a conflict copy

The local-first snippet lifecycle. Editing and recovery stay on my machine. Versions and syncing only happen when I ask.
  1. Open or create a snippet in the terminal editor.
  2. Background autosave writes a local recovery snapshot.
  3. A journal tracks file metadata and pending operations.
  4. An explicit save creates a durable version.
  5. An explicit sync reconciles it with the private GitHub repository.
  6. If both sides changed, a conflict copy is kept.

This is what people mean by local-first. The file on my machine is the real copy. GitHub is where it gets synced, not where it lives.

Autosave without junk history

Building a synced CLI is trickier than it looks, and autosave is where it starts. If every keystroke became a commit, the history would be useless. If nothing was saved until I asked, one closed terminal would lose the draft.

pb splits those two promises. While I type in the built-in editor, it writes recovery snapshots in the background, on my machine only. Its local data lives under the user configuration directory (Go’s os.UserConfigDir), where a journal tracks file metadata and pending operations. A durable version, the kind that reaches GitHub, is only created when I explicitly save or sync.

Five layers of snippet durabilityThe editor buffer feeds a recovery snapshot, accepted working file, Git commit, and remote repository. A journal records relationships and pending work across the layers.describesdescribesdescribes

Editor buffer
survives nothing

Recovery snapshot
survives process crash

Working file
accepted local content

Git commit
intentional version

Remote repository
off-device copy

Journal
state + identity

Five layers of snippet durabilityThe editor buffer feeds a recovery snapshot, accepted working file, Git commit, and remote repository. A journal records relationships and pending work across the layers.describesdescribesdescribes

Editor buffer
survives nothing

Recovery snapshot
survives process crash

Working file
accepted local content

Git commit
intentional version

Remote repository
off-device copy

Journal
state + identity

Five layers of durability. Each layer makes one promise and survives one kind of failure.
  1. The editor buffer contains the current in-memory text.
  2. A local recovery snapshot protects recent edits from a process crash.
  3. The working file contains content the user explicitly accepted.
  4. A Git commit creates a named durable version and history.
  5. The private remote repository provides an authenticated off-device copy.
  6. The journal records how these layers relate and which work remains pending.

The safe way to write a snapshot like this is an old trick: write to a temporary file, flush it, then rename it over the previous one. A crash leaves either the old snapshot or the new one, never half of each. The code below is that pattern in outline, not pb’s exact source.

func SaveRecovery(path string, body []byte) error {
    tmp := path + ".tmp"

    file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
    if err != nil {
        return err
    }

    if _, err = file.Write(body); err != nil {
        file.Close()
        return err
    }
    if err = file.Sync(); err != nil {
        file.Close()
        return err
    }
    if err = file.Close(); err != nil {
        return err
    }

    return os.Rename(tmp, path)
}

Sync, and what happens when two machines disagree

I edit a snippet on my work laptop and open it at home. The sync command reconciles the two. Because saving and syncing are separate, editing never waits on the network, and a failed sync never costs me a saved draft.

The states a snippet moves throughA snippet is a draft, a saved local version, or synced to GitHub. A clash keeps a conflict copy.editsavesynceditanother machine changed it tooI choose a version

Draft, autosaved

Saved locally

Synced to GitHub

Conflict copy kept

The states a snippet moves throughA snippet is a draft, a saved local version, or synced to GitHub. A clash keeps a conflict copy.editsavesynceditanother machine changed it tooI choose a version

Draft, autosaved

Saved locally

Synced to GitHub

Conflict copy kept

The states a snippet moves through. Save and sync are separate steps, so a failed sync never costs a saved draft.
  1. Editing makes a snippet an autosaved draft.
  2. Saving makes it a saved local version.
  3. Syncing sends it to GitHub.
  4. If another machine changed it too, a conflict copy is kept.
  5. Choosing a version returns it to synced.

The hard case is when both machines changed the same snippet before either synced. pb does not blindly overwrite one with the other. It creates a conflict copy, so both versions survive and I choose. Losing a note silently is the one thing a notes tool must never do.

The store is a Git repository, so these are the cases Git’s merge documentation describes. A CLI can hide the commands, but it should not hide the outcome.

Installing it without sudo

The biggest hurdle with small internal tools is getting them onto a machine. I wanted pb to install without administrator rights.

There are shell installers for macOS and Linux and a PowerShell script for Windows. Each one pulls the compiled Go binary from GitHub releases and puts it in a bin directory the user owns.

pb also keeps itself current. It checks GitHub for a newer release about once a day, and the upgrade policy can be set to auto, prompt, or manual, so it never interrupts me mid-thought.

Small and sharp

Building pb was a good exercise in local-first design, and it fixed a very specific itch. I no longer keep terminal output in clunky web apps. I type pb new notes/today.txt, drop the code in, and save.

The best tools are not always big platforms. Sometimes they are the small, sharp utilities we build for ourselves.

References

  1. Building a GitHub Backed Pastebin CLI in Go

    Abhishek Choudhury. Original project article, published April 2026.

  2. Local-First Software: You Own Your Data, in Spite of the Cloud

    Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. The principles and trade-offs behind local-first software.

  3. Package os: UserConfigDir

    The Go Authors. Platform-aware application configuration directories.

  4. git-merge documentation

    The Git Project. Merge states, conflicts, abort behavior, and safe preconditions.

  5. GitHub CLI: gh auth login

    GitHub. Authentication modes and credential-storage behavior.