内存 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。
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
FOLDERnode has no on-disk existence.FileLibNode.storageDirisNULLfor folders. The tree isparentIdplus thepathIdsmaterialized path; nothing in the filesystem mirrors it. - Projects are flat under one root, keyed by id.
storageDiris<storageRoot>/<nodeId>wherenodeIdis arandomUUID(). Names never enter the path, which is whyrenameNodetouches 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.mddoes not appear ingit log -- b.md. This preserves the D16 property that advancing one file does not invalidate another file'sbaseVersion, even though commits are repository-global objects. baseVersionchecking (S1/S2) compares the caller's id against the current per-file version.baseVersion: nullmeans create, and conflicts if the path already exists atHEAD.- Reading version
Vof a path meansgit 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, plusGIT_CONFIG_GLOBAL=/dev/nullandGIT_CONFIG_SYSTEM=/dev/null. A project repository is data, uploaded by teachers. Without this, a committed.git/hooks/entry or a developer's globalgitconfigwould execute or alter server-side behavior.GIT_LITERAL_PATHSPECS=1and--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'sdisplayName(falling back touserIdwhen absent), and the email is<userId>@filelib.paradigm-edu.net. The email deliberately keys onuserIdrather 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>/.gitand--work-tree=<projectDir>are pinned on every invocation, andGIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE/GIT_OBJECT_DIRECTORYare 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 defaulthub/.filelib-repossits 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 withgit 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.jsonis not read or migrated by the new store. Existing development data underHUB_FILELIB_STORAGE_ROOTdoes not appear in the git store; those projects reportrepo_not_founduntil recreated. No production data exists to migrate, since the in-memory store was never production-viable.VersionIdchanges shape in API responses (GET .../files/*, history, and the 409currentVersiondetail). Clients must keep treating it as an opaque string;filelib-webalready does.VersionInfo.authornow comes back as the git author name, which is the acting user's display name at commit time (or theuserIdwhen no display name is known). Commits written without an author carry a fixedfilelibidentity rather thanundefined. Display names are point-in-time: renaming a user does not rewrite existing commits, and the stable identifier stays in the email.VersionStore.commit/removetake a structuredCommitAuthor({ 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.
gitmust 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, andversionStorere-checks it rather than trusting callers.
Alternatives considered
isomorphic-gitorsimple-git. Both add a dependency to carry work that threeexecFilecalls do.isomorphic-gitadditionally 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
VersionIdalongside 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 thelistFiles/readFileport. - Recovering
provisionStatus=FAILEDprojects by re-runninginit; the status machine records the failure but nothing retries it yet.