sugarcraft / candy-git-server
Scaffold library pieces for a self-hostable Git server: config, repo, user/access-control, git-daemon, smart-HTTP, ForceCommand SSH-gate and LFS classes, each individually tested. No assembled server ships yet — the serve command refuses; see README 'Known gaps'. Modeled on the charmbracelet/soft-se
Package info
github.com/sugarcraft/candy-git-server
Type:project
pkg:composer/sugarcraft/candy-git-server
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- ext-ssh2: *
- react/event-loop: ^1.6
- react/promise: ^3.3
- sugarcraft/sugar-async: dev-master
- sugarcraft/sugar-core: dev-master
- symfony/yaml: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-28 16:45:56 UTC
README
Formerly published as
sugarcraft/candy-serve(renamed 2026-10). Namespace changed:SugarCraft\Serve\→SugarCraft\GitServer\. Env compatibility:CANDY_SERVE_*exports are still honored viaLegacyEnv(deprecated, stderr-noticed).
CandyGitServer
candy-git-server — status: scaffold. Library building blocks for a self-hostable Git server, for PHP 8.3+. No assembled server ships yet.
Overview
This package is a PHP port of pieces of the charmbracelet/soft-serve Go
project. The domain classes are real and individually tested, and the
smart-HTTP read path is now a running service: serve --http starts a
listener that real git clone/git fetch succeed against (owner ruling
1b, phase 1 — r1024/githttp). Every other transport stays assembly-less:
the default serve still refuses, and so do --daemon/--pid-file:
$ candy-git-server serve candy-git-server: 'serve' refuses to start — server assembly is not implemented; see docs (status: scaffold). $ echo $? 1
The bare command refuses loudly (non-zero exit, honest stderr) rather than
printing a startup banner while serving nothing — the ssh and git-daemon
transports remain scaffold until their phases land, and push
(git-receive-pack over HTTP) is a pinned phase-2 refusal (501) even on
the live listener. Nothing forks.
What is implemented:
Config— YAML config loader (symfony/yaml) with typed accessorsRepo— bare-repository model: init (Git at<path>/.git), refs, branches/tags, file reads, mirror metadata, visibilityUser,AccessControl,Visibility— user model, per-repo permission checks, visibility enum (no persistence layer — see Known gaps)Git\PktLine— the single pkt-line codec (spec%04xASCII-hex length prefixes,0000flush, tolerant decode); it replaced four ad-hoc binary-framed emittersGit\StatelessRpc— spawns the realgit upload-pack/receive-pack --stateless-rpcchildren the HTTP read path delegates to (bounded stdin write + bounded drain on one deadline, cap refusal, TERM→KILL reap per spawn)Git\UploadPack,Git\ReceivePack— in-process clone/push command handlers for the daemon/CGI shapes (framing now goes through the codec; the daemon's own dialect still deviates — see Known gaps)Git\GitDaemon— socket daemon class with PID file + signal handling (class-complete, but its protocol dialect deviates from spec and no shipped entrypoint constructs it)HttpSmartProtocol\Server— request router for the Git smart-HTTP and LFS batch APIs; the smart-HTTP read path is live (advertisement + negotiation behind trust gates; push POST answers 501)HttpSmartProtocol\HttpListener— the HTTP/1.1 socket server that hosts that router, started only byserve --httpSSH\SSHServer— aForceCommand-style git-shell gate for host sshd (parses onegit-upload-pack/git-receive-packrequest and dispatches; does not implement the SSH wire protocol)LFS\LFSHandler+LocalStorageBackend— LFS batch API + object storeJobs\Schedule,Jobs\MirrorPuller—@every/alias schedules and mirror-pull job logicStats,StatsServer— counters + JSON stats endpoint classSupport\PromisePool,Support\Version— bounded-concurrency helper, phar build-stamp resolverbin/candy-git-server— CLI:serve(refuses),serve --http --repo-root DIR(starts the smart-HTTP read listener),init(writes a config skeleton),repo create|list|info(works against the local data dir),usersubcommands (in-memory only — nothing persists),help,version
What does not exist: any TUI (there is no terminal UI of any kind); a
listener on :23231/:23232/:9418 by default (serve --http binds only
the address you pass it); user storage; git push end-to-end (phase 2 —
the wire refuses you with 403/501); LFS end-to-end proof (the batch routes
ride the same listener but are pinned only at router level); an SSH or
git-daemon transport that answers traffic.
Clipboard/Osc52.php is a standalone OSC 52 encoder with zero consumers —
it was written for a TUI that was never built.
Architecture (actual files)
candy-git-server/
├── bin/candy-git-server CLI (serve refuses; serve --http listens; repo/init work)
├── src/
│ ├── Config.php YAML config loader (symfony/yaml)
│ ├── Repo.php Bare Git repo (init at <path>/.git, refs, metadata)
│ ├── Visibility.php Repo visibility enum (public/collaborator-only/private)
│ ├── User.php User model + SSH public-key matching (no persistence)
│ ├── AccessControl.php Permissions (admin/read/write)
│ ├── Stats.php Counters (connections, packs, LFS)
│ ├── StatsServer.php JSON stats endpoint class (needs a host to start it)
│ ├── Lang.php i18n strings
│ ├── Jobs/
│ │ ├── Schedule.php "@every 10m"-style job schedules
│ │ └── MirrorPuller.php Mirror-pull background job
│ ├── SSH/
│ │ └── SSHServer.php ForceCommand git-shell gate (no SSH wire protocol)
│ ├── Git/
│ │ ├── GitDaemon.php Socket daemon class (un-assembled; see Known gaps)
│ │ ├── PktLine.php The pkt-line codec (spec framing, flush, tolerant decode)
│ │ ├── StatelessRpc.php git --stateless-rpc children for the HTTP read path
│ │ ├── UploadPack.php git-upload-pack handler (daemon/CGI shape)
│ │ └── ReceivePack.php git-receive-pack handler (daemon/CGI shape)
│ ├── HttpSmartProtocol/
│ │ ├── HttpListener.php HTTP/1.1 socket server (serve --http hosts the router)
│ │ └── Server.php Smart-HTTP + LFS router (read path live; push 501)
│ ├── Clipboard/
│ │ └── Osc52.php OSC 52 encoder (orphan: no TUI consumes it)
│ ├── LFS/
│ │ ├── LFSHandler.php Git LFS batch API
│ │ ├── LocalStorageBackend.php aa/bb/oid object store
│ │ └── LFSStorageBackendInterface.php
│ └── Support/
│ ├── PromisePool.php Bounded-concurrency helper
│ └── Version.php Phar build-stamp resolver
├── examples/ config.php, repo.php (library usage)
├── tools/build-phar.php phar build script
└── tests/
Install
composer install
The smart-HTTP read path delegates advertisement, negotiation and pack
building to the system git binary (tested against git 2.43); without
a git on PATH, serve --http starts but every read request answers
500 and the read-path pins skip loudly.
Configuration
Create config.yaml in your data directory (candy-git-server init
emits a skeleton). Note: several keys below are parsed but not yet
consumed by any shipped code path (db, ssh.idle_timeout — see Known
gaps); the listening addresses describe what a future assembly would use.
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 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
The default serve still refuses with exit 1 (see Overview). Two things
run for real: the local-data CLI pieces, and the opt-in smart-HTTP read
listener:
# phase 1 read path: clone/fetch only — push stays a 501 refusal
candy-git-server serve --http --repo-root /var/lib/candy-git-server/repositories \
--http-port 23232
git clone http://localhost:23232/myrepo.git
serve --http flags: --repo-root DIR (required; every bare dir and every
<dir>/.git under it is registered under its basename), --http-port N
(default 23232), --http-host H (default 127.0.0.1), --config F
(the YAML config, defaults applied when absent). Exit codes: 1 for a
flag or bind error, 0 after SIGINT/SIGTERM shutdown; anonymous reads of
public repos are served, private repos answer 403, and git push fails on
the wire. The CLI pieces that work without any listener operate on the
local data directory:
candy-git-server init /var/lib/candy-git-server # writes config skeleton + dirs candy-git-server repo create myrepo /var/lib/candy-git-server # creates myrepo/.git bare candy-git-server repo list /var/lib/candy-git-server candy-git-server repo info myrepo /var/lib/candy-git-server
repo create prints no clone URL on purpose: nothing listens for git
traffic yet. CANDY_GIT_SERVER_INITIAL_ADMIN_KEYS is not read by any
code (it appears only in a stale init next-steps string) — see Known gaps.
SSH Access (not shipped)
No SSH server is wired. SSH\SSHServer is a ForceCommand-style
git-shell gate: it parses a single
git-upload-pack/git-receive-pack <repo> request, validates the repo name
and the provided SSH public key against a User, and dispatches to
Git\UploadPack/Git\ReceivePack. It does not implement the SSH wire
protocol — it expects to run under the host's OpenSSH daemon via
ForceCommand, which is a deployment step nobody has shipped here.
See also — SSH middleware (sugar-ssh-server)
For a general SSH middleware/session framework (middleware chain, ForceCommand
supervisor, Session, transports), see the sibling
sugar-ssh-server. Both run under host sshd; neither implements the
SSH wire protocol itself. Their auth models differ deliberately: candy-git-server
does its own SSH public-key matching (SSH\SSHServer + User), while sugar-ssh-server trusts
sshd and allowlists by username + key fingerprint.
HTTP Smart Protocol (read path live behind serve --http)
HttpSmartProtocol\Server routes the Git smart-HTTP flow and the LFS
batch API; HttpSmartProtocol\HttpListener feeds it real sockets when you
run serve --http:
- Client GETs
/repo.git/info/refs?service=git-upload-pack— a codec- framed# service=header + flush, then the advertisement bytes ofgit upload-pack --stateless-rpc --advertise-refsthemselves - Client POSTs
/repo.git/git-upload-pack— the body is fed togit upload-pack --stateless-rpc; the pack comes back side-banded - For push: Client POSTs
/repo.git/git-receive-pack— answers 501: receive-pack is phase 2; the route refuses before touching the access layer
Both advertisement routes are gated before a single ref byte is
built: git-upload-pack requires canRead (anonymous passes only for
public repos), git-receive-pack requires canWrite (fail-closed false
for anonymous callers, so the old unauthenticated receive-pack
advertisement hole is gone). Authentication uses HTTP Basic auth or the
X-CandyGitServer-User header — the header path additionally requires
user_trust_mode, trusted_proxies and auth_secret to be configured
(Server.php), otherwise the header is ignored; deactivated
(isActive === false) users authenticate to nothing on either path. The
Known gaps list below still records the plaintext-password storage and
403-without-challenge shapes as open work — the read path deliberately
ships no new credential surface.
Git Protocol (daemon class only — not assembled)
Git\GitDaemon is a class, not a service: nothing in the shipped CLI or
composer scripts constructs it, and its wire behavior still deviates from
the git-daemon spec (Known gaps #2 — its packet framing went through the
shared Git\PktLine codec in r1024/githttp, but the request-line dialect,
capabilities and negotiation gaps are open). The descriptions below
document the class surface for whoever wires it, not a promise of working
git:// clones.
Class behavior:
- Uses
pcntl_fork()to detach and become a session leader (serve()'s--daemon-style argument path) - Listens on
git.listen_addrfromconfig.yaml(default:9418) - Writes PID to the given file (or a caller-chosen default)
- Handles
SIGTERM,SIGINT(graceful shutdown) andSIGHUP - Cleans up PID file and closes all connections on exit
Signal handling:
SIGTERM/SIGINT— graceful shutdown (closes connections, removes PID file)SIGHUP— also shutdown (GitDaemon.php:1156-1158: all three signals route to the samehandleSignal(); config reload is not implemented)
Async daemon mode (ReactPHP, opt-in, library-level)
serve() runs a classic blocking socket_select() loop. serveAsync() is
the opt-in ReactPHP path: connections are accepted
and read through the event loop, so a host application could run the Git
daemon alongside timers, HTTP servers, and other sockets on one loop.
use React\EventLoop\Loop; use SugarCraft\GitServer\Config; use SugarCraft\GitServer\Git\GitDaemon; $daemon = new GitDaemon(Config::load('/var/lib/candy-git-server/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 sugar-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\GitServer\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 (class-level)
The smart-HTTP router implements the Git LFS batch API
plus basic object transfer when lfs.enabled is true (reachable through
serve --http; the routes are pinned at router level only — no end-to-end
LFS client proof ships in phase 1; repo keys use the .git-suffixed
form):
| 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-CandyGitServer-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). - TLS keys in config are refused at load (
config.tls_not_supported) — HTTPS termination is out of scope for the class.
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\GitServer\Jobs\MirrorPuller; use SugarCraft\GitServer\Repo; $mirror = Repo::new('linux', '/var/lib/candy-git-server/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. As with everything server-shaped, no scheduler host ships:
you call runOnce() (cron) or attach() (your loop).
Stats endpoint (class-level)
StatsServer serves the shared Stats counters as JSON when a host
starts it on stats.listen_addr:
use SugarCraft\GitServer\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 class (both modes), the smart-HTTP
class, 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
Clipboard/Osc52.php is a standalone OSC 52 escape-sequence encoder
(system/primary/secondary selections). No TUI consumes it — there is
no terminal interface in this package. The class is kept because a future
front end would want it; it does nothing today.
Repo Permissions
Visibility is a single SugarCraft\GitServer\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
Collaborator lists live on the Repo object in memory. There is no
storage layer: the CLI's user add/user key commands construct
throwaway objects and persist nothing (user list always reports empty),
and a host must re-supply users on every run until a store exists
(Known gaps).
Known gaps — open work on the remaining transports
Non-exhaustive inventory from the docs-review-1023 audit, renumbered
2026-10-24 (r1024/githttp, owner ruling 1b phase 1). Fixed since the
audit: the binary pkt-line framing (one Git\PktLine codec replaced all
four emitters — the byte-exact pins that deliberately recorded the broken
shape were flipped with dated comments), the unauthenticated receive-pack
advertisement (both advertisement routes are now gated before a single
ref byte is built), and the isActive skip on the HTTP auth path. Line
numbers below are as of this commit. The daemon and SSH transports remain
unreachable from the shipped CLI — plain serve refuses them — which is
why those phases still carry the scaffold label.
- Server assembly partial.
serve --httpconstructsHttpSmartProtocol\Server+HttpSmartProtocol\HttpListenerfor the read path (phase 1); nothing yet assemblesGitDaemonorSSHServer, and the oldservecommand's simulated success (banner + exit 0) remains replaced by the refusal (docs-fix-1023 N4). Push over HTTP is a pinned 501 refusal — receive-pack wiring is phase 2. - git-daemon dialect deviates from spec: bare request line without
the pkt-framed host/user delimiter (
Git/GitDaemon.php:575), no side-band multiplexing or want/have depth negotiation, capabilities joined with|where the spec uses NUL/space separators (Git/GitDaemon.php:813). Packet framing went through the shared codec in r1024/githttp; these dialect gaps remain. - Plaintext password comparison:
User::$passwordstores the raw string and is compared withhash_equals— no hashing (HttpSmartProtocol/Server.php:716,User.php). The phase-1 read path ships no new credential surface; fixing this is phase-2 push groundwork. - Proxy HMAC replayable: the signature covers only the username — no
timestamp, nonce, or request binding (
HttpSmartProtocol/Server.php:782-792). - 403 without
WWW-Authenticate: private repos answer 403 with no challenge header, so git clients never prompt for credentials (HttpSmartProtocol/Server.php:246,:281) — live behavior of the phase-1 read path under the anonymous-public doctrine. - Async receive-pack race: the loop can dispatch a push before the
pack body has arrived, yielding
unpack no packfile received(Git/GitDaemon.php:424,:848-853). SIGHUPshuts down instead of reloading — all three signals share one handler (Git/GitDaemon.php:1151).- No user/repo persistence at all:
user add/user keydiscard their objects (bin CLI,cmdUserAdd/cmdUserKey),db.driver/db.data_sourceconfig is parsed but consumed nowhere, andCANDY_GIT_SERVER_INITIAL_ADMIN_KEYSis read by no code (it survives only in a staleinithint,lang/en.phpcli.next_step_3). - Extension reality mismatch: the daemon uses ext-sockets
(
Git/GitDaemon.php:1078-1093) without declaringext-sockets(composer.json requires only json/openssl/ssh2), andext-ssh2is declared but has zero call sites (vestigial). AccessControl::allowAnonymousRead()is hardcodedtruewith no config knob (AccessControl.php:78-81) —serve --httptherefore serves every public repo to anyone by design (phase-1 doctrine); a knob belongs with the auth hardening.User::keysMatchrequires the comment field to match verbatim (whitespace-normalized) — a key offered without its stored comment fails; fail-closed but surprising (User.php:208-213).max_connectionskicks the oldest client silently — no warning, no error packet (Git/GitDaemon.php:351-352; the HTTP listener is one-request-per-connection and carries no concurrency throttle).composer serve --daemon …flags are intercepted by composer; usephp bin/candy-git-server …directly.- Stale CLI strings:
helpand theinitnext-step lines still describe a starting server; per the i18n law they cannot be fixed in one locale, so all 16 files must be touched together (deferred).
Shared foundations
The sugar-async Subscriptions machinery is wired into GitDaemon for
graceful shutdown registration (present and tested at class level; it
becomes load-bearing only once a host runs the daemon).
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem (soft-serve); SugarCraft is developed as a native PHP project.