☰
Xray Memo Epoch 机制:Git HEAD 移动时工作树副本的重建协议与未保存编辑合并
2026/9/25 3:39:06 网站建设 项目流程
  • 开发工具

【免费下载链接】xray

An experimental next-generation Electron-based text editor

项目地址:https://gitcode.com/gh_mirrors/xray/xray
点击查看免费下载

本文以 docs/architecture/003_memo_epochs.md 为骨架,完整梳理 Xray 项目中 Memo 协议在"仓库 HEAD 发生变化"这一关键事件下的操作序列:本地机器如何基于新 HEAD 重建一棵工作树、如何检测并合并本地未保存编辑的冲突,以及远端站点如何校验 Lamport 时间戳、接收新 Epoch 并把打开的缓冲区平滑迁移到新树上。读完后,你可以理解 Memo"把单个 Git 工作副本复制到多台机器实时协作"这一目标中,Epoch 切换为什么是协议的正确性核心,并能对照 memo_core 源码验证每一步的落地实现。

为什么 Git HEAD 移动需要一套专门的 Epoch 协议

根据 memo_core/README.md 的定位,Memo 的目标是扩展 Git:Git 本身只能在提交之后同步变更,各机器的 worktree 彼此独立;而 Memo 用无冲突复制数据类型(CRDT)记录工作副本的全部未提交变更,让它们在多个副本间实时同步,并用操作日志为每个 commit 补充细粒度的编辑历史。

这就带来一个必然问题:当某个副本上的git checkout、pull或强制推送使HEAD 发生移动时,工作副本的"地基"被整体抽换,所有基于旧 HEAD 之上的未提交编辑、未保存缓冲区都必须重新落到新地基上。003_memo_epochs.md 描述的正是这一时刻两端应执行的操作序列——文档开头也点明了协议的一个基本假设:

算法假设版本向量不会跨 Epoch 重置。这确实引出"版本向量在仓库生命周期内可能无限增长"的隐忧,但为了推进工作,我们暂时搁置这个担忧。

