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 init→kos 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
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-...
Create snapshots:
kos save "Initial commit"
kos save "Added authentication"
kos save "Fixed bug in auth flow"
View your timeline:
kos view
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)
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
Tag releases:
kos tag v1.0
kos tag stable abc1e2f1 # Tag a specific snapshot
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
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: ****
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
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
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
}
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
}
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
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.
}
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
}
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
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
}
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)
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
}
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[:]
}
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
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
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/clifor CLI parsing -
golang.org/x/termfor password masking -
github.com/fatih/colorfor colors -
github.com/google/uuidfor UUIDs -
github.com/sergi/go-difffor 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
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
That's it. No setup. No config. No dependencies. Just version control, zero friction.