← Back to all notes

把 GitHub 仓库当网盘:Contents API 的乐观并发与空目录

Published By Kite

  • 工程笔记
  • Panda Storage Tool
  • GitHub
  • CSharp

写操作必须串行,因为它是乐观并发

Contents API 更新与删除文件时,必须携带文件当前的 SHA。 于是每一次写都是「读 SHA → 提交」的读-改-写流程:两个任务同时读到同一个旧 SHA, 后提交的那个必然拿到 409 Conflict。

这里没有事务可用,能做的是把写操作排队:

/// 写操作串行化。上传/删除都是「先读 SHA,再提交」的读-改-写流程,
/// 并发提交会让读到的 SHA 过期,从而触发 409 冲突。
/// 用静态信号量把同进程内所有 provider 实例的写操作排队执行;
/// 只读操作(校验、列举、下载)不加锁,不受影响。
private static readonly SemaphoreSlim WriteLock = new(1, 1);

静态是关键字:锁的范围要覆盖同进程内所有 provider 实例,只锁实例等于没锁。 只读操作不进锁,否则列举一次目录会把整个上传队列堵住。

冲突策略给了三种,各有适用场景:

策略 行为 适用
Overwrite 带已有 SHA 覆盖 同步本地目录
Fail 立刻报「文件已存在」 不想误覆盖时
AutoRename 依次试 -1 … -50,都撞了用 Unix 秒时间戳 批量上传

边界要写清楚:这把锁只覆盖当前进程。GitHub 网页、命令行、另一台电脑依然可能在你读 SHA 之后改文件; 代码收到 409 后不会自动重读 SHA 重试 —— 那是下一步该补的东西。

Base64 上传决定了内存成本,也解释了为什么没有断点续传

var bytes = await File.ReadAllBytesAsync(request.LocalPath, cancellationToken);

var payload = new Dictionary<string, object?> {
    ["message"] = commitMessage,
    ["content"] = Convert.ToBase64String(bytes),
    ["branch"]  = branch,
};
if (existingSha is not null) payload["sha"] = existingSha;

整个文件先完整进 byte[],Base64 又涨三分之一,再变成 JSON 字符串、再进 HTTP body —— 一个 100MB 的文件在这一路上远不止 100MB 的内存。 也正因为 Content 是一个完整字段,分块上传和断点续传在 Contents API 上根本无从实现: 没有 Range / Content-Range / 206 Partial Content 的对应物,重试就是从头再来一遍。

README 里写的建议上限是 100MB。真要做大文件,路是 Git Data API 或 Git LFS, 而不是在 Contents API 上硬补分块。

空目录:用一个占位文件补 Git 的模型差

Git 只存文件,不存空目录。网盘工具如果直接映射 Git 树, 用户新建的空目录会立刻消失,目录里文件删光之后目录也会消失。

private async Task EnsureKeepFileAsync(string folder, string branch, CancellationToken ct) {
    var key = $"{branch}\n{folder}";
    if (_ensuredFolders.Contains(key)) return; // 同一目录只补一次
    // ... 写入空 .gitkeep
}

三个配套决定:

  • 上传整个文件夹时按本地结构递归,每一级目录都补一个 .gitkeep;
  • 于是「一个文件都没有的子目录」也能被建出来;
  • 列表里永远不显示 .gitkeep —— 它是实现细节,不是用户的文件。

副作用要认:空目录的存在以本地记录为准,在别处(网页、命令行)删掉的空目录, 要等一次刷新才会消失。空目录本来就是边缘情况,可以接受。

外链只认公开仓库

上传成功后给的外链是 raw.githubusercontent.com 或 jsDelivr,两者都匿名可读 —— 前提是仓库本身公开。用私有仓库也能上传,但那时候的外链别人打开只会看到 404。

外链形式 适用 说明
raw.githubusercontent.com 默认 官方直链,国内偶尔不稳定
jsDelivr 需要加速时 CDN 缓存,更新有几分钟延迟
自定义模板 自建镜像 模板里填 {owner} / {repo} / {path}

现在的形态

文件列表(暗色)

上传与下载都留痕,失败的还会写明原因(大部分失败其实是限流 —— 未认证请求 5000 次/小时):

传输记录

还有一个不显眼的坑

上传文件夹是「一个文件一次提交」。文件多的时候既慢、又容易撞上限流。 要合并成一次提交就得走 Git Data API(建 tree → 建 commit → 更新 ref), 那是另一套完全不同的接口,代价不小。