Building KOS: A Journey Through Zero Dependencies (And the Bugs That Taught Me Everything)

go dev.to

I didn't set out to reject go get. I just wanted to build something clean. But somewhere between the first snapshot corruption and the fourth complete rewrite, I realized: zero dependencies wasn't a constraint. It was a feature.

This is the real story of KOS—what I built, what broke, why I fixed it the way I did, and how you can use it yourself.

What is KOS?

KOS is a portable, encrypted version control system. It's not trying to replace Git. It's trying to be something Git isn't:

  • Single file: Everything lives in one main.go (~2000 lines)
  • Zero dependencies: 100% Go standard library
  • Encrypted: AES-256-CFB with HMAC-SHA256 integrity
  • Portable: Runs from a USB stick, no installation
  • Simple: kos initkos save "message"kos view

It stores your project snapshots in a global vault (~/.kos-global/) indexed by UUID. No Git databases. No complex merge logic. Just snapshots and timelines.

Getting Started: How to Use KOS

Installation

git clone https://github.com/kosmoscpp/kos
cd kos
make install  # Linux/Mac
# or: build.bat  # Windows
Enter fullscreen mode Exit fullscreen mode

That's it. One binary. No dependencies to install. You can even compile it on a machine without Go and ship the binary to another machine.

Basic Workflow

Initialize a project:

kos init
# ✓ Initialized empty KOS repository in /home/user/.kos-global/abc123...
# Project UUID: abc123-def456-ghi789-...
Enter fullscreen mode Exit fullscreen mode

Create snapshots:

kos save "Initial commit"
kos save "Added authentication"
kos save "Fixed bug in auth flow"
Enter fullscreen mode Exit fullscreen mode

View your timeline:

kos view
Enter fullscreen mode Exit fullscreen mode

Output:

╔══════════════════════════════════════════════════════════╗
║                     KOS TIMELINE                         ║
╚══════════════════════════════════════════════════════════╝

  ● abc1e2f1 ┃ Jan 08, 14:32 (just now) ┃ Fixed bug in auth flow
  │           ┃  42 files
  │
  ● a2d3f4b5 ┃ Jan 08, 14:15 (15 mins ago) ┃ Added authentication
  │           ┃  41 files
  │
  ● 8c7e9a0b ┃ Jan 08, 12:00 (2 hours ago) ┃ Initial commit
  │           ┃  40 files
  └── (End of history)
Enter fullscreen mode Exit fullscreen mode

Compare snapshots:

kos diff abc1e2f1 a2d3f4b5
# Shows: + Added (0), ~ Modified (2), - Deleted (0)

kos diff abc1e2f1 a2d3f4b5 auth.go
# Shows line-by-line diff for auth.go
Enter fullscreen mode Exit fullscreen mode

Tag releases:

kos tag v1.0
kos tag stable abc1e2f1  # Tag a specific snapshot
Enter fullscreen mode Exit fullscreen mode

Restore your work:

kos checkout abc1e2f1                    # Restore entire snapshot
kos checkout abc1e2f1 src/main.go        # Restore single file
kos checkout v1.0                        # Restore by tag
kos checkout HEAD~3                      # Go back 3 commits
Enter fullscreen mode Exit fullscreen mode

Encrypted snapshots:

kos save --encrypt "Secret credentials"
# Password: ****
# Confirm: ****
# 🔐 Saved encrypted snapshot: Secret credentials (15 files)

kos share abc1e2f1  # Export as snap_abc1e2f1.tar.gz.enc
# Can email/USB/cloud this file. Recipient loads with:
kos load snap_abc1e2f1.tar.gz.enc
# Password: ****
Enter fullscreen mode Exit fullscreen mode

Project management:

kos projects           # List all projects in global store
kos rename "my-blog"   # Rename current project
kos stats              # Show project statistics
kos search "refactor"  # Search commit messages
Enter fullscreen mode Exit fullscreen mode

The Bugs (And Why They Matter)

