# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project ## Status Accepted. ## Context The file library (`hub/src/database/filelib/`, an independent subsystem that does not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project as a versioned file tree behind the `VersionStore` port (contract C1). Until now the only implementation was `createInMemoryVersionStore`: a `Map` of per-file version chains, with `VersionId` as a per-repository monotonic counter (`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the entire storage root written to `/.version-store.json` so that a process restart did not lose the demo data. Two things about the surrounding design were already settled in code and are confirmed here rather than changed: - **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is `NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path; nothing in the filesystem mirrors it. - **Projects are flat under one root, keyed by id.** `storageDir` is `/` where `nodeId` is a `randomUUID()`. Names never enter the path, which is why `renameNode` touches no disk state and does not rewrite descendant paths. What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was documented as "the project git repository root" and `fileService` was documented as observing a "git first, then audit" ordering, but no code in the repository ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the directory was never created. Every project's entire content and history lived in one process-global JSON file. The header comment and `README.md` both marked this as a placeholder awaiting an npm package from the versioning team. That package has not arrived, and the in-memory store's properties are not acceptable for real teacher data: a corrupt or lost `.version-store.json` loses every project at once, the whole storage root is rewritten on every commit, and `VersionId` values are meaningless outside the process that minted them. ## Decision **Each file library project is a real Git repository at `/`.** `VersionStore.init` creates the directory and runs `git init` there. This is the production implementation; `createInMemoryVersionStore` is retained for tests only. **`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by `git rev-parse`. It is no longer a per-repository counter. **File-level versioning (D16) maps onto commit history as follows.** A write touches exactly one path and produces exactly one commit. The version of a file is the hash of the most recent commit that modified that path — `git log -1 -- `. Consequently: - Two files in one project have independent versions, because a commit that touches `a.md` does not appear in `git log -- b.md`. This preserves the D16 property that advancing one file does not invalidate another file's `baseVersion`, even though commits are repository-global objects. - `baseVersion` checking (S1/S2) compares the caller's id against the current per-file version. `baseVersion: null` means create, and conflicts if the path already exists at `HEAD`. - Reading version `V` of a path means `git show V:`, which is the content as of that commit, not the content the commit introduced to some other file. **Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and commits, so the path is absent from `HEAD` and `list` stops reporting it, while `git show :` still resolves. The in-memory store expressed this as a `deleted: true` chain entry; the observable API semantics are the same. **Git is invoked as a subprocess, not through a library.** `node:child_process` `execFile` with an argument array, no new npm dependency. Every invocation is hardened, and the hardening is load-bearing rather than incidental: - `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project repository is *data*, uploaded by teachers. Without this, a committed `.git/hooks/` entry or a developer's global `gitconfig` would execute or alter server-side behavior. - `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never reinterpreted as an option or as pathspec magic (`:(glob)`). - `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on credentials. - Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*` environment variables, never written into the repository's config. The git author name is the acting user's `displayName` (falling back to `userId` when absent), and the email is `@filelib.paradigm-edu.net`. The email deliberately keys on `userId` rather than the display name, because nicknames change and identity attribution must not drift with them. Characters that would break git's ident line (`<`, `>`, newlines) are stripped from the name. - `--git-dir=/.git` and `--work-tree=` are pinned on every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/ `GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise searches *upward* for a `.git`, and the storage root is frequently nested inside another repository — the local development default `hub/.filelib-repos` sits inside this very repo. Without pinning, operations on a project directory that has no repository of its own silently retarget the enclosing repository. Existence is therefore tested on the filesystem (`/.git`), not with `git rev-parse --git-dir`, which merely echoes a pinned value back. **Writes to one repository remain serialized in-process**, as under S4, because concurrent git invocations contend on `index.lock`. This is a single-process guarantee only; see Consequences. ## Consequences - `.version-store.json` is not read or migrated by the new store. Existing development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git store; those projects report `repo_not_found` until recreated. No production data exists to migrate, since the in-memory store was never production-viable. - `VersionId` changes shape in API responses (`GET .../files/*`, history, and the 409 `currentVersion` detail). Clients must keep treating it as an opaque string; `filelib-web` already does. - `VersionInfo.author` now comes back as the git author name, which is the acting user's display name at commit time (or the `userId` when no display name is known). Commits written without an author carry a fixed `filelib` identity rather than `undefined`. Display names are point-in-time: renaming a user does not rewrite existing commits, and the stable identifier stays in the email. - `VersionStore.commit`/`remove` take a structured `CommitAuthor` (`{ userId, displayName? }`) rather than a bare author string, so the port can express both the stable key and the display label. Deletion carries the same identity as any other commit. - Serialization is per-process. Two Hub processes sharing a storage root can race on the same repository and surface a git lock error rather than a clean conflict. The alpha Silo deployment (ADR-0025) is one process per organization, so this is not currently reachable; a multi-process deployment needs either a database advisory lock keyed by project id or a single writer. - `git` must be present on the host. Absence is a startup-visible failure of project creation (`provision_failed`), not a silent degradation. - Repository content is now attacker-influenced data on disk. The path validation in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths, control characters) moves from hygiene to a security boundary, and `versionStore` re-checks it rather than trusting callers. ## Alternatives considered - **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that three `execFile` calls do. `isomorphic-git` additionally reimplements the object layer, so its bugs would be ours to diagnose. - **One commit per repository state, with the repository head as the version.** Simpler mapping, but it breaks D16: any write would invalidate every other file's `baseVersion`, turning independent edits into false conflicts. - **Keeping the counter as `VersionId` alongside git.** Requires a durable counter-to-hash mapping outside git, which is the state the decision removes. - **Bare repositories with a git index-only write path.** Avoids a working tree, but every read and write becomes plumbing (`hash-object`, `update-index`, `commit-tree`), for no benefit at this scale. ## Deferred - Cross-process write serialization (advisory lock keyed by project id). - Garbage collection and pack maintenance policy for long-lived repositories. - Whether export builds (`exportService`) should read a git tree directly instead of going through the `listFiles`/`readFile` port. - Recovering `provisionStatus=FAILED` projects by re-running `init`; the status machine records the failure but nothing retries it yet.