A fully static, interactive Git course for complete beginners. Learners type real git commands into a simulated in-browser terminal (no real git binary, no WASM — the repo state machine is pure JS) and watch a live commit-graph visualization update after every command.
No dependencies and no build step. Accounts and course statistics are an
optional, equally dependency-free extra — the course itself still runs as plain
static files. node_modules/ never appears.
What is left to do lives in TODO.md.
Contributions are welcome; see CONTRIBUTING.md. For sensitive bug reports, see SECURITY.md. The public source includes worked challenge solutions and authoring drafts: this is a learning tool, not a secure assessment system. The accounts server keeps these files off its HTTP routes, but they are readable in the source repository.
There are two ways to run it, and the course is identical in both.
npm start # with accounts → http://localhost:8000
npm run serve:static # static only → http://localhost:8000
npm test # 2,900+ assertions, zero dependenciesnpm start adds optional sign-in, so progress is saved to an account and
follows a learner between devices, and gives the course owner a statistics
page. npm run serve:static is the plain file server: no accounts, progress
kept in each browser. Either way there is no build step and nothing to install
— the server is Node's own http module and nothing else.
Caddy:
yourdomain.com {
root * /path/to/first-commit
file_server
}Note: the app loads
content/course.jsonviafetch(), so it must be served over http(s) — openingindex.htmlviafile://will not work.
index.html— public landing page (hero, curriculum, YouTube embed slot)app.html— the course applicationadmin.html— course statistics, for the owner
first-commit/
├── content/course.json # ALL course data: modules, lessons, exercises, checks
├── engine/
│ ├── git-engine.js # simulated git state machine — pure JS, zero DOM deps
│ └── validators.js # state-based exercise validation + the step cascade
├── server/ # OPTIONAL accounts backend — zero dependencies
│ ├── index.js # http server: static allowlist + JSON API + CSP
│ ├── api.js # auth / progress / account / admin routes
│ ├── auth.js # scrypt hashing, sessions, tokens, rate limiting
│ ├── mail.js # file + SMTP transports, and the two messages
│ ├── store.js # one JSON document, written atomically, migrated
│ └── stats.js # progress documents → the owner's numbers
├── ui/
│ ├── app.js # controller: routing, exercise orchestration
│ ├── terminal.js # terminal emulator (history, tab-completion, colorizing)
│ ├── graph.js # SVG commit-graph renderer (the core teaching visual)
│ ├── filetree.js # working dir / staging / HEAD file-state panel
│ ├── lesson.js # content-block renderer (escaped, minimal inline md)
│ ├── landing.js # renders the landing curriculum + video from course.json
│ ├── states.js # the #/states gallery: every UI state, live
│ ├── icons.js # the seven inline SVG marks the interface uses
│ ├── auth.js # sign-in dialog + the top-bar account control
│ ├── admin.js # the course-statistics page
│ └── progress.js # ProgressStore interface ← ★ BACKEND SEAM
├── css/
│ ├── tokens.css # design tokens, base reset, shared primitives, @font-face
│ ├── app.css # course app — three layouts over one DOM
│ ├── landing.css # landing page
│ └── admin.css # course statistics
├── assets/fonts/ # self-hosted latin subsets (Archivo + JetBrains Mono)
├── tests/
│ ├── engine.test.js # engine + validators (306 assertions)
│ ├── content.test.js # course.json valid AND solvable (2262)
│ ├── ui.test.js # UI ↔ HTML contract, a11y (137)
│ ├── server.test.js # accounts, progress, stats, safety (149)
│ ├── progress.test.js # the browser's progress stores (37)
│ └── fixtures-solutions.json # a worked solution for every challenge
├── tools/ # authoring tools, not shipped to learners
│ ├── check-module.mjs # validate one drafted module + prove its challenge solvable
│ ├── merge-drafts.mjs # splice drafts into course.json, refusing on any regression
│ ├── lint-course.mjs # cross-module coherence: ordering, xrefs, vocabulary
│ └── course-to-md.mjs # render the course as readable Markdown for proof-reading
├── index.html # landing page
├── app.html # course app shell
└── admin.html # course statistics (owner only)
Separation of concerns: content is data (/content), the git simulation is a
pure, unit-testable library (/engine), and the DOM lives only in /ui. The
engine can be driven headlessly (that's exactly what the tests do).
The shell has two shapes, switched by data-mode on <body>. read is the
lesson reader: one centred column, no workspace. exercise brings up the four
regions an exercise needs, and has two layouts of its own, set by data-layout:
split— instructions rail on the left, commit graph above the terminal on the right, file state as an overlay toggled from the exercise bar.focus— one panel at a time (steps / graph / files) above a pinned terminal, with a "now" bar naming the current step. Forced below 1000px, which is what makes all four regions usable on a 360px phone.
Both layouts are the same DOM; only CSS and three attributes change, so nothing is re-rendered when the learner switches.
The UI talks only to the ProgressStore interface in ui/progress.js. Two
implementations ship — LocalStorageProgressStore (this device) and
RestProgressStore (the signed-in account) — and chooseStore() picks one at
boot from whether there is a server and whether anyone is signed in. Nothing
above the seam knows which it got, and tests/ui.test.js asserts that no other
module in ui/ so much as mentions localStorage.
The progress document is versioned JSON ({version, completedLessons, lastLessonId, updatedAt, resetAt}), which is also exactly what the API stores,
so replacing this server with a different one is a matter of matching four
routes — and one rule. A save folds into the stored copy; it never replaces
it. Every open tab saves its whole copy each time it shows a lesson, so
replacing let a tab left open on an old copy (or a laptop left open while the
learner carried on from a phone) erase everything finished since. Completions
only accumulate, except across a reset: resetAt marks when progress was last
erased, and a copy from before it gets the reset rather than restoring its old
list. A copy with no resetAt has not heard either way and is folded in as
current. Both stores apply the rule (foldProgress in ui/progress.js and
server/api.js), and both answer a save with what they now hold, which is how
an open page picks up lessons finished elsewhere.
A save the server refuses or never receives — offline, session over, address not yet confirmed — is not lost with the tab: it is kept on the device under the account's id and goes with the next save, or the next sign-in, that gets through.
Optional, and optional all the way through: without the server the control never appears, and with it a learner can finish the whole course as a guest. What an account buys is progress that survives a new laptop.
npm start # http://localhost:8000
PORT=3000 # port to listen on
FC_DATA=./data/fc.json # where the JSON document lives
FC_OWNER_EMAILS=you@example.com # who owns the course
FC_BASE_URL=https://course.example.com # public origin, for email links
FC_SMTP_URL=smtps://user:pass@smtp.host:465 # send real email
FC_MAIL_FROM=course@example.com # envelope sender
FC_REQUIRE_VERIFICATION=1 # unconfirmed accounts cannot save
FC_SECURE_COOKIES=1 # behind HTTPSOwnership is what opens /admin.html and everyone's progress with it.
- With
FC_OWNER_EMAILSset, ownership comes from that list and nowhere else. Strangers who register are learners, however early they arrive. - Without it, the first account to register takes ownership. That is convenient on a laptop and a real hole on a public host: between starting the server and signing up yourself, whoever finds the URL first becomes the owner.
The server prints a loud warning while an instance is unclaimed. Do not ignore it on anything reachable from the internet.
Everything above still applies; three more things start to matter once sign-up is public.
Set FC_TRUST_PROXY to the number of reverse proxies in front of you —
almost always 1, because that is how you got HTTPS. Without it every request
looks like it came from the proxy, so all rate limiting collapses into one
bucket shared by the whole internet: one attacker locks out every real learner,
and the per-client protection is worth nothing.
Sign-up sends mail to whatever address is typed, which makes any open
course a potential machine for delivering mail to strangers from your domain.
There is a ceiling on outbound mail for the whole instance —
FC_MAIL_MAX_PER_HOUR, default 100 — and going over it logs loudly and drops
the message rather than sending it. Accounts are still created; mail is
best-effort and never a gate.
Consider FC_REQUIRE_VERIFICATION=1. Unconfirmed accounts can still read
the course but cannot save progress, which makes throwaway sign-ups pointless
without making the course unusable for someone who has not checked their mail
yet.
A working public configuration:
FC_OWNER_EMAILS=you@example.com \
FC_BASE_URL=https://course.example.com \
FC_TRUST_PROXY=1 \
FC_SECURE_COOKIES=1 \
FC_REQUIRE_VERIFICATION=1 \
FC_SMTP_URL=smtps://user:pass@smtp.example.com:465 \
FC_MAIL_FROM=course@example.com \
npm startHow many people it holds. Every write rewrites the whole data file, so cost
is linear in accounts. Measured, with each learner carrying progress and a live
session: 100 users → 0.9 ms per save, 1,000 → 2.5 ms, 5,000 → 10.3 ms. Writes
are serialised, so even 5,000 accounts leaves room for roughly a hundred saves a
second, far more than a course generates. Comfortable into the low thousands;
past that, server/store.js is the one file to replace and nothing above it
needs to change.
FC_SMTP_URL is the only thing standing between you and working verification
and password reset. Without it nothing is lost — messages are written to
data/outbox/ and the link is printed to the console, which is enough to run a
course for people you know. With it, sign-up confirmation and self-service
password reset work the way people expect.
Only two messages are ever sent: confirm your address, and reset your password.
PRIVACY.md records what is stored, for how long, who can see it, and how to get rid of it. Learners can export everything the server holds about them and delete their account from the account menu; the owner can reset or delete a learner from the statistics page.
Signing in merges rather than replaces: whatever a guest finished on that device is unioned into the account, because losing three modules of work at the moment you sign up is how you lose the learner too.
| Storage | One JSON document, written atomically. data/ is git-ignored. |
| Passwords | scrypt, per-user salt, constant-time compare. Ten characters minimum. |
| Sessions | Opaque random tokens; only their SHA-256 is stored. HttpOnly, SameSite=Lax, 30 days. |
| CSRF | SameSite plus a custom header no cross-origin form can set. |
| Guessing | Rate-limited per address and client, so nobody can lock a learner out on purpose. |
| Enumeration | "Wrong password" and "no such account" return the same message. |
| Served files | An allowlist. data/, tests/, drafts/ and server/ are not reachable over HTTP — the accounts file and the challenge solutions both live there. |
| Reset links | Single-use, one hour, and using one signs out every other device. |
| Ownership | From FC_OWNER_EMAILS when set; otherwise first-to-register, with a warning. |
The owner's view, computed server-side from the same progress documents the learners write, so it cannot disagree with what a learner sees:
- Learners, active this week, average completion, how many have finished.
- Per module, the share of learners who completed every unit in it.
- Where the course loses people — every unit in order, how many finished it, and the drop from the unit before. This is the one that changes what you write next.
- Every learner: completion, which module they are on, what they last opened, when they were last seen.
engine/git-engine.js models: a fake flat filesystem, the index/staging area,
commits as an immutable DAG, branches as pointers, HEAD (attached or detached),
merge state (incl. conflict markers + MERGE_HEAD semantics), simulated remotes
with per-remote tracking refs and upstreams, a stash, and a reflog.
Supported commands: init · status · add [-f] · restore [--staged] · commit [-m|-a|-am|--amend] · log [--oneline|-n] · diff [--staged|<ref>|<refA> <refB>] · show [<ref>] · branch [-d|-D] · switch [-c|--detach] · checkout [-b|--|<commit>] · merge [--abort] · reset [--soft|--mixed|--hard] · revert · rebase · stash [push|pop|apply|list|drop|clear] · remote [add|-v] · push [-u] · fetch · pull · reflog, plus shell basics
(ls · cat · echo >/>> · touch · rm · pwd · clear) and .gitignore patterns.
Output format and error messages mirror real git (root-commit lines, diffstat,
"Your local changes … would be overwritten", detached-HEAD advice, non-fast-forward
push rejection with the fetch first hint, etc.) so learners transfer cleanly
to the real tool. Where the sandbox genuinely can't do something — git clone
has no network to reach — it says so and points at what to do instead, rather
than pretending the command doesn't exist.
Sandbox limitations (deliberate, and worth knowing before you write a lesson):
the filesystem is flat-ish — paths with / work, but there is no mkdir or
cd; rebase handles linear branches only and aborts on conflict; stash pop
restores its snapshot wholesale rather than three-way merging it; .gitignore
supports name, *.ext and dir/ patterns, not negation or globstars.
Validation is state-based. Exercises pass when the resulting repo state
matches declarative checks (fileCommitted, commitTouched {exact},
mergeCommitAt, remoteUpToDate, …) — never by matching typed command strings.
The one pragmatic exception is commandRan (semantic: parsed command + success),
used only for read-only steps like git status in guided walkthroughs. The
content test enforces that challenges never use it.
Everything lives in content/course.json. A lesson is concept, guided, or
challenge; modules also get a recap. Useful pieces:
- Body blocks:
{"p": …},{"h": …},{"list": […]},{"code": …},{"analogy": …},{"tip": …},{"warn": …}, and{"graph": {"ops": […], "caption": …}}— graph blocks are rendered by running the ops through a throwaway engine and drawing the result, so diagrams can never drift from engine behavior. The recognised keys are exported asBLOCK_KINDSfromui/lesson.js; an unrecognised block warns in the console and fails the tests. - Exercise setup ops:
init,write,add,commit,branch,switch,switchCreate,merge,remote,remoteCommit(simulates a teammate pushing — used to teach pull/rejected-push). The list is exported asSETUP_OPSfromengine/git-engine.js; a typo'd op is reported inengine.setupWarningsinstead of being silently skipped. - Checks: see
engine/validators.jsfor the full catalog; every check takes a human-readablelabelshown in the live goal checklist.
All 11 modules ship: 34 lessons (12 concept, 11 guided, 11 challenge) plus 11 module recaps, 472 content blocks and 32 live commit-graph diagrams.
| # | Module | Teaches |
|---|---|---|
| 1 | Why Git Exists | version control from first principles; snapshots, not diffs |
| 2 | Your First Repository | init, status, add, commit -m |
| 3 | Staging vs Committing | the three areas; crafting precise commits; restore --staged |
| 4 | Status & Diff | reading status; the unified diff format; diff --staged, diff <ref>, show |
| 5 | Branching | a branch is a pointer; switch -c, switch, branch -d; HEAD |
| 6 | Merging & Conflicts | fast-forward vs three-way; conflict markers; merge --abort |
| 7 | Remotes & Push/Pull | origin/main as cached memory; push -u, fetch vs pull; rejected pushes |
| 8 | Collaboration Basics | the feature-branch loop end to end; pull requests conceptually |
| 9 | Undoing Mistakes | reset soft/mixed/hard, revert, stash, --amend, reflog |
| 10 | .gitignore | ignore patterns; what belongs in a repo; add -f; committed secrets |
| 11 | Commit Hygiene | message anatomy; atomic commits; history as communication |
Author into drafts/module-<N>.json — {"module": {...}, "solutions": {...}},
where solutions maps each challenge lesson id to the commands that solve it —
then:
node tools/check-module.mjs drafts/module-5.json --verbose # iterate until "ready to merge"
node tools/merge-drafts.mjs # dry run
node tools/merge-drafts.mjs --write # splice into content/course.jsoncheck-module splices your module into the real course, runs the full content
test, replays every guided walkthrough, and replays your challenge solutions —
printing the exact check that missed and the terminal transcript when something
fails. merge-drafts refuses to write if any module regresses, if lesson ids
changed (they are URLs and progress-store keys), or if a module nobody drafted
was modified.
Nothing else needs touching: the landing-page curriculum and the in-app sidebar
both render from course.json.
Lesson ids are permanent — they are URLs and progress-store keys, so a lesson
inserted between m2l1 and m2l2 is m2l1b, not a renumbering. Order comes
from the array, never from the id.
lint-course reports competing words for the same idea. Some of that drift is
real and some of it is teaching, so the calls below are made and should not be
re-litigated every time the linter prints them:
| It says | The call |
|---|---|
repo (81) vs repository (50) |
Both, deliberately. Module 1 introduces it as A repository ("repo"), because "repo" is what every real conversation and every tutorial says. Refusing the shorthand would leave a beginner unable to read anything outside this course. |
snapshot (33) vs commit |
Both, deliberately. "Snapshot" is the metaphor that makes a commit make sense; "commit" is its name. The course teaches the first and then uses the second. |
save point (2) |
Kept. It appears once, introducing the video-game analogy in module 1, and is never used as a bare synonym. |
the stage (1) |
A false positive — it is matching "the staged file". |
working tree (7) |
Kept where it quotes real git status output, which says "working tree clean". Changing it would make the course disagree with the terminal. |
your folder (4) |
Kept only where it is deliberately plain language for a beginner. Where the precise idea was being taught, it now says working directory. |
The rule behind all of these: precise terms where the learner needs to recognise them later, plain words where they only need to understand the idea now.
npm test enforces this authoring contract:
- lesson ids are unique, routable, and don't collide with the generated
<module>-recapids or theplaygroundroute; - a module's
statusand its lessons'comingSoonflags agree; - every body block, setup op and check kind is one the code implements, and
every check carries a
label; - no exercise is already solved by its own setup (a challenge that passes before the learner types anything, or a guided step that opens pre-ticked);
- every guided walkthrough completes when you type exactly the commands it
displays — each step's
cmdis replayed through the sameadvanceStepscascade the browser runs, so a step whose instructions don't actually satisfy its own checks fails CI.
That last one is why every guided step must carry a cmd. If a step needs two
commands, put both in it separated by a newline — the panel renders them as a
block and the test replays them in order.
npm test # everything below, in order
npm run test:engine # engine + validators
npm run test:content # course.json validity, completability, solvability
npm run test:ui # UI ↔ HTML contract and a11y guarantees
npm run lint:course # cross-module coherence report (full detail)-
engine.test.js covers init/add/commit/status flows, diff (
--staged,<ref>, and<refA> <refB>),git show, unstaging, branching, fast-forward + three-way merges, conflict → resolve → merge-commit,merge --abort, blocked checkouts, detached HEAD, reset (soft/mixed/hard), revert, rebase,commit --amend,stash,add -fvs.gitignore, push/reject/pull cycles across multiple remotes, reflog, all validators, the guided-step cascade, and the UI adapter snapshots. -
content.test.js validates and executes
content/course.json(above) — the bulk of the suite, because every lesson, block, check and walkthrough in all 11 modules is verified individually. It also replays a worked solution for every challenge fromtests/fixtures-solutions.jsonand requires the checks to go green, so a challenge can never be stated in a way nothing satisfies. Add a module, add its solution there too. -
lint-course.mjs catches what only shows up reading all 11 modules together: an exercise running a command no earlier lesson introduced, a cross-module reference pointing at a module that doesn't exist, a sandbox-only command quoted as if real. Those are hard errors and fail CI. It also reports vocabulary drift, duplicated sentences and length outliers for a human to judge — those never fail the build.
-
ui.test.js asserts every
$('#id')inui/resolves to an element the HTML defines or the app creates, that both shells load their modules over relative paths, and that the accessibility guarantees (focus ring,.sr-only, reduced-motion block, the graph's live region) are still in place. -
server.test.js boots the real server against a scratch file and talks to it over HTTP. It asserts the things that are silent when they break: that a near-miss password fails, that sign-out really ends the session, that one learner cannot read or overwrite another's progress, that a learner cannot open the statistics, and that the accounts file and the challenge solutions are not reachable over HTTP. It also replays the ways progress used to go missing: an older tab saving over a newer one, two saves overtaking each other, and a reset undone by a tab that never heard of it.
-
progress.test.js runs
ui/progress.jsitself against a shared stand-in forlocalStorageand the real server: a guest's two tabs, a save made offline and delivered on the next visit, and work done before confirming an address arriving once it is confirmed.
There is a fifth check that CI cannot run, because it needs a browser and a live server:
npm start
node tools/sweep.mjs http://localhost:8000
For account interactions, run node tools/sweep.mjs --accounts. It starts its
own disposable server and checks real forms, account transitions, and admin
writes without touching deployment accounts. For a plain file server, run
node tools/sweep.mjs http://localhost:8000 --static. The browser tool needs
Node 22+ and Chrome; the app and backend still support Node 18+.
The sweep now visits all 47 lesson/recap routes and exercises actual outcomes.
See the independent sweep report for findings,
reproductions, regression coverage, and remaining manual checks, and
the October sweep for the progress-saving fixes
and what is still open.
It drives a real browser over the DevTools protocol and reports dead controls (a button with no handler, a submit button outside its form), broken aria wiring, duplicate ids and console errors, across every route and dialog. That is the class of defect the other four structurally cannot see — a submit button rendered outside its form once shipped looking perfect and doing nothing, with every test green. Run it before a deploy; it exits non-zero on findings.
CI runs all four on Node 18, 20 and 22 (.github/workflows/test.yml).
Keyboard focus is visible everywhere (:focus-visible), the sidebar closes on
Escape and reports state via aria-expanded, the active lesson is marked
aria-current, and the commit graph — the course's core visual — is narrated
into an aria-live region that updates after every command. All animation and
programmatic scrolling backs off under prefers-reduced-motion.
Colour is never the only carrier of meaning. Terminal output prints a glyph in a
fixed gutter (› command, ! error, + addition, − deletion) as well as
colouring the line; file rows carry a glyph and the state spelled out; and a
challenge condition says met or waiting next to its filled or hollow disc.
- 100% static output — no bundler, no framework, no CDN dependencies, no absolute paths (safe behind any reverse proxy / subpath). The accounts backend is optional and equally dependency-free; without it the course is unchanged.
- ES modules require a normal web server (see Quick start).
- Type is self-hosted from
assets/fonts/— the latin subsets of Archivo (one variable file, 200–700) and JetBrains Mono at 400/500, ~115 KB in all. Nothing is fetched from Google Fonts or anywhere else. - The YouTube embed appears automatically once
meta.youtubeVideoIdis set incontent/course.json— there is no HTML to edit.
The code and the course content in this repository are MIT — see
LICENSE. That covers everything under version control here, the
lesson prose in content/course.json included.
The two typefaces in assets/fonts/ are not covered by that, and are not
ours to relicense. Both are under the SIL Open Font License 1.1, which allows
bundling them but requires their copyright notice and licence text to travel
with the font files:
| Typeface | Copyright | Licence |
|---|---|---|
| Archivo | 2020 The Archivo Project Authors | assets/fonts/OFL-Archivo.txt |
| JetBrains Mono | 2020 The JetBrains Mono Project Authors | assets/fonts/OFL-JetBrainsMono.txt |
Both are shipped as latin subsets, which the OFL permits as a Modified Version. assets/fonts/NOTICE.md records what was changed. If you fork this, keep those files — dropping them is the one thing here that would actually breach a licence.
Nothing else is vendored: no framework, no CDN, no analytics, no npm packages.