ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

context-mode:用一条shell命令存档/恢复Git、Tmux与Vim开发上下文

context-mode:用一条shell命令存档/恢复Git、Tmux与Vim开发上下文 上周我干了件特别愚蠢的事从 feature 分支切回 main 修一个紧急 bug修完切回去之后盯着编辑器愣了二十多分钟才想起来自己设计到一半的接口签名是什么、刚才准备处理哪个文件的告警、还有两条完全只存在脑子里的待办。类似的事每隔几天就来一次。问题是这种切换成本明明是可以消除的。我花了两周把它做成了一个叫 context-mode 的脚本化工作流一条命令把当下的工作上下文存档一条命令把现场整体恢复回来——包括 git 状态、打开的编辑器会话、终端的现场状态以及最关键的那一堆只存在于脑子里的计划和决策。这篇文章会讲清楚 context-mode 的理念、第一版实现以及它跟 tmux、Vim、AI 编程工具联动的取舍最后是几个我实打实踩过的坑。适合手里同时压着两三个需求或 bug、每天频繁切换上下文的开发者参考。1. 为什么我会专门做一个 context-mode被任务切换吃掉的产出力1.1 一天里最浪费时间的不是写代码是重新想起来我自己统计过一天里真正痛苦的往往不是某个技术难题而是间歇性插入的打断一个线上告警、一次评审邀请、leader 临时甩过来一个帮我看一眼的小需求。每个打断只有三五分钟但等它过去我要重新花很长的预热时间才能回到原来的思路。可别小看这个预热。加州大学尔湾分校的 Gloria Mark 团队很早就研究过干扰与专注恢复的关系结论是一次打断之后人平均需要二十多分钟才能真正回到原来的认知状态。至于程序员这种记忆力经常被加载到内存的任务冷启动只会更惨——函数调用链在脑子里断了设计取舍的背景忘了连刚才改到一半的文件都要重新摸一遍。二十多分钟已经是非常乐观的估算。我自己最典型的场景是早上一小时修 main 分支上的 bug切到 feature 分支做支付重构下午还要 review 同事的 MR。三个任务轮流来每轮切换都像是在重新拼图。这种状态下一天有效产出可能就三四个小时剩下的时间全消耗在补救性回忆上。1.2 git 分支只保存了代码没保存在脑子里的那层状态很多人会问有 git 分支不就行了吗切过去切回来代码状态全都在。理论上确实如此但实际远远不够。git 保存的是代码这一层的状态保存不了一个人类在开发过程中产生的其他信息我刚才为什么决定用XMLElement而不是普通字符串下一个文件本来要改哪里具体到第几行的哪个函数我已经试过用方案 A结果被某个边界条件卡住了这个试错结论还没被写进任何代码注释。有三条参考链接、两个待办都在脑子里排着队。分支只回答代码在哪这个问题回答不了我当时在想什么、准备做什么、哪些路走不通。浏览器标签页和历史记录能覆盖一部分但它们是按时间组织的信息流不是按任务组织的工作现场。翻历史命令更是低效committed 于肌肉记忆的动作根本没留下目标这层语义。所以结论很直接我需要一个除了 git 之外的上层存档把和代码耦合得很紧的机器状态跟只存在于人脑里的任务状态一起打成一个快照。1.3 我的目标像游戏存档一样管理任务现场给这个说法定个可以验证的标准。我当时给自己列了四条存档和读档都要在几秒内完成最好是单手敲一条命令。不依赖外部服务不引入一个需要部署的复杂套件。一台 Linux 或 macOS 开发机就能跑。存档至少要覆盖五类信息代码状态、编辑现场、终端状态、任务笔记、环境信息。隔了十天再回来哪怕记忆完全冷掉也能靠存档在三五分钟内重建现场。说白了就是给开发任务做一个save point。游戏里存档之后敢随便浪因为随时能读档我希望开发时遇到打断也能有这种底气先存一下再走回来不慌。在这个目标下我决定不做什么花哨的东西什么后台服务、GUI 面板、云同步全砍掉先做一套纯 shell 工具名字就叫 context-mode。2. 拆解上下文到底什么值得被存档2.1 五类信息一张表说清楚存档的第一步是定义上下文的边界。我把它拆成五类可以用一张表说明信息类别具体内容获取来源context-mode 是否自动处理代码现场当前分支、HEAD 提交、暂存/未暂存/未跟踪文件清单git status --porcelain、git rev-parse HEAD自动编辑现场打开的文件、缓冲区列表、光标位置、折叠状态Vim 的:mksession、Neovim 的 session 插件半自动终端现场tmux 窗口/面板布局、最近执行过的命令、后台进程tmux list-windows、fc -ln半自动任务信息目标、当前进展、尝试过的方案、下一步、参考资料手工笔记手工环境信息本地监听端口、临时环境变量、依赖文件变化lsof、envdiff、锁文件变更自动记录指标这个分类是我整个设计的基石。做的时候我有个直觉前四类解决我在干嘛第五类解决为什么跑不通缺一角恢复现场时就会卡壳。2.2 为什么不做全量快照你可能会问既然要存档干脆把整个开发环境打个包得了——VM 快照、叠加层文件系统、整机镜像什么都有了。这种方案当然强但我不选原因有三个。一是太重。全量快照意味着每次存档和恢复都伴随大量 I/O读档跑起来慢吞吞根本不可能做到几秒内回到工作状态。二是噪声太大。屏幕上 37 个标签页、后台挂着的几百个无关进程全都会被一起打包恢复出来依然是一团乱麻你照样要花时间重新找到该看哪个。三是最关键的全量快照是惰性方案它把理解现场这个责任推给了未来的自己而不是帮助你整理现场。记住代码内容本身就放在 git 里我根本不需要重复存档文件内容。我需要存的是指针和意图当前在哪个分支、改了哪些文件、下一步打算干什么。指针是轻量的意图是只有人能提供的。2.3 存档的物理形态一个目录两样东西物理设计我选得尽量保守。每个项目根目录下会有一个.ctx/文件夹放进.gitignore里面固定放两个东西state.json机器自动生成的结构化状态记录分支、HEAD、dirty 文件列表、时间戳这些。notes.md任务笔记纯手写记录人类才能提供的信息。可选第三个session.vim编辑器 session 文件用:mksession导出的编辑现场。为什么放在项目目录里而不是全局目录因为这样存档天然跟随仓库迁移。同事克隆整个项目、换个机器拉代码只要这个目录被保留哪怕不提交工作现场就跟着走。如果放全局目录路径一换就找不到了。让它留在.gitignore里是不想把个人状态污染进共享历史。命令层面为了敲起来短我用了一个ctx的 shell 函数对接所有子命令后面在第三节详述。3. 第一版命令设计ctx 怎么做到一键读档3.1 命令全景第一版我只设计了 7 个子命令尽量少够用就行命令作用ctx start 任务名在当前仓库初始化一个新的任务存档打开 notes.mdctx save把当下的代码状态、终端状态写进 state.json并自动更新 notes 头部时间戳ctx load id恢复某个任务校验并隐藏当前改动、切分支、打开编辑器 session、打印任务简报ctx list列出当前项目所有任务存档附新旧程度和分支信息ctx open id只打开某个任务的 notes.md免去完整读档ctx archive id标记任务结束保留存档备查不再出现在默认列表里ctx drop id删除某个存档有一点很重要ctx start和ctx save是完全解耦的。start 只是建了个新任务骨架save 才是真正把现场固化下来。我习惯的做法是接一个需求先ctx start 支付重构写两行笔记干了半小时觉得可能要被打断了就ctx save。不强制你每次开始都要做完整仪式。3.2 state.json 长什么样一条典型的存档长这样{ version: 1, id: paycheck-20241108a, task: 支付重构拆结算服务, created_at: 2024-11-08T10:30:0008:00, last_saved_at: 2024-11-08T15:45:0008:00, repo: /home/me/work/checkout, branch: feature/pay-refactor, head: a3f09c2, dirty: { staged: [internal/pay/parser.go], unstaged: [internal/pay/service.go, go.mod], untracked: [notes/design.md] }, editor_session: .ctx/session.vim, notes: .ctx/notes.md, recent_commands: [go test ./internal/pay/..., git diff --stat], tags: [checkout, refactor] }这里每一行都是恢复现场时需要的原信息。比如recent_commands很多人不在意但它其实价值很高——我切回来看到自己最后跑的是go test ./internal/pay/...马上就能回到我正在验证哪个改动的思路里。3.3 两个核心函数的实现shell 函数我放在~/.zshrc.d/ctx.zsh里核心逻辑其实很短。最关键的是 save 和 load 两个函数我贴一下结构版本去掉了错误处理细节ctx_save() { local task_id${1:-$(ctx_current_id)} local root$(git rev-parse --show-toplevel 2/dev/null || pwd) local dir$root/.ctx # 代码状态 local branch$(git rev-parse --abbrev-ref HEAD) local head$(git rev-parse --short HEAD) git status --porcelain $dir/status.txt # 编辑器 session如果正在用 vim/nvim if [[ -n $NVIM ]]; then nvim --headless mksession! $dir/session.vim qa 2/dev/null fi # 最近命令 local recent$(fc -ln -8 | tr \n ; ) # 写 JSON cat $dir/state.json EOF { id: $task_id, branch: $branch, head: $head, last_saved_at: $(date %Y-%m-%dT%H:%M:%S%z), recent_commands: $recent } EOF echo saved: $task_id on $branch }load 函数要更小心因为涉及对当前工作区的改动ctx_load() { local id$1 local dir$(git rev-parse --show-toplevel 2/dev/null)/.ctx local state$dir/$id/state.json # 解析目标分支 local target_branch$(jq -r .branch $state) # 当前有未提交改动就先藏着 if [[ -n $(git status --porcelain) ]]; then git stash push -u -m ctx:$id fi git checkout $target_branch 2/dev/null # 打开任务简报 ctx_brief $id }那为什么不直接用git stash pop恢复因为藏起来和弹出来之间隔了太久分支可能已经前进了几百个提交直接 pop 冲突会吵到你怀疑人生。我的规则是默认只 stash 不 pop等确认新任务现场没问题之后再人工决定要不要把那个 stash 恢复出来。这是踩过坑之后定下的保守策略后面第五节细讲。3.4 为什么用纯 shell 实现而不是做个 GUI 或守护进程这是我最初就明确的原则只要 shell 能解决就不要上重型方案。原因很现实第一终端是程序员最底层的工作台面它一定在第二zero dependencies 意味着我拿到任何一台新机器只需要把.zshrc.d/ctx.zsh拉下来就能用不需要安装 node、nvm 或者一堆二进制第三shell 函数天然就是胶水它很容易把 git、tmux、vim、curl 这些专业命令优雅地接在一起而不用我去重新实现它们的接口。GUI 不是不好是延迟太高。我要的是一个产生念头后 300 毫秒内发生的动作GUI 起码要经历开窗、定位、点击三层操作根本做不到。守护进程听起来很酷但任务状态存在文件里已经足够了没必要常驻一个进程时时盯着。4. 给 context-mode 配上手脚tmux、编辑器与 AI 辅助的联动到了这一步context-mode 已经具备了最基本的存档读档能力但它还只是一个状态抽屉真正让它长出办事能力要跟开发机里的几样标配工具联动。4.1 tmux把终端现场连锅端大多数需要靠上下文恢复的任务终端里都不止一个窗口。比如我改后端服务的时候通常左边开着编辑器右边开着一个跑测试的 pane可能还有一个 pane 在追踪日志。切走几分钟再回来那几个 pane 里的输出早就滚动得认不出来了。我让ctx start顺手做一件小事以任务 id 命名创建一个 tmux session。tmux new-session -A -s ctx-$id -c $root-A的意思是如果这个 session 存在就直接附加不存在就创建。之后无论什么时候想回到这个任务的终端现场只要tmux attach -t ctx-$id就能立刻回到当初的终端布局。配合 TPM tmux-resurrect 这类插件面板布局和正在运行的程序也能恢复但我不太依赖它们——因为这种全量恢复有时候会把已经过时的进程也一起带回来反而添乱。我更相信 notes.md 里写的那两三行接下来该看什么日志比任何进程快照都诚实。4.2 编辑器会话的恢复比想象中更需要耐心编辑现场是另一个大头。Vim 的:mksession可以把窗口、缓冲区、折叠都存下来Neovim 也有对应的 session 商店类插件。我在 save 里写了一个念头如果检测到$NVIM环境变量就自动执行nvim --headless mksession!把 session 导出到.ctx/session.vim。但实测之后我得说句实话session 文件是个能用但脆弱的东西。它保存的是插件、布局、路径的耦合态机器一变、插件一升级session 恢复出来经常是错的目录对不上、buffer 崩溃、neo-tree 的折叠状态莫名其妙。所以我给这个功能降了级不再把它当核心链路而是加了一个ctx files命令专门从状态里把最近编辑过的文件列表打印出来ctx files id # internal/pay/parser.go # internal/pay/service.go # internal/pay/checkout_test.go读档时按这个列表重新打开文件几十秒钟的事效果和盲目信任 session一样好但稳定得多。这大概是我在整个项目里最实用主义的一个决定工具如果不可靠就会被使用者悄悄放弃与其让 session 文件偶尔闹脾气不如把最不坏的选择做进流程里。4.3 任务简报读档不等于进入状态用过 game save 的人都知道读档之后你还是需要时间理解现在的地图是什么。所以ctx load之后我会紧跟着一个动作——打印任务简报ctx_brief() { local id$1 local state$dir/$id/state.json echo 任务: $(jq -r .task $state) echo 分支: $(jq -r .branch $state) echo 最后更新: $(jq -r .last_saved_at $state) echo 未提交文件: jq -r .dirty | .staged[], .unstaged[] $state | head -n 10 echo 最近命令: jq -r .recent_commands $state }这个简报不是装饰品。我给自己立了一个规矩读档之后先花三四十秒只读简报、看 notes.md、看git diff --stat把当时的思路在脑子里接上电然后再动手。反过来如果简报和 notes 对不上那多半说明存档时间点之后我又手动改过东西这时候就得先停下来对齐。任何跳过这一步硬写代码的行为都是在为半小时后的迷茫埋雷。4.4 把 notes.md 当 AI 编程助手的持久上下文这两年的开发工作流里多了一个新角色AI 编程助手。于是 context-mode 的价值多了一重——它顺手成了我对给 AI 煮上下文这件事的基础设施。我现在写任务笔记时会用一个几乎固定的模板开头确保机器可读# 支付重构拆结算服务 ## 目标 把结算细分拆成独立服务先把接口层切开。 ## 当前假设 余额校验逻辑可以整体搬走费率表继续留在原服务。 ## 试过不行的方案 - 入参直接用 proto —— 老服务还在用 thrift协议转换成本高。 ## 下一步 1. 完成 parser.go 的字段映射 2. 接入新的费率服务客户端 3. 跑通集成测试 ## 参考 - docs/pay-v2-design.md然后把整个 notes.md 作为 context 文件扔给 AI 助手。这样它在一开始就知道目标、当前假设、已经排除的方案、下一步计划而不是基于我对一整包文件的一两句模糊描述乱猜。实测下来这种做法让小助手给的建议质量高了一个档次因为它不再是看了几百个文件但仍然不知道你在哪一步的瞎子。有一点必须强调丢给第三方 AI 服务之前要过一遍你有没有把敏感信息写进 notes。比如 token、内网 IP、客户数据这类东西绝对不要放进会被分享出去的上下文。我的做法是给 notes 加一个头部约定标记可分享区和本地区导出时截断本地区。这个习惯救过我一次——差点把一个内部探针的地址推送出去。5. 实战两周后改掉的几个规则踩坑记录任何流程都要经得起真实使用。我把 context-mode 在真实项目里连用两周至少改了四次设计其中好几个坑都是会咬人的单独记一节。5.1 存档如果脏恢复就会变成灾难第一次实践就翻车。当时我在 feature 分支上有一堆未提交改动临时被叫去修线上 bug。我直接git checkout main结果被 git 挡了回来有未合并的变更。我当时的实现是自动 stash再切过去修完回来git stash pop。这套逻辑看起来没问题问题出在时间跨度上。我修线上 bug 修了两天期间 feature 分支被同事合了两次主干回来 pop 的时候冲突排山倒海。最后我只能把 stash 留下手动把两个小文件的改动重新 apply 出来。从那以后我的 load 函数改成三条铁律永远不自动 pop stash只stash push -u存起来回头人工处理。stash message 里必须带任务 id方便顺着git stash list找回来。恢复前先打印状态统计让我自己决定要不要 pop。这个改动虽然多了一次人工判断但换来的是永远没有读档毁档的恐怖时刻。5.2 上下文会过期别被看起来还新鲜的存档骗了第二个教训来自时间。一个任务存档放了五天后我在ctx list里看到它觉得状态还挺新就 load 进去。结果分支已经被合进 main 又 rebase 过我当时存的各种 diff 位置全对不上了。后来我给每个任务加了新鲜度指标。ctx list会按最后保存时间排序超过三天的任务名字前面会标一个[old]。更狠的一招是 load 的时候自动对比存档时的 HEAD 和当前分支 HEAD 的提交数差距ahead$(git rev-list --count $(jq -r .head $state)..HEAD 2/dev/null || echo ?) if [[ $ahead ! 0 $ahead ! ? ]]; then echo 警告这个存档落后当前分支 $ahead 个提交 fi如果数字大我不会直接读档做事而是先git log --oneline -5看一下别人动过什么再决定要不要把改动搬到新分支重开一局。宁可在这一分钟里拖一拖也别在错误的代码上下文里浪费半天。5.3 自动化与手工要分工别把笔记也自动化了我一开始贪心想连 notes.md 也自动生成把每次 commit 的 message、每个 diff 的摘要统统自动灌进去。结果就是 notes 迅速变成了一堆噪音像流水账一样堆在那里真正我为什么选 A的原因反而被淹没了。现在铁律是能交给机器的进 state.json需要人类判断的进 notes.md。两者绝不互相污染。state.json 是全自动的机器视图notes.md 是手工维护的人类视图一个描述外部发生了什么一个记录内部在想什么。这两张表互为参照才是完整的上下文。给还没做过的同学一个模板这是我打磨过好几轮之后稳定下来的# 任务名 ## 目标 一句话说清楚要交付什么 ## 当前假设 我认为成立的前提如果错了下面所有计划都要推翻 ## 试过不行的方案 踩过的坑避免返工 ## 下一步 列表每次动手前更新 ## 参考 链接、文件名、会议结论填不动没关系不填也可以。但存一次ctx save之前我会强迫自己至少把下一步更新一下因为这一步直接决定我下次读档能不能三分钟内醒过来。5.4 不是所有切换都值得一趟完整读档最后一条经验听起来有点反直觉context-mode 不是为所有切换设计的。存读档也是有成本的哪怕它只有 5 秒也架不住一天来回十次。我后来给自己定了一个分流表打断时长处理方式5 分钟以内只往 notes.md 里写一行我在做什么不存 state0.5 天以内ctx save但不用刻意整理 notes0.5 天以上ctx save加上完整的模板笔记恢复时走完整简报记住context-mode 的价值在于降低重入成本而不是帮你把每一条心思都石刻保存。如果存档流程让你觉得麻烦你就会拖着不存然后继续被切换吃掉。工具是自己的怎么轻怎么来。6. 目前的使用感受与我想继续做的扩展6.1 两个月下来收获最大的其实是心理上的坦白说这个工具给我省下的绝对时间并没有想象中那么多——毕竟用 shell 函数存档也就几秒。但它带来了一个非常明显的变化我对被打断这件事不再那么焦虑了。以前被叫去处理紧急 bug 的时候脑子里会反复转三个字等会我要干嘛来着现在只需要ctx save然后就可以轻轻松松去处理别的。存档这个动作本身就把焦虑这种东西从脑子里卸下来了。是那种保存即放下的感觉。说实话做完一个存档然后安心离开工位这种体验比任何时间统计都更能说明工具的价值。6.2 三个想继续加的能力基于这两个月的使用我心里已经有三件接下来想做的事。第一把.ctx/做成一个可选的 git 仓库专门用于跨机器同步任务现场。注意绝对不要把 secrets 同步进去连state.json里的环境变量片段都要清理。目标是我换台电脑也能ctx pull出昨天的笔记。第二用 zsh 的preexec钩子把每一条命令自动追加到任务命令日志里不写进 notes.md只作为状态的一部分。这样读档的时候能精确看到我当时敲过的最后十几条命令而不只是笼统的 recent_commands。第三在ctx archive的时候自动生成周报/MR 描述草稿拿git diff --stat的摘要加 notes.md 里的目标和下一步拼出一段可以直接当 MR 描述的半成品。差旅费是省不了的但至少不用每次写完代码再对着空模板发呆。最后分享一个很小但受用的习惯。每次ctx load之后给自己立一条规矩前三十分钟只看不写。把 notes.md、git log、diff 全部读一遍确认当时的思路仍然成立再动手写代码。这个动作让我的读档成功率提高了很多强烈建议一试。
返回列表