也就是说,Epoch 不是对历史的"截断重置",而是同一版本向量坐标系下的一个新纪元:新 Epoch 的树(T')从新 HEAD 重新扫描而来,但 CRDT 层面的版本向量持续累积。

Epoch 的核心数据结构:一个 Epoch 到底携带什么

要读懂协议,先看 memo_core/src/epoch.rs 中Epoch结构体的实际形态:

pub struct Epoch { pub id: Id, // Id 是 time::Lamport(Lamport 时间戳即 Epoch 标识) pub head: Option<Oid>, // 该 Epoch 对应的 Git HEAD SHA(Oid = [u8; 20]) base_entries_next_id: u64, base_entries_stack: Vec<FileId>, metadata: btree::Tree<Metadata>, // 文件/目录元数据 parent_refs: btree::Tree<ParentRefValue>, // 子节点 -> 父节点(支持重命名历史) child_refs: btree::Tree<ChildRefValue>, // 父节点 -> 子节点(支持可见性/冲突) replica_locations: HashMap<ReplicaId, ReplicaLocation>, version: time::Global, // 工作树的当前版本向量 local_clock: time::Local, text_files: HashMap<FileId, TextFile>, deferred_ops: OperationQueue<Operation>, }

对照文档中"广播新 Epoch B 时包含新 HEAD SHA、工作树的当前版本向量、一个 Lamport 时间戳和所有合成的操作",可以看到每一项都有直接对应物:head(HEAD SHA)、version(版本向量)、id(Lamport 时间戳,pub type Id = time::Lamport,见 epoch.rs#L22),以及随 Epoch 一起传播的操作序列。

这里有三套时钟(memo_core/src/time.rs),是理解整个协议的前提:

类型结构语义关键方法
Local{ replica_id: Uuid, value: u64 }单副本本地逻辑时钟,用于给插入/编辑打标tick()自增取值;observe()只吸收同副本时间戳(time.rs#L37-L67)
GlobalArc<HashMap<ReplicaId, u64>>版本向量:每个副本一个计数observe()取逐分量最大值;observed()/changed_since()判断可见性(time.rs#L113-L136)
Lamport{ value: u64, replica_id: Uuid }全局 Lamport 时钟,兼任 Epoch 标识observe()为max(自身, 对方) + 1,保证接收方之后产生的 Epoch 一定比收到的更晚(time.rs#L188-L223)

Lamport的observe实现是max + 1(而非简单的 max),这保证了同一副本上"收到 Epoch B 之后再广播的任何新 Epoch"在标识上必然大于 B,与文档中"如果收到的 Epoch 时间戳小于当前 Epoch,忽略它"的判据严丝合缝。

文件标识也分为两类(epoch.rs#L103-L107):FileId::Base(u64)表示从 Git 对象库扫描出来的"地基"文件,FileId::New(time::Local)表示在 Epoch 期间由某副本新建、以本地时间戳命名的文件。这个区分正是文档中"把 Git 数据库条目扫描进新 Tree T'"与"本地新建文件需要特殊迁移"两个处理分支的基础。

HEAD 移动后创建新 Epoch:本地侧的完整操作序列

文档"Creating a new epoch after HEAD moves"一节给出了假定当前处于由 Tree T 描述的 Epoch A 时的四步操作,下面逐步展开并用源码印证。

第一步:把新 HEAD 的 Git 数据库条目扫描进新 Tree T'

文档原文要求:"Scan all the entries from Git's database based on the new HEAD into a new Tree T'."

在实现中,这一步由Epoch::append_base_entries(memo_core/src/epoch.rs#L248-L318)完成:接收一串DirEntry(depth/name/file_type),按深度用base_entries_stack恢复父子关系,为每个条目分配递增的FileId::Base,批量写入metadata、parent_refs、child_refs三棵 B 树。Git 侧的条目流则来自抽象的GitProvider(memo_core/src/work_tree.rs#L17-L20):

pub trait GitProvider { fn base_entries(&self, oid: Oid) -> Box<Stream<Item = DirEntry, Error = io::Error>>; fn base_text(&self, oid: Oid, path: &Path) -> Box<Future<Item = String, Error = io::Error>>; }

WorkTree::start_epoch在加载基线时以500 条一个 chunk流式消费base_entries(work_tree.rs#L254-L272),每处理完一批就调用append_base_entries并把其返回的fixup_ops包装成OperationEnvelope向外广播——这正是"合成操作"的一个来源。

值得注意的实现细节:扫描时若发现新条目与已有子引用同名,会记录进name_conflicts并调用fix_name_conflicts生成修正操作(epoch.rs#L294-L313)。这体现了 Memo"覆盖整个工作树树结构"的设计优先级(memo_core/README.md)——树结构本身的冲突(如同一目录下的重名)也要通过可交换的操作来表达,而不是在本地悄悄丢弃。

append_base_entries与apply_ops还有一个共同点:都会 drain 出deferred_ops(因前置条件不满足而被推迟的操作)并重新尝试应用(epoch.rs#L314-L317)。can_apply_op检查操作的依赖文件是否已存在(epoch.rs#L552-L561),这解释了为什么 Epoch 的构建是"先铺地基、再放操作"的两段式过程。

第二步:为未提交变更合成操作

文档原文:"Synthesize and apply operations for all uncommitted changes via agit diff. This includes file system operations as well as uncommitted changes to file contents."

即:新 HEAD 的树 T' 只包含已提交内容,而工作区里相对 T' 的未提交差异(新增文件、移动、未保存的文本改动)需要被合成为一组操作应用到 T' 上,使本地副本在切换后不丢失任何未提交工作。Epoch的Operation枚举(epoch.rs#L76-L101)提供了合成操作的表达空间:

pub enum Operation { InsertMetadata { file_id, file_type, parent, local_timestamp, lamport_timestamp }, UpdateParent { child_id, new_parent, local_timestamp, lamport_timestamp }, BufferOperation { file_id, operations: Vec<buffer::Operation>, local_timestamp, lamport_timestamp }, UpdateActiveLocation { file_id, lamport_timestamp }, }

其中BufferOperation内嵌的buffer::Operation来自 CRDT 文本缓冲区(memo_core/src/buffer.rs#L153),基于带Insertion节点(携带id: time::Local、parent_id、lamport_timestamp,见 buffer.rs#L84-L91)的片段树,保证不同副本以任意顺序收到同一组编辑也能收敛到相同文本——这也是共享工作区文档中"缓冲区复制通过中继无冲突的编辑操作表示实现"的底层依据。

第三步:带未保存编辑的缓冲区做冲突检测与变换

这是文档中最精细的一段,对应"对于 T 中所有带未保存编辑的缓冲区":

  1. 用该缓冲区在 T 中的路径,把 T 中的最后保存内容(T)与 T' 中的当前内容(T')做 diff。这个 diff 描述了"在我们控制之外被触碰的区域集合";
  2. 遍历 T 中每一条未保存操作,检查它是否与 diff 中的任一区域相交,以此检测冲突:
    • 有冲突:对 T' 与 T 的内容做 diff,把结果合成为一组操作,作为未保存操作叠加在 T' 之上,并将该缓冲区标记为冲突;
    • 无冲突:把所有未保存操作按第一步得到的 diff 做位置变换(transform),然后应用到 T' 中的缓冲区上。

从源码结构看,缓冲区迁移本身由WorkTree切换 Epoch 时的SwitchEpoch异步逻辑承担(memo_core/src/work_tree.rs#L952-L1081):对旧 Epoch 中每个打开的缓冲区,先解析其路径,若新 Epoch 中同路径仍是文本文件,就发起git.base_text(head, path)请求、取到新 HEAD 下该文件的基线文本后用open_text_file在新树中重新打开;缓冲区持有的未保存编辑随后以操作形式重放,由Buffer::apply_ops的 CRDT 合并与延迟队列(deferred_ops)完成最终收敛。文档中"diff 出区域—检查相交—变换或合成"的算法描述,正是这一重放过程在协议层的规范表述。

第四步:广播新 Epoch B

文档原文:"Afterward, we broadcast a new epoch B that contains the new HEAD SHA, the work tree's current version vector, a Lamport timestamp, and all synthesized operations."

实现中WorkTree::reset(head)(work_tree.rs#L145-L155)就是这一广播的入口:它先lamport_clock.tick()生成新 Epoch 的 Lamport 标识,产出Operation::StartEpoch { epoch_id, head },再接上start_epoch产生的操作流。所有操作都以OperationEnvelope { epoch_head, operation }的形式带上新 HEAD 一起封包(work_tree.rs#L45-L48),这样接收端即使先于StartEpoch收到操作,也能知道它属于哪个 HEAD。

接收新 Epoch:远端侧的校验、重建与缓冲区迁移

文档"Receiving a new epoch"一节的规则,在WorkTree::apply_ops中有逐字对应的分支(work_tree.rs#L167-L188):

  1. Lamport 时间戳检查:
    • 若收到的 Epoch 时间戳小于当前 Epoch → 直接忽略(源码中为Ordering::Less => {});
    • 等于当前 Epoch → 把操作加入cur_epoch_ops正常应用;
    • 大于当前 Epoch(Epoch 尚未启动)→ 放入defer_epoch_op延迟队列,等新 Epoch 建立后再重放(work_tree.rs#L181)。
  2. 重建树:对通过校验的 Epoch,"Scan all entries from Git's database based on the new epoch's HEAD SHA into a new Tree T'",然后把"与新 Epoch 关联的操作"应用到 T' 上。

注意start_epoch里的守卫条件new_epoch_id > e.borrow().id(work_tree.rs#L241-L244):只有严格更新的 Epoch 才会触发真实的树重建与缓冲区迁移,否则返回空流——这与第 1 条的忽略规则互为印证,也保证了重复消息(网络重传同一 Epoch)的幂等性。memo_core的测试模块甚至内置了一个会随机插入重复消息的网络模拟器(memo_core/src/lib.rs#L132-L214),用于验证操作与 Epoch 在乱序、重复投递下的收敛性。

打开的缓冲区怎么办:文档的两种迁移路径

文档对"收到新 Epoch 后缓冲区会怎样"给出了明确答案,SwitchEpoch的实现与之逐条对应:

  • 对于包含"未被 Epoch 变更的版本向量所包含的编辑"的缓冲区——即本地领先于远端、尚未被确认的未保存编辑:
    • 若 T' 中存在同路径的文件:用缓冲区在 T 中的路径,把版本向量所覆盖的内容与 T' 中的内容做 diff,得出"外部触碰区域";再逐条检查本地编辑,只要不直接与 diff 区域冲突,就基于 diff 调整位置,合成一条新操作并应用到 T'上。实现中这体现为:从 Git 取新 HEAD 下的基线文本(base_text_requests,见 work_tree.rs#L1008-L1032)重新打开文件,再由 CRDT 缓冲区的操作重放完成位置调整;
    • 若 T' 中不存在该路径的文件:则以 T 中的初始内容创建它。源码对应分支非常直白(work_tree.rs#L1053-L1079):调用new_text_file在新 Epoch 中建一个空文本文件,立即合成两条fixup_ops(创建操作 + 编辑操作),把旧缓冲区的全文(cur_epoch.text(buffer_id).into_string())作为一次编辑写入,再建立buffer_id → new_file_id的映射。这样,当用户在本地新建的文件恰好被另一端的 HEAD 移动"抹掉"时,其内容不会丢失,而是作为新文件在协作方那里重现。该分支中留有一条 TODO(work_tree.rs#L1054-L1059),计划由发起重置的站点直接下发"旧 file_id → 新 file_id 映射",减少对路径匹配和新建文件的依赖——可见这一段仍是活跃演进中的设计。

Epoch::open_text_file也为此做了配套处理(epoch.rs#L610-L639):如果该文件此前处于TextFile::Deferred状态(只累积了操作、尚未物化为缓冲区),打开时会先用基线文本构造缓冲区并把延迟操作全部重放上去,确保迁移后的缓冲区内容是"基线 + 所有已知编辑"的完整合成。

版本向量与 Epoch 的组合比较:observed() 与 Version

文档假设"版本向量不跨 Epoch 重置",而接收端判断"某个状态是否已被自己观察到"的逻辑在WorkTree::observed中给出了精确实现(work_tree.rs#L300-L307):

pub fn observed(&self, other: Version) -> bool { let version = self.version(); match version.epoch_id.cmp(&other.epoch_id) { Ordering::Less => false, Ordering::Equal => other.epoch_version <= version.epoch_version, Ordering::Greater => true, } }

即Version { epoch_id, epoch_version }先比 Epoch 的 Lamport 标识,Epoch 相同再逐分量比版本向量。这解释了为什么版本向量可以跨 Epoch 累积而不影响正确性:跨 Epoch 的比较由 Lamport 标识"短路"处理,向量只在同一个 Epoch 内部承担偏序判断。文档所搁置的"版本向量无界增长"担忧,也正是因为每个副本的计数只增不减、且所有副本都会出现在向量里——这是当前设计为换取正确性而接受的内存代价。

小结:协议要素与源码位置速查

把 003_memo_epochs.md 的操作序列映射回实现,可以得到如下速查表,便于按图索骥:

文档步骤语义源码位置
扫描新 HEAD 建立 T'GitProvider::base_entries流式加载,500 条/chunkwork_tree.rs#L254-L272
树结构落地Epoch::append_base_entries,三棵 B 树 + 命名冲突修正epoch.rs#L248-L318
合成未提交变更操作Operation四元组 + CRDTBufferOperationepoch.rs#L76-L101
缓冲区未保存编辑的冲突/变换SwitchEpoch重放 +Buffer::apply_ops收敛work_tree.rs#L985-L1081、buffer.rs#L524
广播 Epoch B(HEAD + 版本向量 + Lamport + 操作)reset→StartEpoch+OperationEnvelopework_tree.rs#L145-L155
接收端 Lamport 检查与忽略/延迟apply_ops中 Less/Equal/Greater 三分支work_tree.rs#L167-L188
缓冲区路径迁移 / 不存在则按 T 内容新建SwitchEpoch两分支,new_text_file+ 全文编辑work_tree.rs#L1047-L1079
三套时钟与 Epoch 标识Local/Global(版本向量)/Lamporttime.rs#L14-L223
跨 Epoch 状态比较Version+observed()work_tree.rs#L300-L315

需要说明的是,memo_core/README.md 明确写着该库"work in progress,愿景尚未完全实现",因此本文描述的协议是仓库当前的设计文档 + 参考库实现的组合:文档给出规范,memo_core给出可运行、可测试(Rust 工作区成员,见 Cargo.toml)的 Rust 实现;其中缓冲区映射 TODO、版本向量增长等均已由仓库自身标注为已知取舍,引用时请以这些边界为准。

  • 开发工具

【免费下载链接】xray

An experimental next-generation Electron-based text editor

项目地址:https://gitcode.com/gh_mirrors/xray/xray
点击查看免费下载

相关推荐

上一篇:如何使用manga-tui:从安装到阅读的完整指南,3分钟快速上手
下一篇:终极Linux服务器安全配置指南:WebDAV访问控制与SSL/TLS强制启用

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询