PHP port of charmbracelet/soft-serve — the mighty, self-hostable Git server for the command line.
CandyServe is a self-hostable Git server you run on a VPS or machine. Users authenticate via SSH public keys and can:
- Browse repos, files, and commits via a terminal TUI over SSH
- Clone repos over SSH (
git clone ssh://user@host/repo), HTTP, or Git protocol - Push to create repos on demand
- Collaborate via per-repo access control with SSH public keys
- Use Git LFS for large file storage
candy-serve/
├── bin/soft-serve Entry point (serve command)
├── src/
│ ├── Config.php YAML config loader (symfony/yaml)
│ ├── Repo.php Bare Git repo (init, access, metadata)
│ ├── Visibility.php Repo visibility enum (public/collaborator-only/private)
│ ├── User.php SSH public key auth + user model
│ ├── AccessControl.php Permissions (admin/read/write)
│ ├── Stats.php Counters (connections, packs, LFS)
│ ├── StatsServer.php JSON stats endpoint on stats.listen_addr
│ ├── Lang.php i18n strings
│ ├── Jobs/
│ │ ├── Schedule.php "@every 10m"-style job schedules
│ │ └── MirrorPuller.php Mirror-pull background job
│ ├── SSH/
│ │ ├── SSHServer.php libssh2-based SSH server
│ │ ├── Auth.php Public key authentication
│ │ └── Commands.php git-upload-pack / git-receive-pack
│ ├── Git/
│ │ ├── GitDaemon.php Real daemon with socket connections, PID file, signal handling
│ │ ├── UploadPack.php git-upload-pack (clone/fetch)
│ │ └── ReceivePack.php git-receive-pack (push)
│ ├── HttpSmartProtocol/
│ │ └── Server.php HTTP smart protocol server (git-over-HTTP)
│ ├── Clipboard/
│ │ └── Osc52.php OSC 52 clipboard handler
│ └── LFS/
│ ├── LFSHandler.php Git LFS batch API
│ ├── LocalStorageBackend.php
│ └── LFSStorageBackendInterface.php
├── cmd/
│ └── serve.php Serve command implementation
└── tests/
composer installCreate config.yaml in your data directory:
name: "My Git Server"
ssh:
listen_addr: ":23231"
public_url: "ssh://localhost:23231"
key_path: "ssh/soft_serve_host"
idle_timeout: 120
git:
listen_addr: ":9418"
http:
listen_addr: ":23232"
public_url: "http://localhost:23232"
max_pack_bytes: 268435456
db:
driver: "sqlite"
data_source: "candy-serve.db"
lfs:
enabled: true
jobs:
mirror_pull: "@every 10m"
stats:
listen_addr: ":23233"Config files are parsed with symfony/yaml,
so full YAML 1.2 (block nesting, flow maps, anchors, …) is supported.
Quote values that start with a YAML reserved indicator — listen
addresses like ":23232" and schedules like "@every 10m".
# Set admin SSH key (your public key)
export CANDY_SERVE_INITIAL_ADMIN_KEYS="ssh-ed25519 AAAA... user@host"
# Start the server
CANDY_SERVE_DATA_PATH=/var/lib/candy-serve composer serve# Connect to TUI
ssh -p 23231 user@your-server
# Clone a repo
git clone ssh://user@your-server:23231/repo-name
# Browse repo tree
ssh -p 23231 user@your-server repo tree repo-name
# View a file with syntax highlighting
ssh -p 23231 user@your-server repo blob repo-name path/to/file.php -c -lCandyServe supports Git clone/fetch/push over HTTP using the smart protocol (not the dumb HTTP transport).
# Clone over HTTP
git clone http://user@your-server:23232/repo-name.git
# Authenticate with Basic auth (when required)
git clone http://username:token@your-server:23232/repo-name.gitThe smart protocol flow:
- Client GETs
/repo.git/info/refs?service=git-upload-pack— receives ref advertisement - Client POSTs
/repo.git/git-upload-pack— exchanges pack data for fetch/clone - For push: Client POSTs
/repo.git/git-receive-pack— sends pack and receives status
Authentication uses HTTP Basic auth or the X-CandyServe-User header.
CandyServe can run as a real background daemon serving Git clone/fetch/push over the native Git protocol on port 9418.
# Start as a daemon (forks to background, writes PID file)
CANDY_SERVE_DATA_PATH=/var/lib/candy-serve composer serve --daemon --pid-file /var/run/candy-serve-git.pid
# Run in foreground (shows banner and repo list, stays attached)
CANDY_SERVE_DATA_PATH=/var/lib/candy-serve composer serveDaemon mode behavior:
- Uses
pcntl_fork()to detach and become a session leader - Listens on
git.listen_addrfromconfig.yaml(default:9418) - Writes PID to
--pid-file(or<data_path>/git-daemon.pidby default) - Handles
SIGTERM,SIGINT, andSIGHUPfor graceful shutdown - Cleans up PID file and closes all connections on exit
Signal handling:
SIGTERM/SIGINT— graceful shutdown (closes connections, removes PID file)SIGHUP— reload configuration (restarts the daemon)
Clone over Git protocol:
# Anonymous clone (for public repos)
git clone git://your-server:9418/repo-name
# The git protocol is stateless; access is controlled per-repo (public/private)The Git protocol supports:
git-upload-pack— clone and fetch (read access)git-receive-pack— push (write access, requires collaborator permission)
The daemon is dual-mode. serve() runs the classic blocking
socket_select() loop (the default shown above). serveAsync() is the
opt-in ReactPHP path: connections are accepted
and read through the event loop, so a host application can run the Git
daemon alongside timers, HTTP servers, and other sockets on one loop.
use React\EventLoop\Loop;
use SugarCraft\Serve\Config;
use SugarCraft\Serve\Git\GitDaemon;
$daemon = new GitDaemon(Config::load('/var/lib/candy-serve/config.yaml'));
$daemon->registerRepos($repos);
// Returns a promise that resolves with exit code 0 on graceful stop.
$promise = $daemon->serveAsync(); // global loop, or pass your own
echo $daemon->listenAddress(), "\n"; // actual bound addr (port 0 = ephemeral)
Loop::addTimer(3600, fn () => $daemon->shutdown()); // graceful stop from loop code
Loop::run();Both modes share the same protocol code; shutdown() (or a
SIGTERM/SIGINT) tears down every loop registration — server stream,
per-client streams, housekeeping timer — unsubscribes candy-async
Subscriptions, closes connections, and removes the PID file. The
per-request work (ref advertisement, git pack-objects, git update-ref) is the same synchronous code the blocking mode runs; it
executes inside the readiness callback.
LFS batches have the same split: LFSHandler::handleBatch() is the
synchronous path, handleBatchAsync() resolves the identical response
via the loop with at most concurrentTransfers objects in flight
(bounded by SugarCraft\Serve\Support\PromisePool). Note that per-object
storage inspection is still synchronous file I/O inside its loop tick —
the async path bounds scheduling, it does not make file_get_contents
asynchronous.
The smart-HTTP server speaks the Git LFS batch API
plus basic object transfer when lfs.enabled is true:
| Route | Method | Purpose |
|---|---|---|
/{repo}/info/lfs/objects/batch |
POST | Batch API — advertises download/upload/verify actions |
/{repo}/info/lfs/objects/{oid} |
GET | Download an object |
/{repo}/info/lfs/objects/{oid} |
PUT | Upload an object |
/{repo}/info/lfs/objects/{oid}/verify |
POST | Post-upload existence + size check |
Rules enforced at the HTTP boundary:
- OIDs must be full lowercase SHA-256 hex (64 chars) — anything else is a 400.
- Downloads (and
downloadbatches) require read access; uploads, verifies, anduploadbatches require write access — the same auth (Basic orX-CandyServe-User) as the pack endpoints. - Uploads are capped by
http.max_pack_bytes(413 when exceeded) and the body's SHA-256 must match the OID (422 on mismatch).
Objects are stored via LFS\LocalStorageBackend under <data_path>/lfs/
using the standard aa/bb/oid fan-out layout.
A repo becomes a mirror by giving it an upstream pull URL:
use SugarCraft\Serve\Jobs\MirrorPuller;
use SugarCraft\Serve\Repo;
$mirror = Repo::new('linux', '/var/lib/candy-serve/repositories/linux')
->withMirrorFrom('https://github.com/torvalds/linux.git');
$puller = new MirrorPuller($config); // parses jobs.mirror_pull
$puller->registerRepo($mirror);
// Async daemon: pull due mirrors on the event loop every interval.
$puller->attach($loop);
// Blocking host / cron: pull due mirrors right now.
$puller->runOnce();Each due mirror is refreshed with
git -C <path> fetch --prune <url> '+refs/*:refs/*' (the full-mirror
refspec, so refs deleted upstream prune locally). The jobs.mirror_pull
schedule accepts @every <duration> with Go-style durations (30s,
10m, 8h, 1h30m) and the aliases @hourly, @daily, @midnight,
@weekly, @monthly, @yearly — full cron expressions are not
supported and throw. Failed pulls are logged and re-attempted on the
next interval.
When stats.listen_addr is configured, StatsServer serves the shared
Stats counters as JSON:
use SugarCraft\Serve\StatsServer;
$stats = new StatsServer($config); // reads stats.listen_addr
$stats->start($loop); // ReactPHP loop only$ curl -s localhost:23233/stats
{"uptime_seconds":42.1,"connections":7,"pack_uploads":1,"pack_downloads":4,
"lfs_batch_requests":2,"lfs_object_downloads":2,"lfs_object_uploads":1}Counters are recorded by the Git daemon (both modes), the smart-HTTP
server, and the LFS handler into the shared Stats::getInstance()
(injectable per-object via setStats() for tests/hosts). The stats
server itself runs only on a ReactPHP loop — there is deliberately no
blocking mode, since a blocking stats listener would starve the servers
it reports on; hosts running only the blocking GitDaemon::serve()
loop simply don't get a stats endpoint.
The TUI supports clipboard operations via OSC 52 (Operating System Command 52). This enables:
- Copying repo URLs, file content, or commit hashes from the TUI to the system clipboard
- Reading clipboard content into the TUI (e.g., for pasting)
Supported selections:
c— system clipboard (default)p— primary selection (X11)s— secondary selection
Visibility is a single SugarCraft\Serve\Visibility enum on Repo
(replacing the old paired isPublic/private booleans; the boolean
accessors remain as BC delegates):
Visibility::Public— anyone can read; only collaborators can push (or anyone, whenallowPushis set)Visibility::CollaboratorOnly— not readable anonymously; only collaborators and adminsVisibility::Private— only collaborators and admins can read or push
$repo = Repo::new('secret', $path)->withVisibility(Visibility::Private);
$repo->isPrivate(); // true
$repo->isPublic; // false (BC readonly property)
$repo->isVisiblePublic(); // falseCollaborators are added by an admin via SSH public key.
candy-serve uses candy-async for graceful shutdown via subscriptions on Git daemon connections.