Jujutsujj run设计解析:跨修订并行运行命令、自动改写提交与失败处理策略
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
jj run是 Jujutsu 中用于在多个修订(revision)上批量执行用户命令(脚本、linter、formatter、构建系统等)的命令,它把每个目标修订检出到隔离的临时工作副本中运行子进程,再把子进程产生的改动自动改写入对应提交。本文以仓库中的设计文档 docs/design/run.md 为骨架,结合jj run的实际实现 cli/src/commands/run.rs 与测试 cli/tests/test_run_command.rs,完整讲解该命令的设计动机、目标与非目标、工作副本池机制、提交改写模型、并行与失败策略、全部命令选项,以及设计提案与最终实现的差异。读完后你将理解jj run的设计取舍,掌握在本地仓库中用它批量跑测试、格式化、lint 与构建的完整方法,并能读懂其底层工作副本管理、transform_descendants重写与进程调度实现。
设计背景与前身(Preface & Context)
jj run的设计文档(Initial Version, 10.12.2022)目标是明确jj run的正确行为规范,并作为后续jj test、jj fix、jj format等专用命令的基础设施。在设计之初,作者调研了其他 DVCS 中的既有实现:
| 前身实现 | 所属项目 | 与本提案的关系 |
|---|---|---|
git test | git-branchless | 与本提案最接近,直接启发了"为每个并行命令创建临时工作副本"的做法 |
hg run | Google 内部 Mercurial 扩展 | 功能类似,但依赖 CitC(Clients in the Cloud)虚拟文件系统做惰性应用 |
hg fix | Google 开源 Mercurial 扩展 | 更专门化:在没有完整工作目录上下文的情况下重写文件内容 |
git rebase -x | Git | 在 rebase 过程中机会式运行命令 |
git bisect run | Git | 运行命令以定位引入 bug 的提交 |
需求来源:最初的需求来自一次 GitHub 讨论(关于 pre-commit 集成),而在关于 git hook 模型的 Discord 讨论中,社区达成共识——不要重蹈 git hook 的覆辙。另一个关键约束是:目前 Jujutsu 的开源后端(Git、Simple)都没有支持虚拟文件系统的工作副本,因此无法像 Google 内部hg run那样"懒应用"命令到受影响的文件;在实现基于虚拟文件系统的工作副本之前,jj run只能在常规本地磁盘工作副本中运行命令。
目标与非目标
目标(Goals)
- 命令应能应用于任意修订,无论已发布(published)还是未发布(unpublished)。
- 能并行运行实际命令,同时保持良好的控制台输出。
- 命令能作用于任意提交,包括工作副本提交
@本身或其他任何提交。 - 存在某种方式指示硬失败(hard failure)。
- 构建足够的基础设施,为将来的
jj test、jj fix、jj format铺路。 - 主要目标是"足够好"(good enough),功能可随未来迭代扩展。
非目标(Non-Goals)
- 不为
jj run堆叠jj test/jj format/jj fix的用例——那是它们各自的职责。 - 命令不应"太聪明",对工作流做过多的假设只会让用户困惑。
- 不做输出结果的智能缓存,因为用户输入的命令不可预测。
- 不做细粒度的用户可见配置,避免不必要的复杂度。
- 不在
jj run中塞入fix子命令,那会过度压缩设计空间。
这些原则在后面设计里得到了贯彻:实现保持简单直接,把"智能"留给未来的专用命令。
典型使用场景(Use-Cases)
设计文档给出了三类典型场景:
Lint 与格式化:
jj run 'pre-commit run' -r $revset jj run 'cargo clippy' -r $revset jj run 'cargo +nightly fmt'跨仓库(本地与远端)的大规模改动:
jj run 'sed /some/test/' -r 'mine() & ~remote_bookmarks(exact:"origin")' jj run '$rewrite-tool' -r '$revset'构建系统:
jj run 'bazel build //some/target:somewhere' jj run 'ninja check-lld'设计文档同时指出:部分用例应获得专用命令以便进一步优化,例如jj format(在一个修订的某文件子集上运行一串 formatter)和jj fix(在修订的某文件子集上运行rustfmt --fix或cargo clippy --fix)。当前仓库已落地了与设计一脉相承的fix命令族(见 cli/src/commands/fix.rs 及其配置说明 docs/config.md),这正是设计文档中"jj run构建基础设施"目标的体现。
核心设计:.jj/目录下的临时工作副本池
设计提案(Base Design)
设计文档提出的基础方案是:所有工作都在仓库的.jj/目录内完成,从而对用户隐藏全部复杂度,同时保留用户当前工作区不动。要点如下:
- 借鉴 git-branchless
git test的做法,为每个并行命令创建一个临时工作副本。 - 工作副本在多次
jj run调用之间复用;如果待处理的提交数多于并行任务数,也在单次调用内部复用。 - 保留临时目录中的被忽略文件(ignored files):这能让增量构建受益(例如让 cargo 复用其
target/目录);但代价是运行结果可能变得不那么可复现。因此设计一个 flag 用于从临时工作副本中移除被忽略文件。 - 保留被忽略文件还带来磁盘占用问题(cargo 的
target/经常占用数十 GB),同一个清理 flag 可同时应对;此外可能还需要一个"运行命令之后清理临时工作副本"的 flag。 - 早期版本直接用TreeState管理临时工作副本,意味着在临时工作副本内部再运行
jj将不可用;后续可扩展为完整的Workspace。为防止临时工作副本中的操作影响主仓库,可使用独立的 OpHeadsStore。
实现印证:WorkspacePool
实现把上述设计落到了 cli/src/commands/run.rs 的WorkspacePool结构(run.rs#L159-L350),注释明确写道:
- 工作区池位于
.jj/run/default/下,每个槽位是.jj/run/default/N/,内含working_copy/与state/子目录,锁文件为兄弟文件.jj/run/default/N.lock(run.rs#L154-L158)。 - 工作区在
jj run调用之间持久保留,使构建产物可复用;获取槽位时取第一个空闲者,因此多个并发的jj run进程会协作共享这个池。 - 槽位获取使用文件锁 + 指数退避重试(10ms 起步、250ms 封顶,见 run.rs#L196-L204);持锁期间
RunWorkspace持有的FileLock保证独占,丢弃即释放(run.rs#L111-L120)。 - 复用工作区时,先加载持久化的 tree state,
check_out会只 diff 变更文件;persist()在任务结束后把快照后的 tree state 写回磁盘,供下次获取时做增量对比(run.rs#L122-L129)。 - 崩溃恢复:检出前先删除磁盘上的
tree_state作为"脏标记";若上次任务中途崩溃,下次获取会看到文件缺失而整体清空该槽位,避免信任不一致的状态(run.rs#L218-L229)。
关于"临时工作副本中能否运行jj":当前实现确实只使用TreeState(见 lib/src/local_working_copy.rs),没有为槽位建立完整 Workspace,与设计文档"早期版本直接用 Treestate"的规划一致。
修改工作副本:squash、reparent 与忽略改动
设计文档提出:子进程在临时工作副本中运行,不会干扰用户自己的工作副本,因此jj run运行期间用户可以继续工作。子进程被允许通过更新自己分配的工作副本对仓库做改动,具体语义以"只对提交 A、B 运行(B 的父是 A)"为例:
- 在 A 之上产生的任何改动会被squash(压合)进 A,形成 A';
- 同理,B 之上的改动被压合成 B'。
- 之后有两种选择:对 B' 相对 A' 做普通 rebase,或者直接把 B' 的父指针更新为 A'(reparent)。前者在子进程只基于父提交做部分树更新时更合适。
- 此外,可能还需要一个忽略子进程工作副本中一切改动的选项。
实现印证:三种提交改写模式
实际实现提供了对应三种模式的选项(run.rs#L567-L664 的RunArgs):
| 实现选项 | 对应设计语义 |
|---|---|
| 默认(无选项) | 把命令引入的 diff(new_tree − old_tree)合并传播到 rebase 后的树上,后代随祖先改写而 rebase |
--restore-descendants | 保留后代内容不变(对应设计中的 reparent/只更新父指针) |
--ignore-changes | 命令照跑,但不重写任何提交,改动全部丢弃 |
具体重写逻辑在cmd_run末尾的transform_descendants中(run.rs#L855-L898):
- 默认模式下,用三方合并把
(new_tree, "command result")、(old_tree, "original commit")、(rebased_tree, "rebased")合并,从而把命令产生的差异"嫁接"到已 rebase 的树上; --restore_descendants模式下则直接set_tree(new_tree),忽略祖先改写对内容的影响;- 对不在 run 集合内的后代,
parents_changed()时分别走reparent()(restore 模式)或rebase()(默认模式)。
结束时输出统计信息,如Rewrote N commits.与Rebased N descendant commits.(run.rs#L899-L909)。测试 cli/tests/test_run_command.rs 中的test_run_noop验证了"命令未改动任何 tracked 文件时提交不被重写"的路径。
修改仓库:独立 OpHeadsStore 与父指针校验
设计文档指出:一旦通过独立的 OpHeadsStore给予子进程仓库的 fork 访问权,子进程就能在自己的 fork 中创建新操作(operation)。此时若用户运行jj run -r foo而子进程 checkout 了另一个提交,行为是不明确的——合理的做法是在子进程返回后校验工作副本提交的父指针未变,子进程创建的任何操作将被忽略。底层相关的操作存储实现在 lib/src/op_heads_store.rs。
重写修订与不可变提交
与其他命令一致,jj run拒绝重写 public/immutable 提交;对私有/未发布修订,通过命令选项执行 amend 或 reparent。
实现印证:cmd_run在改写前调用check_rewritable,除非传了--ignore-changes(run.rs#L722-L726)。测试test_run_on_immutable展示了错误输出:
Error: The root commit 000000000000 is immutable即对all()(包含 root)运行时会因不可变提交直接失败(见 cli/tests/test_run_command.rs)。
执行顺序与并行度
设计文档认为按拓扑顺序执行很有用——例如构建系统这类成本与增量变化成正比的命令;拓扑序也是"在首个失败处停止"这类功能有意义的前提(对一串提交跑测试时,按拓扑/时间顺序前进、遇首个失败即停,因为后续很可能同样失败)。并行调度时可以让每个执行槽尽量沿用工作副本以减少增量变化,但如果需要,让所有提交并发运行也应可行。
实现印证:jobs 解析与并发调度
并行度的解析遵循优先级--jobs命令行参数 >run.jobs配置 > 默认 1(resolve_jobs,run.rs#L667-L693)。配置文件写法(见 docs/config.md#run):
[run] jobs = 8run.jobs必须是正整数;命令行可用-j/--jobs覆盖,例如:
jj run -j 4 -- cargo fmt调度实现使用 Tokio 的JoinSet,保持最多jobs个任务在飞、按提交顺序启动(run_inner,run.rs#L386-L432);并发写控制台时,每个子进程的 stdout/stderr 被先完整缓冲、进程结束后原子输出,避免多任务输出交错(run.rs#L506-L512)。--passthrough则直接把 stdout/stderr 接到终端以支持 TTY 程序,但此时强制只允许 1 个 job(run.rs#L730-L735)。
另外,默认目标修订集由配置revsets.run决定,内置值为reachable(@, mutable())(见 cli/src/config/revsets.toml),即从@可达的可变提交;未指定-r时按此集合执行(run.rs#L706-L712)。
失败处理策略(Dealing with failure)
设计文档定义了三种由 UI 选项暴露的策略,用于定制冲突处理:
| 策略 | 行为 |
|---|---|
| Continue | 某个子进程失败后继续处理子修订;退出时向用户报告失败的修订 |
| Stop | 发出致命失败信号,取消尚未开始的已调度工作,但让已启动的子进程跑完;向用户显示子进程产生的错误 |
| Fatal | 立即停止处理并杀死所有正在运行的进程;告知用户未能将该命令应用到特定修订 |
设计同时承诺:只要任一子进程失败,受影响的提交保持原状(不落地部分格式化等半成品状态),以提供更好的用户体验。
实现印证与差异
实际实现以"默认在首个失败处停止 +--ignore-errors继续"的方式落地了该策略:
- 默认(相当于 Stop):
run_inner中一旦有任务失败且未设ignore_errors,会command_futures.shutdown()等待已启动进程退出(避免僵尸进程),随后消费者循环返回包含失败修订摘要的CommandError(run.rs#L414-L429、run.rs#L823-L832)。 --ignore-errors(相当于 Continue):继续处理剩余修订;失败命令的改动不会保存,成功命令的改动在最后原子应用;失败命令的退出码不会影响jj run自身的退出码(run.rs#L654-L663)。- 失败不改写提交:
rewrite_commit在命令失败时返回new_tree: None,只有成功任务的新树才会进入rewritten_commits(run.rs#L544-L554);同时会清掉命令留下的非忽略未跟踪文件,避免污染下一次check_out(run.rs#L524-L537)。
对应测试覆盖了test_run_stops_after_first_failure、test_run_failure_rewrites_nothing、test_run_ignore_errors_rewrites_successes、test_run_ignore_errors_all_fail、test_run_recovers_after_failure等路径(见 cli/tests/test_run_command.rs)。需要注意的是:设计提案中的--error-strategy=continue|stop|fatal单一 flag 在实现中演化成了"默认 stop +--ignore-errors"的组合。
资源约束(Resource constraints)
设计文档提出约束执行以防止资源耗尽,相关资源包括:
- 机器上的 CPU 与内存:
jj run可提供简单缓解措施,例如默认将并行度限制为"CPU 核数",或按"可用内存 / 每次调用内存估算"来限制并行度。 - jj 未知的外部资源:例如并行命令可能希望限制到某个服务器的总连接数。设计倾向是把这类约束推迟给被调用命令自身的实现处理,而不是让 jj 去感知和传递这些信息。
命令选项全览
设计文档强调:任何 jj 命令的基础形态都应可用,默认情况下jj run作用于@(当前工作副本)。设计提案列出的选项如下:
| 设计提案选项 | 说明 |
|---|---|
--command | 第一个参数的显式名称 |
-x | 兼容 git(可能别名其他命令) |
-j,--jobs | 并行度 |
-k,--keep-going | 失败时继续(可能别名其他命令) |
--show | 显示受影响修订的 diff |
--dry-run | 不真正执行,记录所有拟执行的文件与参数 |
--rebase | 在所有父级上 rebase 产生的 diff(可能别名其他命令) |
--reparent | 把受影响修订的父改为新变更(可能别名其他命令) |
--clean | 移除现有工作区与被忽略文件 |
--readonly | 跨多次 run 调用忽略改动 |
--error-strategy=continue\|stop\|fatal | 见上文失败策略 |
实现中的实际选项
当前实现的RunArgs(run.rs#L587-L664)为:
| 选项 | 说明 |
|---|---|
COMMAND(位置参数)+ARGS | 要运行的命令及其参数;用--分隔符可传递以-开头的参数,如jj run --revisions=... -- cargo build --release |
-r,--revision(别名--revisions) | 要处理的修订集(revset),可重复指定 |
-x(隐藏) | 无操作选项,仅为匹配git rebase -x的界面 |
-j,--jobs | 并行进程数,覆盖run.jobs配置,默认 1 |
--root | 在每个提交的工作副本根目录运行命令,而非从调用jj run的子目录运行 |
--clean | 运行命令前删除各工作副本,使每个提交从全新检出的树开始(默认复用工作副本以保留构建产物) |
--restore-descendants | rebase 后代时保留其内容(而非保留 diff) |
--passthrough | 把 stdout/stderr 直接接到终端(支持 TTY 行为如彩色输出、进度条;stdin 不继承;只允许 1 个 job) |
--ignore-changes | 检出并运行命令但不重写任何提交,适合只读检查(测试、linter);允许作用于 immutable 提交而无需--ignore-immutable;与--restore-descendants互斥 |
--ignore-errors | 命令失败时继续处理剩余修订(见失败策略) |
子进程环境变量
实现为每个子进程设置三个环境变量(run.rs#L486-L494),子进程与脚本可用它们感知当前修订:
JJ_CHANGE_ID:当前提交的 change id(reverse hex)JJ_COMMIT_ID:当前提交的 commit id(hex)JJ_WORKSPACE_ROOT:本次运行的工作副本根目录(即临时工作副本)
测试test_run_sets_env_vars验证了子进程把JJ_CHANGE_ID与JJ_COMMIT_ID写入文件后,jj run会把它们随提交一起落地(见 cli/tests/test_run_command.rs#L160-L234)。
与 git rebase -x 的兼容示例
沿用实现 doc 注释中的示例:
# 在本地工作上运行 pre-commit jj run -j 4 -- pre-commit run .github/pre-commit.yaml另外注意:实现里-x仅作隐藏的 no-op 以匹配git rebase -x的界面(run.rs#L605-L607),与设计提案一致。
与其他命令的集成
设计文档明确了各命令与jj run的协同方式:
| 命令 | 处理方式 |
|---|---|
jj log | 无需特殊处理 |
jj diff | 无需特殊处理 |
jj st | 现阶段重新打印jj run的最终输出 |
jj op log | 无需特殊处理,但有待在 issue #963 中进一步讨论 |
jj undo/jj op revert | 无需特殊处理 |
由于jj run的产物是"按提交改写",而 Jujutsu 的操作日志天然记录这类重写,因此jj undo、jj op revert等即可按通用语义回滚jj run造成的修改。
开放问题(Open Points)
设计文档在成稿时留下了三个开放问题:
- 该命令是否应与工作副本后端绑定(working copy backend specific)?
- 如何管理命令产生的进程?
- 配置选项应是用户级还是仓库级?
这些问题的部分答案已在实现中给出:工作副本复用逻辑放在WorkspacePool中、进程由 Tokio 管理、run.jobs与revsets.run成为用户级配置项,但进程管理细节与后端绑定问题仍属可演化的设计空间。
未来可能性(Future possibilities)
设计文档列举了若干未来方向:
- 在内存中重写文件,这是一个不错的优化(避免反复物化工作副本)。
- 暴露部分内部状态,以实现更精确的资源约束。
- 与虚拟文件系统的集成选项,使其可以缓存所需的工作副本。
- 一个Jujutsu 全局的缓存工作副本概念,因为物化工作副本可能很昂贵。
- 定制失败消息,这对机器人(bots)可能有用,类似 Bazel 的
select(..., message = "arch not supported for $project")。 - 让
jj run异步化:spawn 一个主进程后直接返回用户,并增量更新jj st的输出。
实现中也能看到相关伏笔,例如rewrite_commit中"将新树序列化到/output/{id-tree}以便缓存查找"及"以 trait 抽象执行器以对接 Bazel RE 协议"的 TODO 注释(run.rs#L476-L483、run.rs#L550-L551)。
从设计到实现的代码导航
如果想深入阅读jj run的落地情况,以下仓库路径可以直接跳转:
- 设计文档原始版本:docs/design/run.md(网站版位于 web/docs/src/content/docs/design/run.md)
- 命令实现(
RunArgs、WorkspacePool、run_inner、rewrite_commit、cmd_run):cli/src/commands/run.rs - 命令注册与分发:cli/src/commands/mod.rs#L216
- 集成测试(覆盖环境变量、失败策略、子目录跳过、root flag、默认 revset 等):cli/tests/test_run_command.rs
run.jobs配置说明:docs/config.md#run- 默认修订集
revsets.run = "reachable(@, mutable())":cli/src/config/revsets.toml - 底层依赖组件:TreeState 位于 lib/src/local_working_copy.rs,操作存储 lib/src/op_heads_store.rs,工作区抽象 lib/src/workspace.rs
总而言之,jj run的设计文档确立了一条"简单、不过度聪明、可并行、失败可定制"的路线:用.jj/下的临时工作副本池隔离副作用、用 squash/reparent 模型传播子进程改动、用三种失败策略把控制权交还用户,同时为jj test、jj fix、jj format预留了基础设施。而当前实现则忠实地把这份设计落到了WorkspacePool、Tokio 任务调度与transform_descendants重写管线上,二者对照阅读,是理解 Jujutsu 如何在"命令执行"与"提交改写"之间建立桥梁的最佳途径。
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考