多版本管理(快照)
OpenViking 在 VikingFS 之上提供了一套基于 Git 的多版本管理能力,称为快照(Snapshot)。它把某个账号(account)下的整棵资源树保存成一系列不可变的提交(commit),让你能够回溯历史、对比版本,并把工作区恢复到任意一个历史状态。
快照能力底层由内嵌在 Rust RAGFS 层的 gitoxide 驱动,按 account_id 维护一个逻辑 Git 仓库(每个账号一个仓库),对调用方完全透明——你无需关心 .ovgit 目录、对象库或引用细节。
四个核心命令:
| 命令 | 作用 |
|---|---|
commit | 把当前工作区状态保存成一个新快照 |
log | 从最新提交开始回溯历史 |
show | 查看某个提交的元数据,或读取该提交中某个文件的内容 |
restore | 把目录(或整棵账号树)恢复到某个历史快照的状态 |
此外还提供账号级 .ovgitignore 排除规则的管理命令(get/set/delete),用于在 commit 时按规则排除匹配的文件。详见 ignore 管理。
核心概念
- 提交(commit):一个快照对应一个提交,由 40 位十六进制的 SHA-1
commit_oid唯一标识。多数命令也接受 OID 的缩写前缀,或分支名(如main)。 - 分支(branch):默认分支为
main。除非显式传入,所有命令都作用在main上。 - 正向恢复(forward-commit restore):
restore不会回退或改写历史。它会读取source_commit的内容,把差异写回工作区,并在当前 HEAD 之上生成一个新的提交。因此新提交的父提交是恢复操作发生前的 HEAD,而不是source_commit。HEAD 始终单调向前推进,历史永远不会丢失。 - 作用范围:
commit可以通过paths限定只快照部分 URI;restore可以通过project_dir限定只恢复某个子目录,目录之外的文件保持不变。
API 实现介绍
- HTTP 路由:snapshot.py,前缀
/api/v1/snapshot。 - 命名空间(SDK):snapshot_namespace.py,暴露为
client.snapshot.*。 - 底层语义实现:viking_fs.py 的
commit/restore/show/log。 - CLI 命令:main.rs 的
SnapshotCmd,子命令 snapshot.rs。
API 参考
commit()
把当前工作区状态保存成一个新的快照。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| message | str | 是 | - | 提交说明 |
| paths | List[str] | 否 | null | 限定本次快照的 viking:// URI 列表,条目可以是文件或目录;目录会按照快照的剪枝规则递归展开。null 表示对整棵账号树做快照。传入空列表 [] 表示显式的空路径集(不会产生改动)。如果某个路径在 VFS 和前一次快照中都不存在,会输出一条 warn,并按"对该名称下任何子树执行删除"处理 |
| branch | str | 否 | main | 要推进的分支 |
| author_name | str | 否 | null | 覆盖默认的提交者名字(默认 viking-bot) |
| author_email | str | 否 | null | 覆盖默认的提交者邮箱 |
Python SDK (Embedded / HTTP)
result = client.snapshot.commit(
message="v1 initial import",
paths=["viking://resources/my_md.md"],
)
print(result["commit_oid"])TypeScript SDK
console.log(await client.gitCommit({ message: "Update docs", paths: ["resources/docs"] }));HTTP API
POST /api/v1/snapshot/commitcurl -X POST "http://localhost:1933/api/v1/snapshot/commit" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"message": "v1 initial import",
"paths": ["viking://resources/my_md.md"]
}'CLI
ov snapshot commit -m "v1 initial import" --paths viking://resources/my_md.md -o json响应
新建快照时:
{
"status": "ok",
"result": {
"result": "created",
"commit_oid": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2",
"changed": 3,
"ignored": 1
}
}changed 为本次提交中新增/修改/删除的路径数;ignored 为本次被账号 .ovgitignore 规则排除的候选路径数(系统内置剪枝不计入)。当工作区相对上一次提交没有任何变化时返回 noop,commit_oid 为当前 HEAD(noop 同样返回 ignored,但不含 changed):
{
"status": "ok",
"result": {
"result": "noop",
"commit_oid": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2",
"ignored": 0
}
}log()
从某个分支的 HEAD 开始,沿首个父提交(parents[0])逐层回溯历史,按时间从新到旧返回提交列表。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| branch | str | 否 | main | 要回溯的分支 |
| limit | int | 否 | 20 | 最多返回的提交数量。HTTP 接口限制范围为 1–500 |
Python SDK (Embedded / HTTP)
history = client.snapshot.log(limit=10)
for commit in history:
print(commit["oid"], commit["message"])TypeScript SDK
console.log(await client.gitLog("main", 20));HTTP API
GET /api/v1/snapshot/log?branch={branch}&limit={limit}curl -X GET "http://localhost:1933/api/v1/snapshot/log?branch=main&limit=10" \
-H "X-API-Key: your-key"CLI
ov snapshot log --limit 10 -o json响应
result 是一个提交元数据列表,每个元素与 show() 返回的提交元数据结构相同:
{
"status": "ok",
"result": [
{
"oid": "9a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3",
"tree": "11223344556677889900aabbccddeeff00112233",
"parents": ["3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2"],
"author": {
"name": "viking-bot",
"email": "bot@openviking.local",
"time_seconds": 1750300000,
"tz_offset_seconds": 28800
},
"committer": {
"name": "viking-bot",
"email": "bot@openviking.local",
"time_seconds": 1750300000,
"tz_offset_seconds": 28800
},
"message": "v2 modify delete add"
}
]
}当分支还没有任何提交时,HTTP 接口返回
404 NOT_FOUND。
show()
查看某个提交的元数据;如果同时指定 path,则返回该提交中对应文件的内容。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| target_ref | str | 是 | - | 提交 OID(支持缩写前缀)、分支名或标签 |
| path | str | 否 | null | 某个文件的 viking:// URI;省略时返回提交元数据 |
Python SDK (Embedded / HTTP)
# 查看提交元数据
meta = client.snapshot.show("3f2a1b9c")
print(meta["message"], meta["parents"])
# 读取该提交中某个文件的内容
blob = client.snapshot.show("3f2a1b9c", path="viking://resources/my_project/guide.md")TypeScript SDK
console.log(await client.gitShow("main", "viking://resources/docs/api.md"));注意:带
path读取文件内容时,Embedded(本地)客户端直接返回原始bytes;HTTP 客户端返回{"oid": str, "size": int, "bytes": bytes}字典。
HTTP API
GET /api/v1/snapshot/show?target_ref={ref}[&path={uri}]# 提交元数据(返回 JSON)
curl -X GET "http://localhost:1933/api/v1/snapshot/show?target_ref=3f2a1b9c" \
-H "X-API-Key: your-key"
# 读取文件内容(返回二进制流)
curl -X GET "http://localhost:1933/api/v1/snapshot/show?target_ref=3f2a1b9c&path=viking://resources/my_project/guide.md" \
-H "X-API-Key: your-key"不带 path 时返回提交元数据 JSON;带 path 时返回原始字节流(Content-Type: application/octet-stream),并附带两个响应头:
X-Snapshot-Oid:blob 对象的 OIDX-Snapshot-Size:blob 字节数
CLI
# 提交元数据
ov snapshot show 3f2a1b9c -o json
# 读取文件内容(默认输出到 stdout,可用 --out-file 写入本地文件)
ov snapshot show 3f2a1b9c --path viking://resources/my_project/guide.md --out-file ./guide.md响应(提交元数据)
{
"status": "ok",
"result": {
"oid": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2",
"tree": "00112233445566778899aabbccddeeff00112233",
"parents": [],
"author": {
"name": "viking-bot",
"email": "bot@openviking.local",
"time_seconds": 1750299000,
"tz_offset_seconds": 28800
},
"committer": {
"name": "viking-bot",
"email": "bot@openviking.local",
"time_seconds": 1750299000,
"tz_offset_seconds": 28800
},
"message": "v1 initial import"
}
}restore()
把某个目录(或整棵账号树)恢复到 source_commit 时的状态。
这是正向恢复:它会计算 source_commit 与当前 HEAD 之间的差异并写回工作区,然后在当前 HEAD 之上生成一个新的提交。新提交的父提交是恢复前的 HEAD(而非 source_commit),历史不会被改写。project_dir 之外的文件保持不变。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| source_commit | str | 是 | - | 要恢复到的来源:提交 OID(支持缩写前缀)、分支名或标签 |
| project_dir | str | 否 | null | 要恢复的子目录 viking:// URI;省略时恢复整棵账号树 |
| branch | str | 否 | main | 要推进的分支 |
| dry_run | bool | 否 | false | 仅计算并返回差异,不做任何写入 |
| message | str | 否 | null | 新提交的说明;省略时自动生成 |
| author_name | str | 否 | null | 覆盖默认的提交者名字 |
| author_email | str | 否 | null | 覆盖默认的提交者邮箱 |
Python SDK (Embedded / HTTP)
result = client.snapshot.restore(
project_dir="viking://resources/my_project",
source_commit="3f2a1b9c",
message="restore to v1",
)
print(result["result"], result["new_commit_oid"])
# 先预演,确认要改动哪些文件
plan = client.snapshot.restore(
project_dir="viking://resources/my_project",
source_commit="3f2a1b9c",
dry_run=True,
)
print(plan["diff"])TypeScript SDK
console.log(await client.gitRestore({
projectDir: "viking://resources/docs",
sourceCommit: "3f2a1b9c",
}));HTTP API
POST /api/v1/snapshot/restorecurl -X POST "http://localhost:1933/api/v1/snapshot/restore" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"project_dir": "viking://resources/my_project",
"source_commit": "3f2a1b9c",
"message": "restore to v1"
}'CLI
# 位置参数依次为 <source_commit> <project_dir>
ov snapshot restore 3f2a1b9c viking://resources/my_project -m "restore to v1" -o json
# 预演
ov snapshot restore 3f2a1b9c viking://resources/my_project --dry-run -o json响应(applied)
成功写入并生成新提交时,result 为 applied。注意 parent_commit 等于恢复前的旧 HEAD,印证了正向恢复语义:
{
"status": "ok",
"result": {
"result": "applied",
"new_commit_oid": "c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f50",
"source_commit": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2",
"parent_commit": "9a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3",
"written": 1,
"deleted": 1,
"unchanged": 1,
"written_paths": ["resources/my_project/guide.md"],
"deleted_paths": ["resources/my_project/changelog.md"],
"task_id": "snapshot_restore_reindex-..."
}
}当恢复产生向量副作用(写入/删除文件)时,响应会附带一个 task_id,可通过 GET /api/v1/tasks/{task_id} 轮询后台向量重建进度。
响应(noop)
来源与当前状态字节级一致、无需改动时返回 noop,不生成新提交:
{
"status": "ok",
"result": {
"result": "noop",
"head": "9a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3",
"source": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2"
}
}响应(dry_run)
dry_run=true 时只返回计划差异,不做任何写入。差异中的路径均相对于 project_dir:
{
"status": "ok",
"result": {
"result": "dry_run",
"head": "9a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3",
"source": "3f2a1b9c4d5e6f70819293a4b5c6d7e8f9a0b1c2",
"diff": {
"to_write": [{"path": "guide.md", "oid": "..."}],
"to_delete": ["changelog.md"],
"unchanged": ["notes/todo.md"]
}
}
}ignore 管理
账号根目录下的 .ovgitignore 是账号级排除规则文件。在 commit 时,匹配规则的文件被排除出快照;规则文件本身不会被 .ovgitignore 规则忽略(即使规则匹配 .ovgitignore 也不会被排除),且不进入向量索引。规则只影响 commit,不影响 restore/show/log。
语法为常见 glob 子集:空行被忽略、# 开头为注释、行首尾空白被裁剪;不支持 ! 取反与反斜杠转义;文件大小上限 64 KiB(写入时即校验)。匹配路径为账号相对 Git 树路径(/ 分隔)。
提供三个方法:get_gitignore(读取,缺失返回空串)、set_gitignore(写入)、delete_gitignore(删除,缺失即成功、幂等)。三者都只需请求上下文中的账号,无路径参数。
get_gitignore()
读取账号 .ovgitignore 内容;文件不存在时返回空字符串。
Python SDK (Embedded / HTTP)
content = client.snapshot.get_gitignore()TypeScript SDK
console.log(await client.gitGetIgnore());HTTP API
GET /api/v1/snapshot/ignorecurl -X GET "http://localhost:1933/api/v1/snapshot/ignore" \
-H "X-API-Key: your-key"CLI
ov snapshot ignore-get -o json响应
{
"status": "ok",
"result": "*.log\n"
}不带
-o json时,CLI 直接把原始内容打到 stdout(可重定向到文件)。
set_gitignore()
写入账号 .ovgitignore 内容(覆盖)。写入前校验大小上限(64 KiB);语法(取反、转义等)在 commit 时由 Rust 层校验。
参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| content | str | 是 | - | .ovgitignore 文件内容(UTF-8) |
Python SDK (Embedded / HTTP)
client.snapshot.set_gitignore(content="*.log\n")TypeScript SDK
await client.gitSetIgnore("*.tmp\n.cache/\n");HTTP API
PUT /api/v1/snapshot/ignorecurl -X PUT "http://localhost:1933/api/v1/snapshot/ignore" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{"content": "*.log\n"}'CLI
# 用 --content 直接传内容,或用 --file 从文件读取
ov snapshot ignore-set --content "*.log" -o json
ov snapshot ignore-set --file ./my-rules -o json响应
{
"status": "ok",
"result": null
}delete_gitignore()
删除账号 .ovgitignore。文件不存在也视为成功(幂等)。
Python SDK (Embedded / HTTP)
client.snapshot.delete_gitignore()TypeScript SDK
await client.gitDeleteIgnore();HTTP API
DELETE /api/v1/snapshot/ignorecurl -X DELETE "http://localhost:1933/api/v1/snapshot/ignore" \
-H "X-API-Key: your-key"CLI
ov snapshot ignore-delete -o json响应
{
"status": "ok",
"result": null
}典型流程
下面演示一个"提交 → 修改 → 恢复"的完整流程(Python SDK):
import openviking as ov
client = ov.OpenViking()
client.initialize()
root = "viking://resources/my_project"
# 1. 写入初始内容并提交 v1
client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", mode="create", wait=True)
v1 = client.snapshot.commit(message="v1 initial import")
# 2. 修改后再提交 v2
client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", mode="replace", wait=True)
v2 = client.snapshot.commit(message="v2 update")
# 3. 查看历史
for c in client.snapshot.log(limit=10):
print(c["oid"][:8], c["message"])
# 4. 把工作区恢复到 v1(会在 v2 之上生成一个新提交)
client.snapshot.restore(project_dir=root, source_commit=v1["commit_oid"], message="restore to v1")
client.close()更多端到端示例参见仓库中的 examples/snapshot/ 目录,涵盖 SDK、HTTP、CLI 三种调用方式。
错误处理
| 场景 | HTTP 状态码 | 错误码 |
|---|---|---|
分支/提交不存在,或 show 的 path 在该提交中不存在 | 404 | NOT_FOUND |
| 恢复期间分支被并发提交改写(CAS 冲突) | 409 | CONFLICT |
.ovgitignore 过大、非 UTF-8,或包含不支持的 ! 取反/反斜杠转义语法(commit 时校验) | 400 | INVALID_ARGUMENT |
请求体包含未知字段(请求模型为 extra="forbid") | 400 | INVALID_ARGUMENT |
