资讯动态

Xray Memo Epoch 机制:Git HEAD 移动时工作树副本的重建协议与未保存编辑合并

发布时间:2026/9/25 3:39:11 来源:尧图企业网站定制
开发工具【免费下载链接】xrayAn 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: OptionOid, // 该 Epoch 对应的 Git HEAD SHA(Oid [u8; 20]) base_entries_next_id: u64, base_entries_stack: VecFileId, metadata: btree::TreeMetadata, // 文件/目录元数据 parent_refs: btree::TreeParentRefValue, // 子节点 - 父节点(支持重命名历史) child_refs: btree::TreeChildRefValue, // 父节点 - 子节点(支持可见性/冲突) replica_locations: HashMapReplicaId, ReplicaLocation, version: time::Global, // 工作树的当前版本向量 local_clock: time::Local, text_files: HashMapFileId, TextFile, deferred_ops: OperationQueueOperation, }对照文档中广播新 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)GlobalArcHashMapReplicaId, 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 Gits 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) - BoxStreamItem DirEntry, Error io::Error; fn base_text(self, oid: Oid, path: Path) - BoxFutureItem 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: Vecbuffer::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 中所有带未保存编辑的缓冲区:用该缓冲区在 T 中的路径,把 T 中的最后保存内容(T)与 T 中的当前内容(T)做 diff。这个 diff 描述了在我们控制之外被触碰的区域集合;遍历 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 trees 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):Lamport 时间戳检查:若收到的 Epoch 时间戳小于当前 Epoch → 直接忽略(源码中为Ordering::Less {});等于当前 Epoch → 把操作加入cur_epoch_ops正常应用;大于当前 Epoch(Epoch 尚未启动)→ 放入defer_epoch_op延迟队列,等新 Epoch 建立后再重放(work_tree.rs#L181)。重建树:对通过校验的 Epoch,Scan all entries from Gits database based on the new epochs 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 建立 TGitProvider::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→StartEpochOperationEnvelopework_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 状态比较Versionobserved()work_tree.rs#L300-L315需要说明的是,memo_core/README.md 明确写着该库work in progress,愿景尚未完全实现,因此本文描述的协议是仓库当前的设计文档 参考库实现的组合:文档给出规范,memo_core给出可运行、可测试(Rust 工作区成员,见 Cargo.toml)的 Rust 实现;其中缓冲区映射 TODO、版本向量增长等均已由仓库自身标注为已知取舍,引用时请以这些边界为准。赞分享开发工具【免费下载链接】xrayAn experimental next-generation Electron-based text editor项目地址https://gitcode.com/gh_mirrors/xray/xray点击查看免费下载相关推荐用nas-tools把20GB的4K电影压到3.8GBNAS媒体压缩实战指南用nas tools把20GB的4K电影压到3.8GBNAS媒体压缩实战指南 存储条快飘红的时候先别急着下单硬盘。把媒体库交给 nas tools 做一轮媒如何通过混合内部模型实现高效稳定的腿部机器人运动控制3步快速上手指南如何通过混合内部模型实现高效稳定的腿部机器人运动控制3步快速上手指南 HIMLoco是由OpenRobotLab开发的开源项目专注于基于学习的腿部机器人运动vLLM-Omni TTS 模型接入完全指南从参考实现到流式生产部署的实战六站之旅vLLM Omni TTS 模型接入完全指南从参考实现到流式生产部署的实战六站之旅 vLLM Omni 是全模态模型推理框架TTS 是它当前最活跃的扩展方向人工智能大模型模型推理服务多模态语音音频媒体生成本地部署上一篇如何使用manga-tui从安装到阅读的完整指南3分钟快速上手下一篇终极Linux服务器安全配置指南WebDAV访问控制与SSL/TLS强制启用创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