sugarcraft / candy-serve
PHP port of charmbracelet/soft-serve — self-hostable Git server over SSH, Git daemon, and HTTP. Supports user management via SSH public keys, repo access control (public/private), collaborators, on-demand repo creation, Git LFS, and an SSH-accessible TUI for browsing repos/files/commits.
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- ext-ssh2: *
- react/event-loop: ^1.6
- react/promise: ^3.3
- sugarcraft/candy-async: dev-master
- sugarcraft/candy-core: dev-master
- symfony/yaml: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-07-10 05:48:03 UTC
README
CandyServe
PHP port of charmbracelet/soft-serve — the mighty, self-hostable Git server for the command line.
Overview
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
Architecture
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/
Install
composer install
Configuration
Create 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".
Run
# 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
SSH Access
# 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 -l
HTTP Smart Protocol
CandyServe 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.git
The 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.
Git Protocol (Daemon Mode)
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 serve
Daemon 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)
Async daemon mode (ReactPHP, opt-in)
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.
Git LFS over HTTP
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.
Mirrors
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.
Stats endpoint
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.
OSC 52 Clipboard
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
Repo Permissions
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(); // false
Collaborators are added by an admin via SSH public key.
Shared foundations
candy-serve uses candy-async for graceful shutdown via subscriptions on Git daemon connections.