This is where it gets real. Because building from scratch means building through everything.

Bug #1: HEAD Recognition Fails (The Identity Crisis)

Early on, I implemented KOS to share and diff by snapshot ID. Simple, right?

kos share HEAD        # Error: Snapshot 'HEAD' not found
kos diff v1.0 v1.1   # Error: Snapshot 'v1.0' not found
Enter fullscreen mode Exit fullscreen mode

Only raw IDs like 18d03947 worked. Tags and HEAD references were rejected.

The root cause: I wrote separate lookup functions that did direct ID matching instead of using a reference resolver:

// WRONG: Direct lookup, doesn't handle special refs
func findSnapshot(id string) *Snapshot {
    for i := range manifest.Snapshots {
        if manifest.Snapshots[i].ID == id {
            return &manifest.Snapshots[i]
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The fix: I extracted reference resolution into a single function that handles all cases:

// CORRECT: Handles IDs, tags, HEAD, and HEAD~N
func resolveRef(ref string) *Snapshot {
    snaps := manifest.Snapshots
    if ref == "HEAD" {
        return &snaps[len(snaps)-1]
    }
    if strings.HasPrefix(ref, "HEAD~") {
        parts := strings.Split(ref, "~")
        var offset int
        fmt.Sscanf(parts[1], "%d", &offset)
        idx := len(snaps) - 1 - offset
        if idx >= 0 && idx < len(snaps) {
            return &snaps[idx]
        }
    }
    for i := range snaps {
        if snaps[i].ID == ref || snaps[i].Tag == ref {
            return &snaps[i]
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Now every command uses resolveRef(). Git-like references work everywhere.

What I learned: Centralize your logic. Don't repeat the same lookup in ten places. You'll catch edge cases faster and users won't hit weird state machines.

Bug #2: Encryption Produces Garbage (The Defer Nightmare)

Encrypted snapshots were completely corrupted. Trying to load them:

Error: unexpected EOF when trying to decrypt
Enter fullscreen mode Exit fullscreen mode

I was encrypting an incomplete file.

The root cause: In cmdSave(), I used defer for everything:

// WRONG: Defer happens at function exit, but we encrypt before that!
file, err := os.Create(filepathTar)
defer file.Close()

gzw := gzip.NewWriter(file)
defer gzw.Close()

tw := tar.NewWriter(gzw)
defer tw.Close()

// ... add files to tar ...

if encrypt {
    encryptFile(filepathTar, encPath, password)  // File still open!
    // Defer hasn't run yet. File is incomplete. We're encrypting garbage.
}
Enter fullscreen mode Exit fullscreen mode

The file was still being written when I tried to encrypt it. The tar writer hadn't flushed. The gzip writer hadn't compressed. The file wasn't closed.

The fix: Explicit close order before encryption:

// CORRECT: Close in dependency order before encryption
tw.Close()      // Tar closes first
gzw.Close()     // Gzip flushes compression
file.Close()    // File closes last

if encrypt {
    encryptFile(filepathTar, encPath, password)  // Now it's complete
}
Enter fullscreen mode Exit fullscreen mode

This was a painful lesson in writer layering. Each writer depends on the one below it. You have to close from the top down.

What I learned: Don't let defer fool you into false security. When you have layered resources (writer → compressor → file), you need explicit, ordered cleanup. defer is great for simple cases. For complex ones, be explicit.

Bug #3: Encrypted Load Shows Zero Commits (The Skip Logic Backfire)

When loading encrypted snapshots:

kos load snap_abc123.tar.gz.enc
# ✓ Loaded encrypted project successfully!
# Commits: 0  ← WRONG! Should be 5
Enter fullscreen mode Exit fullscreen mode

All the commits disappeared.

The root cause: The extract function had smart logic to skip .kos/ metadata when extracting:

// This is correct for normal use
if strings.HasPrefix(header.Name, ".kos/") {
    continue  // Skip metadata files
}
Enter fullscreen mode Exit fullscreen mode

But the encrypted load code needed to read .kos/manifest.json to show how many commits existed. Since it was being skipped, the manifest was empty.

The fix: Use a different extraction function for encrypted loads that doesn't skip:

// extractFileFromTar reads directly from tar without skip logic
manifestContent, err := extractFileFromTar(decryptedPath, ".kos/manifest.json")
json.Unmarshal([]byte(manifestContent), &embeddedManifest)
Enter fullscreen mode Exit fullscreen mode

Now encrypted snapshots preserve their full history.

What I learned: When you have branching logic (normal extract vs. encrypted load), they need different paths. Don't let one code path's assumptions break another's.

Bug #4: Password Masking Without x/term (The Syscall Journey)

I wanted masked password input (asterisks instead of plaintext). The easy way: golang.org/x/term. But that breaks zero dependencies.

So I went low-level. POSIX syscalls. SYS_IOCTL. Terminal state manipulation.

func readPassword() string {
    fd := int(os.Stdin.Fd())
    oldState, err := getTerminalState(fd)
    if err != nil {
        // Fallback if we can't control terminal
        var password string
        fmt.Scanln(&password)
        return password
    }

    newState := oldState
    newState.Lflag &^= syscall.ECHO  // Disable echo
    setTerminalState(fd, newState)
    defer setTerminalState(fd, oldState)

    var password []byte
    buf := make([]byte, 1)
    for {
        n, err := os.Stdin.Read(buf)
        if err != nil || n == 0 || buf[0] == '\n' {
            break
        }
        if buf[0] == 127 || buf[0] == 8 {  // Backspace
            if len(password) > 0 {
                password = password[:len(password)-1]
                fmt.Print("\b\b")  // Visual backspace
            }
        } else {
            password = append(password, buf[0])
            fmt.Print("*")
        }
    }
    return string(password)
}

func getTerminalState(fd int) (syscall.Termios, error) {
    var state syscall.Termios
    _, _, errno := syscall.Syscall6(
        syscall.SYS_IOCTL,
        uintptr(fd),
        uintptr(syscall.TCGETS),
        uintptr(unsafe.Pointer(&state)),
        0, 0, 0,
    )
    if errno != 0 {
        return state, errno
    }
    return state, nil
}
Enter fullscreen mode Exit fullscreen mode

The tricky part: One wrong flag bit and users see their passwords on screen. The POSIX terminal model is finicky. This code works on Linux. Windows POSIX layer might have issues (but that's acceptable for a hackathon).

What I learned: Sometimes the "easy" dependency exists for a reason. But understanding the low-level mechanism? That's worth more than convenience. Now I know how terminal echo works. I know what TCGETS and TCSETS do. I know the bitfield layout of Termios.

Bug #5: The PBKDF2 Myth (And Correcting a Senior Engineer)

During review, someone asked: "Why SHA256 for key derivation instead of PBKDF2?"

They said: "It's in the standard library."

It's not.

crypto/pbkdf2 doesn't exist in stdlib. It's in golang.org/x/crypto, which is external. I had to correct them.

// This is what I use (stdlib)
func deriveKey(password string) []byte {
    hash := sha256.Sum256([]byte(password))
    return hash[:]
}
Enter fullscreen mode Exit fullscreen mode

Felt weird correcting a senior engineer, but I was right. I triple-checked. go doc crypto/pbkdf2 returns nothing.

The choice: SHA256 is fast. PBKDF2 is slower (which is good for passwords—more resistant to brute force). But for a hackathon tool, the trade-off is acceptable. And it keeps zero dependencies.

What I learned: Don't assume. Don't trust memory. Actually verify what's in the standard library. And sometimes you do know things that others don't.

Bug #6: Ghost Projects In ~/.kos-global/ (The Cleanup Problem)

After months of testing:

ls ~/.kos-global/
abc123-def456-...
import-test-1
import-test-2
my-awesome-project-v1
my-awesome-project-v2
test-uuid-alpha
test-uuid-beta
... fifteen more orphaned directories
Enter fullscreen mode Exit fullscreen mode

Each test run created new UUIDs. Each UUID got added to projects.json. But I never deleted old ones.

The root cause: During testing, I created projects, then deleted them locally, but their entries in projects.json remained. Then their directories stayed in the global store, orphaned and forgotten.

The fix: Had to manually:

rm -rf ~/.kos-global/test-uuid-*
rm -rf ~/.kos-global/import-test-*
vim ~/.kos-global/projects.json  # Remove orphaned entries
Enter fullscreen mode Exit fullscreen mode

Then rebuild the index.

What I learned: Testing creates state. State accumulates. You need cleanup procedures or eventual garbage collection. In production, KOS should have a kos gc (garbage collect) command to clean up orphaned projects.

The Real Lessons

1. Writer Layering Matters

Gzip writes to a tar writer. The tar writer writes to a file. You have to close them in order: tar → gzip → file. Not all at once with defer.

2. Centralize Reference Resolution

Don't lookup snapshots in ten places. Do it in one place (resolveRef). Now HEAD works everywhere. Tags work everywhere. No weird edge cases.

3. Skip Logic Can Break Assumptions

Your extract function skips .kos/ files? That's correct for normal use. But encrypted loads need them. Different paths for different use cases.

4. Syscalls Teach You Real Things

Using golang.org/x/term is easy. Using syscall.Syscall6 with TCGETS is harder. But now I understand terminal echo at the OS level. That knowledge sticks.

5. Test State Accumulates

Build testing cleanup into your workflow. Delete projects between tests. Clean up projects.json. Otherwise you end up with fifteen ghost projects.

Why Zero Dependencies Was Worth It

I could have used:

  • github.com/urfave/cli for CLI parsing
  • golang.org/x/term for password masking
  • github.com/fatih/color for colors
  • github.com/google/uuid for UUIDs
  • github.com/sergi/go-diff for diffs

Each would have saved time on that specific feature. But:

  • I wouldn't have understood how any of them work
  • The binary would be much larger
  • The attack surface (for a security tool!) would be huge
  • I wouldn't have learned anything
  • Deployment would be more complex

Instead, I built a 4.2 MB binary that runs anywhere. I understand every line. I can audit it myself. I can deploy it to air-gapped networks. I can ship it on a USB stick.

How to Try It

# Clone and build
git clone https://github.com/kosmoscpp/kos
cd kos
go build -o kos .

# Initialize a project
mkdir my-project
cd my-project
../kos init

# Save some work
echo "Hello, KOS" > readme.md
../kos save "Initial commit"

# See the timeline
../kos view

# Make a change
echo "Hello, KOS with updates!" > readme.md
../kos save "Updated readme"

# View timeline again
../kos view

# Check what changed
../kos diff <snapshot-id-1> <snapshot-id-2>

# Restore a version
../kos checkout <snapshot-id>
cat readme.md  # Back to original
Enter fullscreen mode Exit fullscreen mode

The Takeaway

Building KOS taught me that constraints aren't prison walls. They're focusing tools.

When I couldn't reach for go get, I had to understand my problems deeply. I had to learn about terminal I/O, writer layering, reference resolution, and encryption. I had to debug by understanding, not by reading someone else's code.

The result isn't just a tool. It's a tool I completely understand. It's a tool that's portable and secure. It's a tool that can run where nothing else can run.

That's worth the bugs. That's worth the deep dives. That's worth saying no to convenience.

Try KOS. Use it. Break it (please report issues). Learn from it.

And maybe, just maybe, build something with zero dependencies yourself. You'll be surprised what you learn when you can't go get your way out.


GitHub: kosmoscpp/kos

Quick Start:

git clone https://github.com/kosmoscpp/kos && cd kos && make install
kos init
kos save "My first snapshot"
kos view
Enter fullscreen mode Exit fullscreen mode

That's it. No setup. No config. No dependencies. Just version control, zero friction.

Source: dev.to

arrow_back Back to Tutorials