最近几周同时在跟三件事:一个正在跑的重构,一个每天都要临时上线的紧急修复,还有一个想抽空试的技术方案。三个都得在同一个仓库里改代码,但主分支一天要切换十几次,编辑器缓存、构建产物、node_modules 状态全都糊在一起。

后来把三件事分别放到三个 git worktree 里,问题少了一半——每份代码有自己的目录、自己的 node_modules、自己的 IDE 索引。但新的麻烦冒出来:git worktree add 之后要手动 cd、要重新装依赖、要重新起 dev server,一套东西下来 3 到 5 分钟没了。开一个新工作树的心理成本高到不如硬着头皮切分支。

这篇讲我怎么把 git worktree 包成一个 TUI CLI(wtc),做到"敲一条命令、选一次基线、点一次环境、直接工作"。工具已经开源在 github.com/FxRayHughes/worktreecli

现象:worktree 好用,但每次都得重新准备一遍

git worktree add ../feature-x main 建目录只用 1 秒,但一个新工作树可用需要经过:

  • cd ../feature-x

  • pnpm install「或 codegraph init / pip install -r requirements.txt 之类的初始化」

  • code .「或者 IDE 加载 workspace」

  • 一个新终端 tab,cd 到新目录再手动起 dev server

这四步每次都是一样的,但每次都得手动敲。更糟的是不同项目的初始化命令不一样——前端要 pnpm install,Go 项目要 go mod download,Rust 要 cargo build --release。手动敲的时候脑子里还得记一下这个项目要跑哪个。

原因:git 只管仓库,不管环境

git worktree 本身只做一件事——在磁盘另一个位置签出一份工作副本,共享 .git 目录。它不知道你的项目要装依赖,不知道你 IDE 缓存放在哪,也不知道你想在新工作树里跑什么。

这是正交设计,也是好设计——git 不该把 npm、pip、cargo 的知识内嵌进去。但用户视角上,git worktree add 只完成了整套工作的 20%,剩下 80% 都是"每个项目自己那套准备动作"。

需要一层壳,把 git 层的原子操作和"项目层的初始化脚本"绑在一起。这层壳有三件事要做:

  1. 给用户一个统一的 UI,选基线分支、选初始化环境、命名工作树

  2. git worktree add 之后自动跑对应的初始化脚本

  3. 让用户能顺畅地"进入"新工作树——要么打开新终端,要么切换当前 shell 的 cwd

方案:TUI 主流程 + 环境脚本 yml + 会话模式

wtc 的主流程一路推到底,中间可以停下来选具体的分支和环境:

drawing-1785516466446

