☰
context-mode:用一条shell命令存档/恢复Git、Tmux与Vim开发上下文
2026/10/4 8:49:48 网站建设 项目流程

上周我干了件特别愚蠢的事:从 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.md
ctx 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:00+08:00", "last_saved_at": "2024-11-08T15:45:00+08: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 里写一行"我在做什么",不存 state
0.5 天以内ctx save,但不用刻意整理 notes
0.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 全部读一遍,确认当时的思路仍然成立,再动手写代码。这个动作让我的"读档成功率"提高了很多,强烈建议一试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询