.geml 文件只放当前版本;同名的 .gemlhistory 边车放历史:从当前版本往回的反向补丁,加上完整快照。边车自己就是一份 GEML 文档,靠 profile = "geml-history/v1" 放行三个块类型;它自带一份「已提交当前版」的镜像,所以重建任何旧版本都不依赖活文件、不依赖 git、不依赖网络。这一页把 profile 的每条规则和 geml history 四个动词的实测放在一起。
规范已定 profile 文档写死的(它自称 Stable)。实测 文档不规定、工具今天这样做。实现偏差本页未发现。
| 规则 | 出处 | 状态 | |
|---|---|---|---|
| 1 | 应用层 profile,核心一字未改;不认识它的处理器完全合规。存在由边车文件表明,词汇由边车自己的 meta 声明;不许从 .gemlhistory 扩展名或 history-of 键推断(§8.6 规则 2)。 | §1 · §1.1 | 规范已定 |
| 2 | 声明放行三个类型:history-revision(键 id parent author summary hash newline)、history-keyframe(id hash)、history-blob(lang)。放行只给名字,body 仍是 raw,与未声明时同一模型。 | §1.1 | 规范已定 |
| 3 | doc.geml 是当前版本的唯一真相,热路径;doc.gemlhistory 是自包含的冷路径,永远带一份已提交当前版的 keyframe 镜像,工具维护、不手改,两者靠哈希对账。 | §2 | 规范已定 |
| 4 | 边车丢了,当前文档完好;活文件丢了,任何版本仍可从边车重建。两个方向都不互相依赖。 | §2 | 规范已定 |
| 5 | 头部 meta 键:history-of、geml-version、current、keyframe-interval。keyframe 和 blob 的正文原样嵌整段 GEML,所以围栏必须比载荷里最长的围栏更长。 | §3 · §3.1 | 规范已定 |
| 6 | 块身份:有 #id 用 id;没有就用内容哈希加结构位置派生的键,算法实现定义,簿记只在边车里、绝不写回活文件。不强制任何块带 id。 | §4 | 规范已定 |
| 7 | 反向补丁四种操作:delete、replace … <- blob:、insert <- blob: <anchor>、move;按写的顺序应用;blob: 引用必须解析到同文件里的 history-blob,否则 error。 | §5 | 规范已定 |
| 8 | 重建:取目标版本或更新的最近 keyframe,沿 parent 链反向打补丁到目标,结果哈希必须等于记录的 hash。不依赖活文件。 | §6 | 规范已定 |
| 9 | 回滚线性、破坏性:截掉目标之后的所有版本,current 改为目标;活文件有未提交改动时必须拒绝,除非调用者明确同意(交互确认或 --force)。 | §7 | 规范已定 |
| 10 | hash 是整份 .geml UTF-8 字节的 SHA-256,前缀 sha256:,含换行风格(newline=lf|crlf)。版本 id 是 <UTC 时间戳>-<hash 前 8 位>,按时间排序;任何不含糊的前缀可作选择器。 | §8 | 规范已定 |
| 11 | 两种轻重:链断、blob 悬空、重建哈希不匹配是损坏 → error;活文件哈希与 current 不同只是未提交 → warning,不得阻止只读操作。 | §8 · §9 | 规范已定 |
| 12 | 工具生成的每个边车必须在 meta 写 profile = "geml-history/v1";agent 可以读边车,不该手写补丁、blob、id、hash,用工具的 save / get / restore / verify。 | §9 · §10 | 规范已定 |
| 13 | CLI:save 追加一版(与 tip 相同则 no-op);get 无参列出全部、带 rev 打印那一版全文;restore <rev> [--force];verify 重建并重哈希整条链。rev 写法:0 tip、-N 往回 N、或不含糊的 id 前缀。 | geml history --help | 实测 |
| 14 | geml revert <file> #id [--rev sel] 是核心动词,用边车把一个块回到过去某版:拼回、复活或删除。项目的 PostToolUse hook 对 spec/ 下的 .geml 自动 save。 | geml --help | 实测 |
编辑器、渲染器、agent 只碰 .geml;历史只在需要时加载。
keyframe-interval 个版本再放一个,把任何目标的反向步数和一条坏补丁的影响范围都限制在一段之内。两次 save 之后的文件,一字不改。它本身通过 geml check。
# History of doc.geml === meta profile = "geml-history/v1" history-of = "doc.geml" geml-version = "1.0" current = "20260904T080743Z-40a503a6" keyframe-interval = 10 === # Committed-current mirror (always present): ==== history-keyframe {id="20260904T080743Z-40a503a6" hash="sha256:40a5…b4f4"} === meta title = "history probe" === # Plan {#plan} === text {#intro} Second version of the intro. === === table {#t format=csv} a,b 1,2 === ==== %% 四杠包三杠:载荷里有 === === history-revision {id="20260904T080743Z-40a503a6" parent="20260904T080742Z-5a3b3ac6" summary="edit intro" hash="sha256:40a5…" newline="lf"} replace #intro <- blob:b1 %% 从当前版回到上一版:把 #intro 换回旧内容 === ==== history-blob {#b1 lang=geml} === text {#intro} First version of the intro. === ==== === history-revision {id="20260904T080742Z-5a3b3ac6" summary="first" hash="sha256:5a3b…" newline="lf"} === %% 根版本:没有 parent,没有补丁
$ geml list doc.gemlhistory #history-of-docgeml heading h1 L1-10 === meta meta anon L3-9 #committed-current-mirror-always-present heading h1 L11-39 === history-keyframe history-keyframe anon L12-25 === history-revision@4214327e history-revision anon L27-29 #b1 history-blob L31-35 === history-revision@e36c84ca history-revision anon L37-38 $ geml check doc.gemlhistory ok: no diagnostics ← profile 声明了,三个类型不报 unknown-block-type
| 块 | 体 | 装什么 |
|---|---|---|
| meta | 键值 | history-of、geml-version、current、keyframe-interval |
| history-keyframe | raw | 某一版的完整 .geml,当前版的那份永远在 |
| history-revision | raw | 属性是元数据,正文是把这一版变成 parent 的操作 |
| history-blob | raw | 一个块在某版的原文,被 blob:<id> 引用 |
20260904T080743Z-40a503a6 是 UTC 时间戳加内容哈希前 8 位,天然按时间排序;校验用的是完整 hash,不是这 8 位。第一次 save 后 5 秒的第二次 save,时间戳差 1 秒。===,所以它们开 ====。这正是核心 §3 的围栏纪律,边车没有发明新东西。history- 前缀,因为 §8.5 要求扩展名带连字符;裸的 revision / keyframe / blob 会占用规范保留的名字。全部在临时目录里实跑;未提交改动时 verify 只 warning 不阻止,是 §8 那条「两种轻重」。
$ geml history save doc.geml -m "first" saved 20260904T080742Z-5a3b3ac6 $ printf 'Second version of the intro.\n' | geml set doc.geml '#intro' --in - --body wrote doc.geml $ geml history verify doc.geml 活文件已改、未 save warning: uncommitted changes: hash(doc.geml) differs from current verify: OK (1 revisions reconstructed & hashed) exit 0 —— 只读操作不被阻止 $ geml history save doc.geml -m "edit intro" saved 20260904T080743Z-40a503a6 $ geml history get doc.geml 第一列就是选择器 0 20260904T080743Z-40a503a6 - edit intro -1 20260904T080742Z-5a3b3ac6 - first $ geml history verify doc.geml verify: OK (2 revisions reconstructed & hashed)
| 动词 | 做什么 | 出处 |
|---|---|---|
| save [-m] | 刷新镜像 keyframe,写反向补丁和 blob,记新 hash 与 id;与 tip 相同则 no-op | §10 |
| get [<rev>] | 无参:全部版本,最新在上;带 rev:那一版的完整文本(重建后核对 hash) | §6 |
| verify | 沿链重建每一版并重哈希;断链、blob 悬空、哈希不符 → error;仅活文件未提交 → warning | §8 · §9 |
| restore <rev> [--force] | 覆盖活文件并截断历史;有未提交改动时拒绝,--force 才丢弃 | §7 |
| revert #id [--rev] | 核心动词:只把一个块回到某版,其余不动 | geml --help |
--force。在短路径临时目录 %TEMP%\geml-hist-probe 里跑,因为会话 scratchpad 的路径长度超过 Win32 工作目录上限,node 起不来。用仓库内当前构建,1.9.2。
| 步骤 | 结果 | 对应 |
|---|---|---|
| save → set → verify → save → get → verify | 全部 exit 0,未提交时一条 warning | 动词、看板 11、13 |
| geml check doc.gemlhistory | 0 诊断 | 边车、看板 2、12 |
| geml list doc.gemlhistory | meta、keyframe、两条 revision、一个 blob | 边车 |
| revert #intro --rev -2(只有 2 版) | error offset out of range,活文件不动 | 越界不改文件 |