Files
curriculum-project-hub/docs/adr/0030-filelib-version-store-is-real-git.md
82241afb56 feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
2026-07-27 15:55:01 +08:00

9.0 KiB

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 <storageRoot>/.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 <storageRoot>/<nodeId> 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 <storageRoot>/<nodeId>. 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 -- <path>. 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:<path>, 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 <olderVersion>:<path> 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 <userId>@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=<projectDir>/.git and --work-tree=<projectDir> 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 (<projectDir>/.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.