把裸仓库丢到静态托管上,访客就能 git clone,同时有一个网页界面浏览文件树、README 和提交历史。
站点本身零运行时依赖、零构建步骤,打开 index.html 就能跑。打包与发布由一个 Go 维护器 rog 负责,只在你自己机器上跑。
Git 有个降级协议叫 dumb HTTP protocol,服务端只需要按约定提供几个静态文件:
HEAD
info/refs 所有分支和标签
objects/info/packs pack 清单
objects/pack/*.pack
objects/pack/*.idx
客户端尝试 smart 协议失败后会自动降级,直接读这些文件。所以一台只会发文件的服务器就够了:Arweave 网关、IPFS 网关、Cloudflare Pages、EdgeOne、nginx 都行。
浏览界面同理,服务端不参与,在浏览器里直接解析 packfile,实现见 internal/sitekit/site/src/git/。
/
├── index.html 界面
├── repository.json 仓库清单
├── src/ 界面代码与样式
└── p2ping/ 裸仓库,一个项目一个(目录名就是仓库名)
这个结构由 rog site init(前端)与 rog pack(裸仓库与清单)各自写一部分,合起来就是一个能直接部署的站点。
repository.json 存在的原因:静态托管没有目录列表 API,无从得知站上放了哪些仓库。
{
"repositories": [
{ "name": "p2ping", "description": "可选的一句话说明" }
]
}命名约定:目录名就是仓库名,不带 .git 后缀。
| 位置 | 形式 | 例子 |
|---|---|---|
| 磁盘目录 | 裸名 | p2ping/ |
repository.json / URL / 界面显示 |
裸名 | p2ping |
| clone 地址 | 裸名 | <站点>/p2ping |
为什么不用 p2ping.git 这种惯用名:git 对 URL 后缀没有任何要求,
dumb 协议下客户端只往 base URL 后面拼 info/refs、objects/info/packs
这些固定路径(见 git 的 http.c),目录叫什么都能 clone。
而带 .git 的路径会被一些网关拦掉,eth.limo 就是一例。
既然两种写法等价,就用不会被拦的那种。
前端对带后缀的写法也做了归一化,但请按裸名写。空、.、..、含路径分隔符的条目会被忽略。
rog site init [目录] [--template <id>] [--force]
rog site list
| 选项 | 默认 | 说明 |
|---|---|---|
[目录] |
public |
站点根目录 |
--template <id> |
default |
用哪套模板,rog site list 看有哪些 |
--force |
关 | 覆盖已存在的文件;默认只补缺失的 |
前端文件是嵌在二进制里的,所以光一个 rog 就能把站点从零立起来:
rog site init site # 铺前端
rog pack . site demo # 写仓库不传 --force 时它不会碰已经存在的文件:站点里的 index.html 可能被你改过,默认不该被模板盖掉。
传了 --force 也有一条例外:repository.json 永远不覆盖——它是 pack 维护的仓库清单,不是骨架。
rog pack [--rebuild] <源仓库> [输出目录] [仓库名]
| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
<源仓库> |
是 | 本地路径(普通仓库或裸仓库),或远端地址 | |
[输出目录] |
否 | public |
站点根目录,也就是放着 index.html 的那个 |
[仓库名] |
否 | 本地取目录名,远端取地址末段 | 对外标识,带不带 .git 后缀等价 |
--rebuild |
否 | 关 | 忽略已有产物从零重建;默认自动(目标里已有能用的仓库就增量) |
# 本地仓库
rog pack ../p2ping
# 远端仓库,直接复刻一份静态版本
rog pack https://github.com/LWDJD/p2ping.git
# 指定输出目录和名字
rog pack git@github.com:LWDJD/p2ping.git site p2ping
# 已有站点上再跑一次:自动增量,只处理变化的对象(不需要任何开关)
rog pack ../p2ping site p2ping
# 忽略已有产物,从零重建
rog pack --rebuild ../p2ping site p2ping尖括号是必填,方括号是可省略。
支持 https:// git:// ssh:// file://,以及 scp 风格的 user@host:path。
远端模式下拿到的是那个仓库的全部分支和标签,不只是你本地 checkout 过的那几个。所以想给一个远端仓库做静态镜像,一条命令就够。
私有仓库得先让 git 自己拿到凭据(credential helper 或 SSH key),程序不处理认证。远端不可达或凭据不对时,git 的报错会原样带出来。
pack 只生成裸仓库和 repository.json,不碰前端。所以输出目录得是放着 index.html 的那个目录,否则产物没法直接部署。用 rog site init 就能把这个目录先铺出来:
你的项目/
public/ ← 站点根
index.html ┐ 前端,仓库还没下载时就得有
src/ ┘
p2ping.git/ ← pack 写进来的
repository.json ← pack 维护的
往空目录里跑也能生成,但会在结尾提醒你还缺 index.html。
裸仓库输出到 <输出目录>/<仓库名>/ → repack -a -d 把对象收进单 pack → gc --prune=now → update-server-info 生成索引 → 清掉协议用不到的文件 → 校验并打印清单 → 更新 repository.json。
重复执行安全:默认自动增量——目标里已有能用的仓库就只传变化的对象,
--rebuild 才忽略旧产物从零重建。增量模式下旧 pack 保持不动,
只把新对象追加进去,refs 对齐到源仓库。仓库名带尾随点/空格、前导 -
或 Windows 设备名(CON 之类)会被直接拒绝,免得两个名字指向同一目录互相覆盖。
产物就七个文件:
config HEAD packed-refs info/refs objects/info/packs
objects/pack/pack-*.idx objects/pack/pack-*.pack
手工命令
git clone --bare <你的仓库> public/p2ping.git
git -C public/p2ping.git repack -a -d
git -C public/p2ping.git gc --prune=now
git -C public/p2ping.git update-server-inforepack 不能省。对象要是散着的,客户端得逐个请求,一个中等项目就是上千次 HTTP 请求。
clone --bare 失败时的回退链路
在沙箱或受限的 sh.exe 环境下,本地传输建不出管道,clone --bare 会报 couldn't create signal pipe。程序这时自动改用 bundle:
git bundle create <bundle> --branches --tags (在源仓库)
git init --bare <target>
git bundle unbundle <bundle> (列出 refs 到 stdout)
git update-ref <ref> <sha> (逐个建 ref,必须做)
git symbolic-ref HEAD <ref>
update-ref 这步不能省:bundle unbundle 只把 refs 列出来,不创建它们。refs 不存在的话,后面 gc --prune=now 会把所有对象当不可达清掉,产出一个空仓库。
用 --branches --tags 而不是 --all,后者会把 refs/remotes 一并带进来。
发布要签名,私钥在浏览器钱包里,所以会起一个只绑本机的小服务,让你在页面上签字。装 Wander 即可。
三条路:
| 目标 | 命令 | 说明 |
|---|---|---|
| 本地目录 | rog publish <站点> <目标目录> |
试跑用,只拷文件,不花钱 |
| Turbo | rog publish <站点> --arweave |
默认。逐个把文件交给上传服务 |
| L1 | rog publish <站点> --arweave --l1 |
打成一包,签一笔交易直接提交到节点,不经过任何服务 |
# 先本地试一遍,确认站点结构没问题
rog publish . /tmp/preview
# 发布到 Turbo
rog publish . --arweave --repo p2ping
# 走 L1,直接提交到 arweave.net
rog publish . --arweave --l1 --repo p2ping跑起来会打印一个签名页地址,浏览器里连接钱包、点「开始签名」,签完自动继续。
增量:第二次发布只处理内容变了的文件,其余复用上次的 id,不为已付费的内容再付一次。记录存在 <站点>/.rog/ 下。
体积:L1 会按 256 KiB 自动分块,多大的站点都能发。分块协议要求把交易先报上去、再逐块补内容,这些程序自己处理。
费用:Arweave 是永久存储,按体积一次性付费。Turbo 有免费额度(约单项 105 KiB、终身 10 MiB),小项目通常够用,超出部分要充值。具体额度以官方页面为准。L1 不经服务,直接付给网络。
rog publish <站点> --arweave --from <入口id> [--repo <名字>] [--gateway <地址>]
发布记录本身也会随站点上链。换机器、本地 .rog 丢了之后,用入口 id 把它取回来,增量能力就续上了,不必从零重传。
--gateway 用来指定读取用的网关,默认 arweave.net。
rog verify <站点目录> [--repo 名字] [--gateway 地址…] [--from 入口id] [--no-content] [--repair]
发布之后的手动核对:拿发布记录逐条问网关「这条还在不在、内容对不对」。 只读、只报告,不做任何自动动作——连修复都是单独一个开关。
| 选项 | 默认 | 说明 |
|---|---|---|
--repo |
站点目录名 | 记录身份,与发布时一致 |
--gateway |
内置清单全查 | 用哪个网关查,可重复给多个,多网关对比 |
--from |
本地记录 | 从链上取一份记录来核对(换机器后用) |
--no-content |
关(默认核对) | 只查可读性,不取内容比对摘要 |
--repair |
关 | 把坏引用从记录里清掉,清完跑一次发布即补上 |
结论分四档:可读(一个网关读到且内容对就算成功)、查不到 (能问到的网关都说 404)、摘要不符(读到的网关内容全对不上)、 网关不可达(连不上,问不出结论)。个别网关读不到、或回了怪东西, 只记在备注里(汇总报「其中 N 条网关有差异」):链上数据在链上即永久, 不影响「上传成功」这个事实。
--repair 只清查不到与摘要不符这两类;网关不可达不清——
网络问题不该让已付费的内容重传。
补传的机制就是增量发布:坏引用从记录里清掉后,下次发布会重新签名、 重新上传这些文件。verify 不碰上传,也就没有第二条上传路径的流程风险。 webui 里有对应的「检查与补传」面板,网关、代理、摘要核对都能手动设。
rog nodes [地址…] [--proxy system|manual|off] [--proxy-url <地址>]
发布前花两秒看清出口:并发探一遍各网关的 /info,报高度、队列与延迟,
建议当前该把 --node 填谁。只读、不花 AR、失败不留痕。
提交时「看似成功、实则没到场」的事,多半就出在网关这一跳。
rog webui [--site <站点目录>] [--port <端口>]
| 选项 | 默认 | 说明 |
|---|---|---|
--site <目录> |
public |
默认操作的站点目录 |
--port <端口> |
随机空闲端口 | 固定端口,方便反复访问同一个地址 |
把打包、发布、检查与补传、从链上恢复、站点文件浏览与替换都摆在页面上, 功能与命令行一致。左栏是面包屑切换卡片:五个操作一次只亮一张, 默认停在发布;网络出口是常驻条,发布/检查/恢复共用。耗时操作会开一个 任务,进度用 SSE 实时推,刷新页面也不丢。
服务只绑 127.0.0.1,并且启动时生成一个随机会话 token:地址栏里带着它,页面内所有请求也跟着带,不带或不对一律拒绝。这样即使同机有别的网页,也没法借你的浏览器去改站点文件。webui 只操作启动时 --site 指定的那个目录,请求里想换根一律拒绝。
仓库页是两栏结构,左栏文件树和文档,右栏 About 和 Releases。
- About:简介取
repository.json的description,没写就退回最新提交标题;许可证扫根目录的LICENSE/COPYING/UNLICENSE,按内容特征串识别类型(MIT、Apache-2.0、GPL 系、BSD 系、ISC、MPL、Unlicense 等),认不出就显示文件名 - Releases:没有 release 概念,用标签近似,按版本号排序,最高的标
Latest - 文档区:文件列表下方的 tab,识别
readme/license/changelog/contributing/code_of_conduct/security/governance/citation/authors/support,默认打开 README。.md走 Markdown 渲染,其余(License 之类)用等宽文本保留原始排版。子目录页同样适用
标签排序用 localeCompare 带 numeric: true,否则 v10.0.0 会排在 v9.0.0 前面。
静态托管没有服务端重写能力,路由只能用 hash。hash 部分不发给服务器,深链接随便用。
#/ 项目列表
#/p2ping 概览
#/p2ping/tree/main/src 目录
#/p2ping/blob/main/x.rs 文件
#/p2ping/commits/main 提交历史
#/p2ping/branches 分支
#/p2ping/tags 标签
可调项都收在 internal/sitekit/site/src/style.css 顶部的 CSS 变量里:
:root {
--bg: #ffffff;
--bg-subtle: #f6f8fa;
--text: #1f2328;
--text-muted: #59636e;
--border: #d1d9e0;
--accent: #0969da;
--accent-mark: #fd8c73; /* 选中 tab 的下划线 */
--page-width: 1280px;
--side-width: 296px;
}深色模式跟随系统,也可以用 <html data-theme="dark"> 强制。改结构或文案动 internal/sitekit/site/src/views.js。
DecompressionStream 对尾部多余字节零容忍,会抛 Trailing junk found after the end of the compressed stream,而 packfile 里每个对象的 zlib 流没有长度字段。
解法是用 pack index 的偏移表反推边界:所有对象偏移排序后,某个对象的字节区间就是 [自身偏移, 下一个偏移),切出来刚好是完整压缩数据。所以 .idx 是必需件。
L1 发布大站点时要算 Merkle 树,而交易签名绑定的 data_root 就来自那棵树。
这里的做法是:data_root 与各块的 proof 都交给签名页里的 arweave-js 算,Go 侧只按回传的 offset 推每块边界,再逐块提交。这样签名的依据与提交的依据天然是同一份,不存在两个实现算不到一块去的可能。
有个细节值得记:恰好等于单块上限(256 KiB)的数据只有一块要传,但它的 proof 比预期长。原因是切块时先切出一个零长度尾块、树按含它的形状建好,之后才把空块丢掉。所以自己照规则重切一遍反而容易错,直接认 offset 最稳。
scripts/arjs-vectors.cjs 能把 arweave-js 的输出固化成对照数据,测试里会拿它比对。
internal/sitekit/site/src/git/
zlib.js 解压封装(输出限长,炸弹截停)
idx.js pack index v2 解析
pack.js pack 读取、delta 递归(环与深度上限)
delta.js delta 指令解码(声明大小上限)
objects.js commit / tree / tag 结构
repo.js 仓库门面:refs / tree / blob / log
RemoteRepo 接受 baseUrl 和可注入的 fetch,所以能在 Node 环境跑:
node scripts/selftest.mjs # 自建 fixture,用完即删
node scripts/selftest.mjs <裸仓库目录> # 或指定一个现成的它会走一遍 pack 加载、refs 解析、tree 递归、blob 解压、提交历史。
前端骨架的真身在 internal/sitekit/site/,go:embed 把它嵌进二进制。
选这个位置是被约束逼出来的:go:embed 只能嵌本包目录下的文件,
而复制一份进包会让两份代码长期漂移。
public/ 是产物目录:骨架、裸仓库、清单都由工具生成,不进版本库。
要看效果就跑 rog site init 铺一份出来。
这个工具是单人管理员工具,安全边界按「你信任谁」来看:
- webui 与签名服务的 token 就是管理员权限。持 token 者可改站点文件、
发起发布与交易。两个服务都只绑
127.0.0.1,token 每次启动随机生成, 不要外传地址栏里的那串 token。 --from、--gateway、--endpoint、--node是信任决策。 入口 id 指向谁、网关指向谁,增量复用与恢复就信谁返回的内容。 Arweave 的 id 是签名哈希不是内容哈希,工具无法按 id 反验内容; 恶意网关可以返回任意字节。只用你信得过的入口与网关。- 不要 pack 不可信来源的仓库。
file://源会在源仓库自己的 git 配置下 运行git-upload-pack,恶意仓库可在配置里指uploadpack.packObjectsHook之类触发命令。把不可信仓库当数据看这个前提,被 git 自己的语义打破了。 - 前端解析器对恶意 pack 做了防御(delta 环、声明大小、解压膨胀都有上限), 打挂标签页这类事不会再发生;但解析器不会替你判断仓库内容是否可信。
- 只读。协议层面没有 push 路径,这正是它能用纯静态文件托管的原因
- 源仓库不能是浅克隆。
git clone --depth拉下来的历史不完整,repack遍历父提交时会失败。程序会提前拦住并提示fetch --unshallow - 整包下载。packfile 一次性载入内存,几十 MB 会卡。要优化得解析
.idx后按 Range 惰性取对象 - 不支持 shallow clone。
--depth依赖服务端裁剪历史 - 不能选择性公开。
info/refs列出仓库里所有 refs,不想公开的分支要在打包前清掉 - 目录名不带
.git,这是刻意的。git 对 URL 后缀没有要求,而带.git的路径会被一些网关拦掉(eth.limo就是一例)。产物目录直接用仓库名,git clone <站点>/demo即可。 - ENS contenthash 要手动填。发完在 ENS 应用里把入口写成
ar://<入口id>,这一步尚未自动化
main.go 命令行入口:site / pack / publish / webui
internal/repopack/ 打包:全量、增量、ref 对齐、文件锁
internal/publish/ 发布抽象、本地目标、发布记录、进程内互斥
internal/arweave/ Turbo 与 L1 两条路:上传、bundle、分块、manifest
internal/signer/ 本机签名服务与内嵌签名页(含 arweave-js)
internal/webui/ 维护台:任务、接口、内嵌页面
internal/sitekit/ 嵌在二进制里的前端骨架
site/ 骨架真身,改前端就在这里
index.html 入口
repository.json 空清单,pack 会覆盖成真的
src/
main.js 启动与路由分发
router.js hash 路由
store.js repository.json 与仓库缓存
views.js 各视图渲染
ui.js DOM 构造与小工具
icons.js 手绘 SVG 图标库
markdown.js 极简 Markdown 渲染(转义 + 表格 + 折叠块)
style.css 主题层
git/ 只读 git 解析器
public/ 部署根(产物目录,由工具生成)
scripts/ 不参与部署
serve.mjs 本地静态服务器,默认服务骨架真身
selftest.mjs 解析器自测
arjs-*.cjs 与 arweave-js 对拍/取证的工具集(形状、基准、探针等)
go build -o rog .没有第三方依赖,用一个标准库足够。站点前端的改动要重新 go build(骨架是
go:embed 编译期固化的),再 rog site init <站点> --force 刷给已有站点;
直接改产物目录里的 public/ 也能生效,但下次刷新会被模板盖掉。
改外观只动 internal/sitekit/site/src/style.css,改结构或文案动 internal/sitekit/site/src/views.js。