A Git-native, database-free CMS: article bodies in commit messages, publish by moving refs.
"I'm using
git pushas my API endpoint."
git-cms treats Git as an application substrate, not just a version-control tool. Article bodies live in commit messages on empty-tree commits. Draft and published state live in refs. History falls out of the storage model instead of being layered on afterward.
Full article: Git as CMS
If you want the runnable appendix rather than the essay, use the companion doc:
Use the reader-safe path first. It is isolated, seeded, and meant for live poking around:
git clone https://github.com/flyingrobots/git-cms.git
cd git-cms
npm run setup
npm run demo
npm run sandboxThen, in a second terminal:
npm run sandbox:shell
# The live Git repo is at $GIT_CMS_REPO
git -C "$GIT_CMS_REPO" for-each-ref refs/_blog/
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world -1 --format="%B"
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world -1 --format="tree: %T"
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world --graph --onelineOpen http://localhost:4638 while npm run sandbox is running.
| Mode | Command | Repo Location | Best For |
|---|---|---|---|
| Guided demo | npm run demo |
Disposable isolated repo inside a temporary Docker project | First contact, screencasts, fast payoff |
| Reader sandbox | npm run sandbox |
/data/repo inside the container, backed by a named Docker volume |
Blog readers, live tinkering, inspecting seeded history |
| Contributor dev | npm run dev |
/app (the bind-mounted checkout itself) |
Working on git-cms code |
Important distinction:
demoandsandboxare the safe reader paths.devis a contributor path. It intentionally uses the checkout as the Git repo.
Compatibility aliases:
npm run playgroundnpm run playground:shellnpm run playground:logs
The stunt is narrow on purpose:
- article bodies live in commit messages
- article commits point to the empty tree
- draft state lives at
refs/_blog/dev/articles/<slug> - published state lives at
refs/_blog/dev/published/<slug> - publishing is pointer movement
- restore writes a new commit from old content
- history is the storage model
The admin UI exists to prove the model is usable. It is not the point of the project.
npm run setupThis checks Docker and Docker Compose. No sibling git-stunts checkout is required.
npm run demoThe demo creates a disposable isolated repo, walks through draft creation, publish, and history, then cleans itself up.
npm run sandboxThis starts the HTTP server on port 4638 against an isolated seeded repo. The seeded state includes:
hello-worldpublished at v1- two later draft commits ahead of published
- enough history to make restore interesting immediately
npm run sandbox:shell
git -C "$GIT_CMS_REPO" for-each-ref refs/_blog/
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world -1 --format="%B"
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world -1 --format="tree: %T"
git -C "$GIT_CMS_REPO" log refs/_blog/dev/articles/hello-world --graph --oneline- The article content is stored in the commit message.
- The commit points at the empty tree.
- Publishing moves
refs/_blog/dev/published/<slug>to the current draft tip. - Restoring an old version creates a new commit instead of rewriting history.
This is the core of the stunt. Before Git can pretend to be a CMS, it has to behave like storage and state first.
The reader-safe commands are safe because they do not use the checkout as the runtime repo:
npm run demouses an isolated disposable reponpm run sandboxuses/data/repoin a Docker volumenpm testruns tests in Docker against temporary repos
Contributor dev mode is different:
npm run devbind-mounts the checkout into/app- the running app uses
/appas its Git repo - it is meant for hacking on
git-cms, not for casual exploration
If you are here because of the article, start with demo or sandbox, not dev.
- Database-free: No SQL, no NoSQL, just Git objects and refs
- Ref-native state model: Draft and published state are explicit refs, not database rows
- Fast-forward publish semantics: Published refs only move to the current draft tip
- Atomic publishes: Publish is a CAS-protected ref update
- Infinite history: Every draft save is a commit
- Optional asset encryption: Assets can be encrypted server-side via
@git-stunts/git-cas
If you are working on the codebase itself:
npm run dev
npm test
npm run test:e2e
npm run test:sandboxThere is also a contributor devcontainer in .devcontainer/devcontainer.json.
Publishing moves the published ref to the current draft tip. If a caller supplies sha, it must match the current draft ref, so the parameter acts as an optimistic-concurrency token rather than an arbitrary publish target.
Attachments are optionally encrypted server-side (AES-256-GCM via @git-stunts/git-cas) before they are committed to the repository.
- The browser uploads base64-encoded file data to the server.
- The server resolves an encryption key from
CHUNK_ENC_KEYor the local vault integration. - Git receives opaque encrypted blobs when encryption is enabled.
The current prototype assumes Git's default SHA-1 object format for HTTP endpoints that accept historical commit IDs. In practice, /api/cms/show-version and /api/cms/restore currently validate 40-character hexadecimal commit IDs.
If you want a Git-native gateway around git-cms, pair it with git-stargate.
Stargate can enforce:
- fast-forward-only pushes
- signed commits
- mirroring to public remotes
Local bootstrap example:
./scripts/bootstrap-stargate.sh ~/git/_blog-stargate.git
git remote add stargate ~/git/_blog-stargate.git
git config remote.stargate.push "+refs/_blog/*:refs/_blog/*"- Blog companion / runnable appendix: docs/GIT_CMS_COMPANION.md
- Reader walkthrough: docs/GETTING_STARTED.md
- Command and API reference: QUICK_REFERENCE.md
- Testing and safety details: TESTING_GUIDE.md
- Architecture and rationale: docs/ADR.md
- Contributor scripts: scripts/README.md
Copyright © 2026 James Ross. This software is licensed under the Apache License, Version 2.0