每一步都是一个 TUI 页面。分支选择页会调 git for-each-ref 列本地和远程分支,按 f 可以先 git fetch --all --prune;环境选择页读 ~/.wtc/environments/*.yml;命名页给一个默认值 <repo>-<6位哈希>,也可以直接改;会话模式页三选一。

环境 yml 支持四个平台块

一个"环境"就是一段初始化脚本。既然要跨平台,一个 yml 里放四段脚本,运行时按当前系统挑:

name: "CodeGraph 初始化"
description: "在新工作树上初始化 codegraph 索引"

onCreate:
  default: |
    cd "$CODEX_WORKTREE_PATH"
    echo "worktree ready: $CODEX_WORKTREE_PATH"
  macos: |
    cd "$CODEX_WORKTREE_PATH"
    codegraph init
  linux: |
    cd "$CODEX_WORKTREE_PATH"
    codegraph init
  windows: |
    Set-Location "$env:CODEX_WORKTREE_PATH"
    codegraph init

onSpawned:
  windows: |
    Write-Host "welcome to $env:CODEX_WORKTREE_NAME"

onCleanup:
  default: ""

运行时按 runtime.GOOS 选对应块,选不到回退到 defaultdefault 也为空就跳过。

三个阶段的分工也是明确的:

  • onCreate:worktree 建好后立刻在后台跑,wtc 自己的子进程,适合做"一次性初始化",比如索引构建、依赖安装

  • onSpawned:会话模式为 spawn 时,在新打开的终端窗口里跑,适合 nvm use / source venv/bin/activate / pnpm dev 这种要保留交互式 shell 的动作

  • onCleanup:worktree 被删前跑,可选

变量注入用一组固定前缀避免和用户脚本冲突:CODEX_WORKTREE_PATH / CODEX_SOURCE_TREE_PATH / CODEX_WORKTREE_NAME / CODEX_BRANCH / CODEX_BASE_BRANCH。用户脚本里像用普通环境变量一样引用即可,Windows 用 $env:变量名

三种会话模式解决 cwd 归属问题

这是设计上最纠结的一点。wtc 是子进程,改不了父 shell 的 cwd,这是 shell 层的硬限制。要让用户"进入"新工作树,只有三条路:

drawing-1785516496495

print 模式是默认。wtc 打印一句 cd '/path/to/worktree',同时尝试用系统剪贴板工具写进去。用户按 Ctrl+V + Enter 就能切过去。看上去最笨,但最可靠——不需要用户配任何 shell 配置。

spawn 模式打开一个新终端窗口,cwd 直接设置为 worktree。Windows 优先 wt.exe,回退到 cmd;macOS 用 AppleScript 调 Terminal.app;Linux 依次尝试 x-terminal-emulator / gnome-terminal / konsole / xterm。onSpawned 脚本也在这个新窗口里跑。

eval 模式是最优雅的方案,但要用户配一个 wrapper 函数。wtc 加 --eval 参数后把 shell 片段写到 stdout,包一层函数就能 eval

# ~/.zshrc
wtc() { local out; out=$(command wtc "$@" --eval) && eval "$out"; }

这样在当前 shell 里敲 wtc,走完创建流程后当场切换到新工作树,不用新开窗口、不用手动粘贴。这一步的关键是 wtc 的 stdout 只输出 shell 代码,所有 TUI 交互、提示信息全部走 stderr。

wtc over 关掉当前工作树

工作树用完要清理,git 原生命令是 git worktree remove <path>,但 Windows 上经常删不掉——codegraph.db.vscode/indexnode_modules/.pnpm 里的文件被 IDE 或后台服务锁着。

wtc over 是一条"结束"命令。在 worktree 目录里直接敲:

wtc over

四阶段兜底:

  1. git worktree remove --force「3 次重试」

  2. 手动 chmod 0700 清只读位 + os.RemoveAll「5 次重试」

  3. 用 Windows RestartManager API 找出占着文件的进程,Kill 之后再 RemoveAll

  4. 仍失败则重命名为 <path>.stale + git worktree prune,让 git 忘掉这个 worktree

第 3 步是关键。rstrtmgr.dll 是 Windows 内置的重启管理器 API,安装程序用它来找"是谁占着我的 DLL"。用同一个 API 找到 codegraph.db 的持有者「codegraph 后台服务、IDE 索引进程」并 kill,绝大多数占用问题就没了。

还有一个隐蔽的坑——wtc 进程本身的 cwd 就在要删的目录里。Windows 会因此拒绝删除目录本身。所以 wtc over 一进入就先 os.Chdir(parent),把自己挪出去,再执行删除。

效果:从 3 分钟压到 15 秒

现在的流程是:

cd my-project
wtc
# 选 main 作为基线
# 选 codegraph 环境
# 直接回车用默认名 my-project-a1b2c3
# 选 spawn 模式
# ...  一个新 Windows Terminal 弹出来,cwd 已经在 F:\Cache\WorkTree\my-project-a1b2c3
# ...  onCreate 里的 codegraph init 已经跑完
# ...  onSpawned 里的 pnpm dev 已经启动

从敲 wtc 到能开始写代码,10 到 15 秒。删的时候一句 wtc over

一些实现上的小坑

Bubble Tea 的 Model 是值类型。第一版把日志滚动写成 strings.Builder 字段,跑起来直接 panic——Builder 检测到被复制就抛 illegal use of non-zero Builder copied by value。事件循环每次 Update 都会拷贝 Model,Builder 里内部指针跟着被复制。改成一个 string 字段就好了。

yml 的双引号路径转义。第一版默认配置写的是 worktreeRoot: "C:\Users\Administrator\wtc-worktrees"yaml.v3 解析到 \U 认为是 unicode 转义序列直接报错。改成单引号 '...' 或者用正斜杠都能解决。这个坑对 Windows 用户来说容易踩。

鼠标点击命中要按屏幕坐标而不是按行数。第一版把按钮条放在屏幕底部,命中坐标用 content 行数 + 边框偏移 计算,结果 body 内容一变多,hotbar 就漂了,点了不生效。后来直接把按钮条挪到顶部,用固定的 Y 坐标 = 5 判断命中,稳定了。

默认配置模板必须自带注释yaml.Marshal(struct) 生成的 yml 是干净的字段名和值,没有任何说明。用户拿到这份配置,不看源码就不知道 sessionMode 可以填哪几个值。这次全部改成硬编码的字符串模板释放,字段上方一行注释说清用途和取值。

结尾

工具做出来的目标不是"取代 git",是把 git 的原子操作和项目的初始化流程绑在一起。之前每次开工作树都要走一遍的"cd → install → 起 dev server",全部塞进一个 yml 里,一次配好长期用。

代码在 github.com/FxRayHughes/worktreecli,Release 页有 Windows / macOS / Linux 五个平台的预编译二进制。