Soft Serve Development Documentation
Important Instructions
- Use the gopls MCP tooling as much as appropriate. It provides dense, highly-relevant symbolic information. Before making any edit, use the workspace tool, check the relevant APIs and references if necessary, and then proceed with edits.
Code Style Guidelines
Formatting
- Go files: Use tabs for indentation
- Other files: Use 2 spaces for indentation
- Use LF line endings
- Insert final newline
- Trim trailing whitespace
- UTF-8 character encoding
Go Code Style
- Format with
gofumptandgoimports(configured in.golangci.yml) - Use standard Go naming conventions (PascalCase for exported, camelCase for unexported)
- Wrap exported types with helper methods rather than exposing underlying library types directly
- Implement standard interfaces where appropriate (e.g.,
sort.Interface)
Comments
- Exported functions, types, and methods must have godoc comments
- Comments should end with proper punctuation (enforced by
godotlinter) - Start comments with the name of the item being documented
Error Handling
- Wrap errors with context using
wrapcheckpatterns - Check all error returns (enforced by various linters)
- Close resources properly (enforced by
bodyclose,sqlclosecheck,rowserrcheck)
How to Write Unit Tests
Tooling
- Unit tests: Go's built-in
testingpackage withmatryer/isfor assertions - Integration tests:
testscriptframework (seetestscript/directory)
Running Tests
# Run all tests
go test ./...
# Run tests for specific package
go test ./pkg/backend
# Run integration tests
cd testscript && go test -v
# Run with PostgreSQL
SOFT_SERVE_DB_DRIVER=postgres \
SOFT_SERVE_DB_DATA_SOURCE="postgres://postgres:postgres@localhost/postgres?sslmode=disable" \
go test ./...
# Disable race detection (for slower systems)
SOFT_SERVE_DISABLE_RACE_CHECKS=1 go test ./...
Writing Unit Tests
- Place test files alongside the code being tested (e.g.,
tree_test.gonext totree.go) - Use
_test.gosuffix for test files - Use
matryer/isfor simple, readable assertions - Mock databases using transactions that rollback in deferred functions
- Test both SQLite and PostgreSQL code paths for database-related changes
- Use
pkg/test/test.gohelpers for fixtures (e.g.,test.RandomPort())
Writing Integration Tests
- Add
.txtarfiles totestscript/testdata/ - Each test spins up a complete Soft Serve instance with randomized ports
- Available commands:
soft,usoft,git,ugit,ui,uui,curl,mkfile,readfile,envfile - Use
ensureserverrunning/ensureservernotrunningto wait for server state
Development Commands and Environment
Build and Development Commands
Building
go build -o soft ./cmd/soft
CSS Cache-Busting
When editing CSS files in pkg/web/static/, increment the version query parameter in pkg/web/templates/base.html:
overrides.css?v=1βoverrides.css?v=2syntax.css?v=1βsyntax.css?v=2
Environment Variables
All configuration can be set via environment variables with the SOFT_SERVE_ prefix:
SOFT_SERVE_DATA_PATH: Data directory location (default:data)SOFT_SERVE_CONFIG_LOCATION: Custom config file pathSOFT_SERVE_INITIAL_ADMIN_KEYS: SSH keys for initial admin (setup only, newline-separated)SOFT_SERVE_SSH_LISTEN_ADDR: SSH server address (default::23231)SOFT_SERVE_HTTP_LISTEN_ADDR: HTTP server address (default::23232)SOFT_SERVE_GIT_LISTEN_ADDR: Git daemon address (default::9418)SOFT_SERVE_DB_DRIVER: Database driver (sqliteorpostgres)SOFT_SERVE_DB_DATA_SOURCE: Database connection stringSOFT_SERVE_NO_COLOR: Disable color output (set to any truthy value)SOFT_SERVE_DEBUG: Enable debug logging (enables auth callback logging)SOFT_SERVE_VERBOSE: Enable verbose logging (logs DB queries, SSH envs/keys; requiresSOFT_SERVE_DEBUGto also be set)
Environment variables always override config file settings via cfg.ParseEnv().
Advanced Features and Storage
TUI Architecture
The TUI (pkg/ui/) is built with Bubble Tea v2 and organized as:
pages/: Main views (repo browser, file viewer, log viewer, refs viewer)components/: Reusable UI elements (code viewer, tabs, header, footer, viewport)common/: Shared state and utilities includingcommon.Commonstruct passed to all componentsstyles/: Lipgloss style definitionskeymap/: Keyboard shortcuts
The TUI runs in PTY sessions and uses the BubbleTea middleware (bm.MiddlewareWithProgramHandler).
Git Protocol Version
The git daemon and SSH/HTTP handlers support git protocol v2. The protocol version is negotiated by the client:
- HTTP: Client sends
Git-Protocolheader (e.g.,Git-Protocol: version=2) - SSH: Client sets
GIT_PROTOCOLin session environment - Git daemon: Client specifies protocol in pktline extra parameters
The server passes the GIT_PROTOCOL environment variable through to git processes.
Architecture and Core Components
Project Overview
Soft Serve is a self-hostable Git server with SSH/HTTP/Git protocol support, featuring a terminal UI (TUI), Git LFS support, webhooks, and access control. It's built with Go and uses Bubble Tea for the TUI.
Core Components
-
Backend (
pkg/backend/): Central orchestrator that manages all business logic. The Backend struct ties together the database, store, config, cache, and task manager. It's the primary interface for repository, user, and settings operations. Key responsibilities:- Repository CRUD operations with cache management
- User authentication and authorization
- Hook execution (implements
hooks.Hooksinterface) - LFS object management
- Webhook event creation
-
Store (
pkg/store/): Database abstraction layer implementing repository, user, collaborator, LFS, access token, webhook, and settings persistence. The actual implementation lives inpkg/store/database/. This is a pure data layer with no business logic. -
Database (
pkg/db/): Low-level database wrapper aroundsqlxsupporting both SQLite (default) and PostgreSQL. Provides transaction management and migrations (inpkg/db/migrate/). Uses context-aware logging when verbose mode is enabled. -
Config (
pkg/config/): Configuration management supporting both YAML files and environment variables. AllSOFT_SERVE_*env vars override config file settings. Config is immutable once loaded and passed through context. -
Git Operations (
git/package): Git command execution and repository manipulation usingaymanbagabas/git-module. Handles commits, tags, references, patches, trees, and server-side hooks. This is a separate top-level package, not underpkg/.
Server Protocols
-
SSH Server (
pkg/ssh/): Provides both interactive TUI access and git protocol operations. Uses a middleware chain pattern:AuthenticationMiddleware: Validates SSH public key fingerprint matches approved keyContextMiddleware: Injects config, backend, store, DB, logger into session contextCommandMiddleware: Routes non-PTY sessions to CLI commands using CobraLoggingMiddleware: Logs connection, commands, and duration
CLI commands live in
pkg/ssh/cmd/as Cobra commands. -
HTTP Server (
pkg/web/): Serves git HTTP protocol, Git LFS endpoints, and provides basic auth using access tokens. Uses Gorilla Mux for routing with these controllers:GitController: Git smart HTTP protocolHealthController: Health check endpoint- Also uses middleware chain for compression, recovery, logging, and context
-
Git Daemon (
pkg/daemon/): Native git:// protocol support for anonymous read access. Implements connection pooling with configurable max connections and timeouts. -
Stats Server: Prometheus metrics endpoint for monitoring.
Access Control
The pkg/access/ package implements a four-level access system defined as iota constants:
NoAccess(0): Denies all accessReadOnlyAccess(1): Can read public reposReadWriteAccess(2): Full control of reposAdminAccess(3): Full server control
Anonymous access is controlled by two settings:
allow-keyless: Whether to allow connections without SSH keys (affects HTTP/git:// protocols)anon-access: Default access level for anonymous users (defaults to read-only)
Access evaluation order:
- Admin users (via
initial_admin_keysor user.is_admin flag) β AdminAccess - Repository owner (user_id matches repo.user_id) β ReadWriteAccess
- Explicit collaborators β their assigned level
- Authenticated users β read-only for public repos
- Anonymous users β
anon-accesssetting (ifallow-keylessis true)
Data Flow and Git Hooks
Key Data Flow
-
Context Propagation: Config, Backend, Store, and DB are passed through
context.Contextusing type-safe context helpers (e.g.,config.FromContext(ctx)). Each package defines its own context key as an unexported type to prevent collisions. -
Initialization Sequence (see
cmd/cmd.go:InitBackendContext):Config β DB β Store β Backend β ContextThis happens in
InitBackendContextwhich is used as a PersistentPreRunE hook in commands that need backend access. -
Request Flow:
SSH/HTTP/Git Protocol β Middleware β Backend β Store β Database β Cache (LRU, 1000 items) -
SSH Command Flow:
SSH Session β CommandMiddleware β Cobra Command β Backend Method β Store β DBNon-PTY sessions are routed to CLI commands. PTY sessions go to the BubbleTea TUI.
Git Hooks System
Git hooks use a clever two-tier system:
-
Hook Dispatcher (generated in each repo's
hooks/directory): Bash script that reads stdin, then calls all executables inhooks/<hookname>.d/directory, passing both stdin and arguments. Exits on first non-zero exit code. -
Soft Serve Hook (in
hooks/<hookname>.d/soft-serve): Calls back into thesoftbinary viasoft hook <hookname>command, which invokes Backend methods that implement thehooks.Hooksinterface.
Hook generation (pkg/hooks/gen.go):
- Creates hook dispatcher at
hooks/<hookname> - Creates
hooks/<hookname>.d/directory - Generates
hooks/<hookname>.d/soft-servethat calls${SOFT_SERVE_BIN_PATH} hook <hookname> - Supports: pre-receive, update, post-receive, post-update
This design allows:
- Multiple hooks per event (run in alphabetical order from
.d/directory) - User-defined hooks alongside Soft Serve's built-in hooks in each repo's
.d/directory
Hook Environment Variables
Git hooks receive environment variables set in the git daemon, SSH middleware, or HTTP handler:
SOFT_SERVE_REPO_NAME: Repository name (without .git)SOFT_SERVE_REPO_PATH: Full path to repositorySOFT_SERVE_PUBLIC_KEY: The authorized SSH key (in authorized_keys format)SOFT_SERVE_USERNAME: Username for authenticated usersSOFT_SERVE_BIN_PATH: Path to soft binary (defaults to "soft" if not set)SOFT_SERVE_HOST: Hostname from git protocol handshakeSOFT_SERVE_LOG_PATH: Path to hooks log fileGIT_DIR: Standard git variableGIT_PROTOCOL: Git protocol version and capabilities
The backend reads SOFT_SERVE_PUBLIC_KEY and SOFT_SERVE_USERNAME to identify users in hooks.
Database Schema and Migrations
Database Schema
Key tables (see pkg/db/models/):
users: User accounts with admin flags, created_at, updated_atpublic_keys: SSH public keys linked to users (one-to-many)repos: Repository metadata with user_id owner (required)collabs: Repository collaborators with access levels (many-to-many usersβrepos)settings: Server-wide settings (key-value store)lfs_objects: Git LFS object metadata linked to reposlfs_locks: Git LFS file locks with path and useraccess_tokens: User access tokens for HTTP auth with optional expirywebhooks: Repository webhook configurations with secret and eventswebhook_events: Webhook event type subscriptions (many-to-many webhooksβevents)webhook_deliveries: Webhook delivery attempts with request/response logs
Important schema notes:
repos.user_idisNOT NULL(legacy repos are assigned to first admin during migration)public_keyshas a unique constraint on the key content (prevents duplicate keys)collabshas a composite unique key on (user_id, repo_id)lfs_objectshas a composite unique key on (oid, repo_id)lfs_lockshas a composite unique key on (repo_id, path)webhook_eventshas a composite unique key on (webhook_id, event)- Foreign key constraints are enabled and critical for data integrity
Database Migrations
Migrations live in pkg/db/migrate/ with separate SQL files for SQLite and PostgreSQL:
NNNN_migration_name_sqlite.up.sql/.down.sqlNNNN_migration_name_postgres.up.sql/.down.sql- Migration number prefixes must be unique and sequential
- Go files (
NNNN_migration_name.go) can handle data migrations
The migration system in migrate.go automatically runs pending migrations on startup.
Transaction Patterns
Always use db.TransactionContext for database operations:
err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
// All operations use tx, not db
return d.store.CreateRepo(ctx, tx, name, ...)
})
return db.WrapError(err) // Wraps db errors as proto errors
The transaction automatically:
- Commits on
nilreturn - Rolls back on error return
- Handles
sql.ErrTxDonegracefully
Common Gotchas
SQLite Foreign Keys
SQLite requires _pragma=foreign_keys(1) in the DSN. The default config includes this, but custom configs must include it:
soft-serve.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)
Without this, cascade deletes won't work and referential integrity is not enforced.
Transaction Context
When creating database transactions, use TransactionContext(ctx, fn) to properly propagate context with logger and other values. The Transaction(fn) method uses context.Background() which loses context values.
Development Workflow Guides
Adding a New SSH Command
- Create command file in
pkg/ssh/cmd/(e.g.,mycommand.go) - Define Cobra command with
RunEfunction - Use
checkIfReadable,checkIfCollab, orcheckIfAdminfor authorization - Add command to
CommandMiddlewareinpkg/ssh/middleware.go - Test with testscript in
testscript/testdata/
Adding a New Database Table
- Create model in
pkg/db/models/ - Create migration files in
pkg/db/migrate/(both SQLite and Postgres) - Add store interface methods to
pkg/store/and implement inpkg/store/database/ - Update
pkg/backend/to expose operations - Test migrations with both databases
Adding a New Webhook Event
- Define event type in
pkg/webhook/event.go - Create event constructor in
pkg/webhook/(e.g.,NewMyEvent) - Call
webhook.SendEvent(ctx, event)where event should fire - Add test in
testscript/testdata/
Working with Git Operations
All git operations should:
- Use the
gitpackage, not exec.Command directly - Set appropriate timeouts (
CommandOptions.Timeout) - Pass context for cancellation
- Handle
git.ErrInvalidRepofor non-existent repos - Use
git.EnsureWithin()to prevent directory traversal attacks
Patterns and Conventions
Context Usage
Always retrieve dependencies from context rather than passing them explicitly:
cfg := config.FromContext(ctx)
be := backend.FromContext(ctx)
dbx := db.FromContext(ctx)
store := store.FromContext(ctx)
logger := log.FromContext(ctx)
user := proto.UserFromContext(ctx)
Repository Naming
- User-facing repo names omit the
.gitextension (e.g.,dotfiles) - On-disk repos are always
<name>.git(e.g.,dotfiles.git) - Repos are stored in
<data_path>/repos/<name>.git - Nested repos are supported (e.g.,
user/project.gitβrepos/user/project.git) - Always use
utils.SanitizeRepo()before using user input as repo name - Always validate with
utils.ValidateRepo()before creating/renaming repos
Cache Invalidation
The backend maintains an LRU cache (1000 items) of repository objects. Critical: Always invalidate cache after mutations:
// Delete cache before Db transaction
d.cache.Delete(name)
return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
// ... database operations
})
Cache invalidation happens in:
DeleteRepository: After deletionRenameRepository: For old nameSetDescription,SetPrivate,SetHidden,SetProjectName: Before update
Failure to invalidate cache will cause stale data to be served until cache eviction.
SSH Authentication Flow
- Client connects with public key
PublicKeyHandlerchecks if key belongs to a user, stores fingerprint inPermissions.Extensions["pubkey-fp"]- If all public keys fail,
KeyboardInteractiveHandlerchecksallow-keylesssetting AuthenticationMiddlewarevalidates that the public key fingerprint in context matches the actual auth key (gossh doesn't guarantee this)
This double-check prevents a security issue where gossh's PublicKeyCallback doesn't guarantee the approved key is the one actually used.
Task Manager for Long-Running Operations
Repository imports run asynchronously using pkg/task:
tid := "import:" + name
d.manager.Add(tid, func(ctx context.Context) error {
// Long-running operation
return nil
})
done := make(chan error, 1)
go d.manager.Run(tid, done)
err := <-done
Tasks are identified by string ID and automatically cleaned up on completion. The manager respects context cancellation.
Repository Path Construction
Always use backend.repoPath() or construct paths consistently:
// Correct
rp := filepath.Join(cfg.DataPath, "repos", repoName+".git")
// Wrong - missing .git suffix
rp := filepath.Join(cfg.DataPath, "repos", repoName)
The .git suffix is mandatory for git to recognize bare repositories.