GitButler CLI 核心概念深度解析:并行工作区模型、短 ID 系统与历史编辑体系
【免费下载链接】gitbutlerThe GitButler version control client, backed by Git, powered by Tauri/Rust/Svelte项目地址: https://gitcode.com/GitHub_Trending/gi/gitbutler
本篇技术指南以 GitButler CLI(but命令)的核心概念文档 concepts.md 为主线,深入讲解 GitButler 与 Git 截然不同的工作区模型(并行栈替代串行分支)、会话级 CLI 短 ID 系统、基于 Sources/Target 的历史编辑模型、依赖跟踪、操作日志(Oplog)与冲突解决模式。读完本文,你将掌握 GitButler CLI 的设计哲学与底层运行机制,能够正确阅读but status/but diff输出并安全使用but commit、but squash、but move、but resolve等核心命令。
工作区模型:从串行分支到并行栈
传统 Git 的串行分支
在传统 Git 中,分支是互斥的工作上下文。一次只能在一个分支上工作:
main ──┬── feature-a (checkout here, work, commit, checkout back) └── feature-b (checkout here, work, commit, checkout back)- 同一时间只能在一个分支上工作;
- 切换上下文必须执行
git checkout; - 变更通过"分支隔离"实现,未提交的改动随 checkout 一起切换。
这种模型的问题在于:当你同时进行多个任务(比如修复线上 bug 与开发新功能)时,需要频繁地在上下文之间切换,git stash、临时分支成了日常操作,很容易丢失或混淆工作现场。
GitButler 的并行栈
GitButler 彻底改变了这个模型。所有"已应用(applied)"的分支同时存在于工作目录中,通过一个gitbutler/workspace合并提交整合在一起:
workspace (gitbutler/workspace) ├─ feature-a (applied, merged into workspace) ├─ feature-b (applied, merged into workspace) └─ feature-c (unapplied, not in workspace)- 可以同时在多个分支上工作;
- 无需上下文切换——所有已应用分支的改动都合并在工作目录中;
- 变更通过**分配(ASSIGNED)**给分支来归属,而不是通过 checkout 隔离。
三个关键推论
不再需要
git checkout:你不需要在分支之间切换。所有已应用分支同时存在于工作区中,编辑的文件通过 CLI ID 显式分配给目标分支。gitbutler/workspace分支是内部实现细节:它是一个包含所有已应用栈的合并提交。不要直接操作它——请始终使用but命令,而不是git checkout gitbutler/workspace或在其上直接git commit(后者会把改动提交到合并提交而非你的分支)。应用(Applied)与未应用(Unapplied)控制分支的活跃状态:
- 已应用分支:位于工作目录中,改动可见,可以修改并提交;
- 未应用分支:存在但未激活,不在工作目录中,不能直接改动;
- 通过
but apply/but unapply控制(详见后文"应用与未应用分支"一节)。
这套模型在仓库中有完整的命令实现支撑,见 crates/but/src/command/legacy/ 下的apply.rs、unapply.rs、setup.rs等模块;工作区层面的实现位于 crates/gitbutler-workspace/。
CLI ID:会话级短标识符系统
GitButler CLI 面向 AI Agent 与脚本自动化设计,因此每个对象都会获得一个短小、可读的 CLI ID,显示在but status或but diff输出中。这些 ID每次会话动态生成,且在所有实体类型之间保持唯一(不会有两个对象共用同一个 ID)——所以永远从当前命令输出中读取 ID,不要硬编码或凭空编造。
各类对象的 ID 形态
| 对象 | ID 示例 | 说明 |
|---|---|---|
| 提交 Commits | 1,ton,mpq#0 | 有 change ID 的提交使用 change ID 短前缀;否则使用 SHA 前缀;#N后缀用于消歧义共享同一 change ID 的提交 |
| 分支 Branches | fe,bu,ui | 分支名的 2–3 字符唯一子串(如feature-x→fe);若无唯一子串则回退为自动生成 ID |
| 文件 Files | uvw,qyo | 由文件路径推导,长度足以保证唯一 |
| Hunk | uvw:2e4,uvw:e2c | <文件ID>:<hunk ID>;裸but diff展示未提交 hunk |
| 已提交文件 | mzm:uvw | <提交ID>:<文件ID>,显示在but status -fv的每个提交下方 |
| 已提交 hunk | mzm:uvw:2e4 | <提交ID>:<文件ID>:<hunk ID>,由but diff <commit-id>展示 |
| 栈 Stacks | m0,n0 | 自动生成,2–3 个字符 |
ID 长度约束
当检测到是 Agent 在操作时,缩短的 change ID、文件和 hunk 前缀有最少 3 个字符的限制;而 SHA 前缀和分支/worktree ID 可以更短。这个设计是为了降低 Agent 误读、误用短 ID 的概率。
读取状态输出的正确姿势
- 每行输出的第一个 token 就是该行的 ID,直接复制即可;
- 冗长(verbose)提交行会在时间戳后附加一条信息性的
(sha …),它在每次 amend 后都会变化——仅供查看,绝不能把它传给命令。
ID 的稳定性与失效语义
理解 ID 的生命周期是安全使用 GitButler CLI 的关键:
- 分支短 ID只在其产生的那个工作区快照内有效;而分支名在无关的工作区变更中保持稳定,因此对分支做变更操作时请始终使用完整分支名。
- 文件/ hunk ID从当前输出复制后,在普通提交序列中通常保持可用,可以连续引用多个,包括跨链式
but commit调用。如果某个 ID 无法解析,重新读一次but diff再继续即可。 - 提交 ID:有 change ID 的提交用 change ID 前缀,否则用 SHA 前缀。change ID 引用在历史编辑(
amend、squash、move、uncommit、reword)后依然有效;而SHA 引用和#N后缀引用会失效——过期的 SHA 可能静默解析到错误的提交上,这是最危险的一类坑。 - 因此,当一次历史编辑链中涉及的每个引用都是 change ID 引用时,可以基于一次
status读取连续执行;否则请逐个执行,并在需要时加--status-after获取下一步所需的引用。
ID 的用法:位置参数
ID 是位置参数、空格分隔的。把 ID 作为参数传给命令:
but commit -b <branch-name> -m "message" <file-or-hunk-id> # 把选中变更提交到分支 but amend -t <commit-id> <file-or-hunk-id> <file-or-hunk-id> # 把文件/hunk 修补进提交 but squash <commit-id> -t <commit-id> -m "message" # 压缩提交 but move <commit-id>:<file-id> --above <commit-id> # 重定位已提交文件 but move <commit-id>:<file-id>:<hunk-id> --above <commit-id> # 重定位已提交 hunkbut help cli-ids会详细说明每一种 ID 的完整规则。需要特别提醒:hunk ID 中冒号后面是hunk 的 ID,不是行号范围(uvw:16-40是非法写法);也不要发明--changes/--hunk/--ids之类的标志,更不要把 ID 用逗号分隔(uvw,qyo会被解析成单个 ID 而失败)。
Worktrees 实验特性中的 ID
工作树支持属于实验特性,只有在worktreeManipulation特性标志开启时才可用(相关实现见 crates/but/src/args/worktree.rs 与 crates/but/src/command/worktree/)。在该模式下:
- 每个活跃 worktree 有自己的 ID,在
but status中以"通道(lane)"形式绘制——一个花括号{<branch>}标题(若其HEAD处于 detached 状态则显示 worktree 名),嵌套在其所依托的提交上方(可以嵌套其他 worktree 的提交,通道递归嵌套),或独立显示在栈下方(当它位于工作区之外时); - 通道中列出该 worktree 的未提交文件与提交;标题 ID 即 worktree 的名字;
<worktree>:@(ID 或名字均可)表示该 worktree 的未提交区域;<worktree>:<path>将文件名限定到该 worktree——@:<path>仍然表示主工作区。一个文件同时在多个 worktree 中脏时会产生歧义,错误信息会提示使用限定形式;- worktree 的文件 ID 或
<worktree>:@可以作为but commit的变更来源和but amend的来源:变更落到目标上,并清空该 worktree 的未提交区域; - 不带目标标志时,worktree 的变更提交到该 worktree 自身分支的顶端;显式指定的目标提交或分支不必属于该 worktree;
- 一次操作只从一个 worktree 读取——混合了多个 worktree 的选择会被拒绝;
- worktree 也可以作为目标:
but commit、but move、but pick配合-b <worktree-id-or-its-branch-name>或--below <worktree-id>会把提交放到该 worktree 所检出的分支顶端(--above会被拒绝,因为那是它的未提交区域); - worktree 自身的提交携带普通提交 ID:
reword、move、squash、pick都接受它们,重写后 worktree 的分支与 checkout 会随之更新; - 对 worktree 提交执行 uncommit 会落到该 worktree 的未提交区域,因此
squash -t要用 worktree 的区域 ID(<id>:@)而不是@来命名它;but uncommit会自动推断。
并行分支与堆叠分支
GitButler 的并行栈模型天然支持两种组织方式:并行分支与堆叠分支。
并行分支(独立工作)
用but branch new <name>创建:
main ──┬── api-endpoint (independent) └── ui-update (independent)适用于以下场景:
- 任务之间互不依赖;
- 可以独立合并;
- 代码之间没有共享。
典型例子:新增一个 API 端点与修改按钮样式,两者完全独立,可以并行推进、分别评审合并。完整实操见 examples.md 中的 Example 1。
堆叠分支(依赖工作)
把已有分支堆叠到另一个分支之上:but move <child-branch-name> --above <parent-branch-name>。
从零创建新的堆叠分支:but branch new <name> --above <parent-branch-name>——仅当子分支尚不存在时使用这个命令。
main ── authentication ── user-profile ── settings-page (base) (stacked) (stacked)适用于以下场景:
- 功能 B 需要功能 A 的代码;
- 在前序工作的基础上增量构建;
- 创建一系列相互关联的变更。
典型例子:用户资料页需要先实现认证功能。
堆叠两个已有分支:如果两个分支都已存在,需要让其中一个依赖另一个,使用顶层move命令:
but move feature/frontend --above feature/backend # 现在 frontend 堆叠在 backend 之上——两者同属一个栈从栈中拆离一个分支:
but move feature/frontend --unstack依赖跟踪:GitButler 自动跟踪哪些变更依赖哪些提交。一个依赖变更只能被提交到包含它所依赖的提交的那个栈中。注意,堆叠操作必须使用完整分支名(分支短 ID 只属于产生它的那个快照);提交重排则使用提交 ID。
编辑模型:Sources 与 Target
GitButler 的历史编辑(history editing)统一表达为来源(Sources)与目标(Target)。Sources 是位置化的 CLI ID;目标通过标志指定。@是一个特殊 ID,表示"未提交区域"。
but squash承载了这个模型的绝大部分语义——它具体做什么取决于你组合的实体类型:
| Sources | 目标(-t) | 操作 | 示例 |
|---|---|---|---|
| 提交(一个或多个) | 提交 | 把多个提交压缩到一起 | but squash mm -t nn -m "…" |
| 分支 | 提交 | 把整个分支压缩进一个提交 | but squash <branch-name> -t nn -m "…" |
| 提交(一个或多个) | 分支 | 压缩进分支的最新提交 | but squash mm -t <branch-name> -m "…" |
| 分支 | (无) | 把分支压缩成单个提交 | but squash <branch-name> -m "…" |
| 未提交文件 | 提交 | 把变更修补进一个提交 | but squash a1 -t nn |
@ | 提交 | 把所有未提交内容修补进提交 | but squash @ -t nn |
| 提交 | @ | 取消提交(uncommit) | but squash mm -t @ |
| 分支 | @ | 取消提交并删除分支 | but squash <branch-name> -t @ |
| 已提交变更 | 提交 | 把变更移动到另一个提交 | but squash nn:a -t mm |
消息标志规则:当来源是提交或分支时(除非目标是@),它们会组合成一条新消息——所以必须传-m。不传的话,Agent 运行时会保留组合出的消息(各来源消息的拼接),终端模式下会打开编辑器。其余行复用目标的消息,不需要消息标志;而-t @会直接拒绝消息标志。
与 amend 的重叠:表格中两个 amend 行与but amend重叠——推荐使用but amend -t nn a1,它只做这一件事,接受相同的 ID。
其他编辑命令是同一模型上更窄的入口:
but amend -t <commit> <changes>—— 把未提交变更修补进已知提交;but uncommit <commits-branches-or-committed-changes>—— 把已提交工作移回未提交状态;分支会被移除,一次调用中的已提交变更必须来自同一个提交;but move <sources> --above|--below|--branch|--unstack—— 重定位提交、已提交变更或分支;这是带位置控制的命令;but discard <changes>—— 丢弃工作而不是重定位它。
这些命令在仓库中的实现分别位于 crates/but/src/command/legacy/ 下的squash.rs、amend.rs、uncommit.rs、move.rs、discard.rs、split.rs、pick.rs、reword.rs等模块。
依赖跟踪:防止破坏性状态
GitButler 自动跟踪变更之间的依赖关系。
工作原理
Commit C1: Added function foo() Commit C2: Added function bar() Uncommitted: Call to foo() in new code上例中,未提交变更依赖C1(因为它调用了foo())。其推论为:
- 不能把这个变更提交到不包含 C1 的栈;
- 当把它修补进历史时,它应当属于 C1(或 C1 之后的提交);
- 如果试图移动这个变更,GitButler 会阻止无效操作。
为什么重要
依赖跟踪从机制上防止你制造损坏状态:
- 不能把依赖代码移离它的依赖;
- 不能把变更提交到错误的栈;
- 确保每个分支保持独立可用。
but commit和but amend会原子性地失败(错误信息形如 "Cannot commit: N changes could not be applied",并点名每个被拒变更依赖的分支与提交),此时没有任何内容被提交、也没有-b分支被创建。若只有一个依赖分支,错误提示会给出精确的恢复命令(but move <your-branch> --above <dependency-branch>或but branch new <name> --above <dependency-branch>);若错误只列依赖而没有提示,运行but status -fv查看依赖提交的位置后再决定放置点。
空提交占位符
你可以创建空提交:
but commit --empty --below nn -m "TODO: Add error handling" but commit --empty --above nn -m "TODO: Add error handling"使用场景:
- 标记未来的工作:创建空提交作为后续变更的占位;
- 组织历史:在提交历史中加入语义标记。
典型工作流:
but commit --empty --below rr -m "TODO: Add error handling" # 之后,把错误处理的变更修补进占位提交 but amend -t <empty-commit-id> <file-id>这让你可以在历史中"预定"一个位置:先立起占位符,再把后续改动通过amend精确落到它上面,保持历史叙事清晰有序。
操作历史(Oplog)
GitButler 中的每一个操作都会被记录到 oplog(operation log,操作日志)中。
记录内容
- 分支的创建/删除;
- 提交;
- squash / amend / move / uncommit / discard 操作;
- push / pull 操作。
使用 Oplog
but oplog # 查看历史 but undo # 撤销上一个操作 but redo # 重做上一个已撤销的操作 but oplog list --since <snapshot-id> but oplog list --snapshot but oplog snapshot -m "known good" but oplog restore <snapshot-id> # 恢复到特定时间点可以把它理解为"git reflog",但记录的是所有 GitButler 操作,而不仅是分支移动。
安全网:犯了错误?but undo撤销它。想回到更早的状态?but oplog restore恢复到之前的快照。
源码层面的实现
oplog 的 CLI 实现位于 crates/but/src/command/legacy/oplog.rs:oplog list支持--since <sha-prefix>过滤,并通过snapshots_iter遍历快照序列(每个快照的commit_id即其短 ID,展示为 git SHA 而非 CLI ID);oplog restore先通过get_snapshot取出目标快照、peel_restore_snapshot处理快照链,再调用restore_snapshot_with_kind恢复工作区(恢复完成后输出 "Workspace has been restored to the selected snapshot.")。底层存储位于 crates/gitbutler-oplog/src/(oplog.rs、snapshot.rs、entry.rs、state.rs、reflog.rs),undo/redo命令实现在 crates/but/src/command/legacy/undo_redo.rs(pub fn undo、pub fn redo)。
注意:but oplog restore的快照引用是 git SHA,不是 CLI ID——从but oplog输出中复制 SHA 使用。由于 oplog 记录所有操作,but undo不仅能撤销提交类操作,还能撤销 squash、move、resolve 等历史编辑操作,恢复被错误合并的状态。
应用与未应用分支
分支存在两种状态:
已应用分支(Applied)
- 在工作区中处于激活状态;
- 已合并进
gitbutler/workspace; - 改动在工作目录中可见;
- 可以修改并提交。
未应用分支(Unapplied)
- 存在但未激活;
- 不在工作目录中;
- 不能直接修改(必须先用 apply);
- 适合临时搁置工作。
控制状态
but apply <branch-name> # 激活分支 but unapply <branch-name> # 停用分支使用场景:
- 停用引起冲突的分支;
- 聚焦工作子集(停用其他分支);
- 临时搁置工作而不删除。
注意:GitButler没有 stash 概念。当需要"暂存"未提交改动时,正确做法是but commit -b <branch> -m "wip" <ids>提交一个 WIP 提交,之后需要时再but uncommit取回(SKILL.md 中明确要求不要手工还原文件)。另外,标记为(merged upstream)的分支表示已合入上游,运行but pull即可移除它们。
冲突解决模式
当but pull引发冲突时,受影响的提交会被标记为冲突状态。冲突不会中断操作——rebase 总会完成,冲突提交在but status中被标记为{conflicted}。
解决流程
- 识别:
but pull的摘要会按从旧到新的顺序列出每个冲突提交的 ID(but status也会显示它们); - 进入模式:
but resolve <commit-id>—— 它会打印带行号的冲突区域。有多个冲突提交时,从最旧的开始解决:完成靠下的提交会自动把其上的提交 rebase 到新基础上; - 修复冲突:编辑文件、移除冲突标记(
<<<<<<<、|||||||、=======、>>>>>>>)。多个文件冲突时but resolve status会重新列出剩余项; - 收尾:
but resolve finish或but resolve cancel。finish会报告剩余标记与幸存的未提交变更,因此无需再额外检查。
解决期间的限制
- 处于专注于该提交的特殊模式中;
- 其他 GitButler 操作受限;
but status会显示你正处于解决模式;- 必须完成(finish)或取消(cancel)后才能继续正常工作。
更精细的做法(Agent 工作流)不进入模式,而是逐冲突应用:but resolve conflicts <branch>列出冲突、but resolve apply <path>:<N>通过 stdin/--file应用合并内容或--ours/--theirs整侧采用、--ai委托给配置的 AI 模型,全程只需but undo即可回退错误的解决——详见 SKILL.md 的"Resolve conflicted commits"小节。解决冲突期间严禁使用git add、git commit、git checkout --theirs/--ours等任何 git 写命令,只需直接编辑文件。
只读 Git 命令的边界
不修改状态的 Git 命令可以安全使用:
安全(只读):
git log—— 查看历史;git diff—— 查看变更(但更推荐but diff,它支持 CLI ID);git show—— 查看提交;git blame—— 查看行历史;git reflog—— 查看引用日志。
不要在 GitButler 工作区中使用:
git status—— 具有误导性:它显示的是合并后的工作区状态,而非各栈的状态;而且缺少 Agent 需要的 CLI ID;git commit—— 会提交到工作区合并提交,而不是你的分支;git checkout—— 破坏工作区模型;git rebase—— 与 GitButler 的管理相冲突(rebase 应用分支到最新目标就是but pull,绝不是move、config target或裸git pull/git rebase);git merge—— 使用but merge代替。
经验法则:能读就行;要写就用but。在仓库的 skill 定义中,这条规则被固化为不可妥协的第一原则:所有写操作必须走but,当用户表达一个 git 写命令时,翻译成but再执行。but与 git 写命令的完整映射见 SKILL.md 的 "Git-to-But Map" 表格(如git add+git commit→but commit -b <branch> -m ... <ids>、git rebase -i→but move/but squash/but reword、git cherry-pick→but pick、gh pr create→but pr new)。
小结
GitButler CLI 的核心概念可以浓缩为一句话:用并行栈替代串行 checkout,用会话级 CLI ID 替代记忆中的哈希,用 Sources/Target 编辑模型统一所有历史改写,用 Oplog 兜底每一次误操作。理解并遵循"只读 Git 命令可用、写操作一律走but"的边界,就能在享受多分支并行工作流的同时,保持历史清晰、状态可回溯。相关配套文档还包括命令速查 reference.md(可用but skill reference从 CLI 直接渲染)与实战工作流示例 examples.md,以及随时可用的but help cli-ids与but skill concepts。
【免费下载链接】gitbutlerThe GitButler version control client, backed by Git, powered by Tauri/Rust/Svelte项目地址: https://gitcode.com/GitHub_Trending/gi/gitbutler
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考