forked from EduCraft/curriculum-project-hub
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。
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user