多个会话同时改一个仓库,代码互相覆盖、文件写了却不见了怎么排查
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
当你开了三个会话同时改一个仓库,最后发现有人的改动没了,绝大多数人的第一反应是”工具有 bug”或者”模型偷偷回滚了我的代码”——这个归因基本是错的。 我处理过的这类事故里,真正的成因几乎都落在三个位置:两个进程在同一时刻读改写同一个文件(后写的覆盖先写的)、提交时用了 git add -A 之类的宽泛动作却被 .gitignore 或者路径写法悄悄跳过了新文件、以及写文件的动作本身在沙箱或者受限目录里被丢弃了而上层只报了一句”已完成”。这三种的现象很像,处置方式却完全不同,所以排查顺序比排查技巧重要得多。
先说清本篇和站内另外两篇的分工,免得你走错门。Claude Code worktree 并行开发 讲的是”怎么把并行做对”——用 worktree 做物理隔离,是预防层面的架构选择;Claude Code 子任务代理机制 讲的是单个会话内部派发任务的机制与边界。本篇不重复这两件事,它站在事故已经发生之后:你手上有一个混乱的工作区、一段丢失的改动、一个”明明写了却找不到”的文件,需要按顺序把成因定位出来并止损。
一、先建立正确的心智模型:谁在写,写到哪
排查之前你得知道并发写入到底发生在哪一层。多会话改一个仓库时,同时存在四个可能被覆盖的层:
磁盘上的工作区文件。 这是最容易出事的一层。两个会话各自读入 src/config.ts 的全文,各自在内存里改了一小段,然后各自整文件写回。第二个写回的会把第一个的改动完整抹掉,而且不会有任何冲突提示——文件系统没有版本概念,最后一次写入就是真相。
git 索引(暂存区)。 索引是单例的。你在会话 A 里 git add,会话 B 紧接着 git commit 不带路径,B 就会把 A 暂存的东西一起提交进去。这类事故的表现是”我的改动进了别人的提交”,看起来像丢失,其实是错位。
HEAD 与分支引用。 两个会话在同一个工作目录里切分支、reset、amend,会互相把对方的 HEAD 拽走。git commit --amend 在并发场景里尤其危险:它重写了刚才那个提交,另一个会话如果基于旧提交做了后续动作,等于站在一块被抽走的地板上。
编辑器/工具自身的缓存。 有些工具会缓存文件内容做增量编辑。当文件已被另一个进程改过,工具拿着旧快照做替换,要么替换失败,要么把整段旧内容重新写回去。
搞清这四层之后你才能问对问题:丢的是磁盘内容,还是只是没被提交,还是提交了但在另一个分支上。
二、判别表:从现象倒推成因
下面这张表是我实际用的第一道分诊。先对上现象,再按”怎么验证”那一列动手,不要凭感觉直接开始修。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 文件里我的改动整段消失,别人的在 | 工作区整文件覆盖写 | git log -p -- <文件> 看是否从未进过历史;再看文件 mtime 和另一会话的动作时间是否重叠 | 从 git stash list、编辑器本地历史或 git fsck --lost-found 找回;之后把该文件改成单写者独占 |
| 工具说文件已创建,磁盘上没有 | 写入被沙箱/权限丢弃,或写到了另一个工作目录 | ls -la <目录> 加 git status --porcelain 双查;再 pwd 确认当前目录不是你以为的那个 | 换成由主会话内联写入并立刻核实;见第四节的落盘核实 |
| 提交了但文件不在提交里 | git add 路径没覆盖,或被 ignore 规则拦下 | git show --stat HEAD;git check-ignore -v <文件> | 显式路径 git add -- <文件>;确认 ignore 后用 -f 或修规则 |
| 改动在,但在另一个分支上 | 并发切分支导致 HEAD 漂移 | git reflog 加 git branch --contains <提交> | git cherry-pick 搬回来,别用 reset 硬拉 |
文件里出现 <<<<<<< ======= >>>>>>> | 真实的 git 合并冲突未解决 | git diff --check;git ls-files -u 列未合并条目 | 手工解决后 git add;先别让任何自动化再动这个文件 |
| 两个会话都说改完了,测试结果反复横跳 | 同一构建产物或锁文件被并发重写 | 看 pnpm-lock.yaml / package-lock.json 的 diff 是否在两次运行间反复变 | 锁文件与依赖安装收归单一会话串行执行 |
| 提交历史里出现别人的半成品 | 索引串用(git commit 不带路径) | git show HEAD --name-only 对照你自己改过的文件清单 | 后续所有提交都带显式路径;已发生的用 git reset --soft 拆分重提 |
这张表的用法是”只走一行”。定位到一行就先执行那一行的验证和处置,不要同时试三种修法——并发事故最怕的就是在混乱状态上再叠一层混乱。
三、排查顺序:三步定性,别跳步
第一步,冻结现场。 让所有其他会话停下来,不要让它们”再试一次”。然后把当前工作区完整备份一份:
git status --porcelain > /tmp/wip-status.txt
git stash list > /tmp/wip-stash.txt
cp -a . /tmp/repo-snapshot-$(date +%s)
拷一份整目录看着笨,但它是唯一能让你后面放心执行 checkout 和 reset 的东西。丢失事故里二次伤害的比例很高,多数是因为没有可回退的快照就开始动手。
第二步,判定丢的是”内容”还是”引用”。 这一步决定了后面所有动作。用一个你确定改过的字符串去搜整个对象库:
git log --all --oneline -S '你改过的那段独特字符串'
git fsck --lost-found
如果 -S 能搜到提交,说明内容进过 git,只是引用漂了,属于”引用问题”,可以稳稳地找回来。这里有个必须知道的边界:git log --all 只走得到”可达”的提交,被 reset 抛下、被 stash drop 丢掉、或者从未被任何引用指向的那些对象,它是搜不出来的。所以这一步要两条腿走:先用 -S 搜可达历史,再用下面这条把 reflog 里残留的引用一起搜一遍,最后才用 git fsck --lost-found 去捞真正悬空的对象(捞出来的东西会落在 .git/lost-found/ 下,用 git show <对象哈希> 逐个看内容)。
git log -g --all --oneline -S '你改过的那段独特字符串'
三处都空,才能判定内容从未被 git 看见过,属于”磁盘覆盖”或”从未落盘”,找回概率取决于编辑器有没有本地历史。顺序别颠倒:fsck 的输出往往是几十行哈希,在没有排除可达历史之前就去逐个 show,纯属浪费时间。这时候不要再纠结 git 命令,直接去看编辑器的本地历史或者时间机器类备份。
第三步,区分”没落盘”和”落盘后被覆盖”。 这两者的处置纪律不同。看文件的修改时间,如果目标文件的 mtime 停留在你这次动作之前,说明写入根本没发生;如果 mtime 是新的但内容不是你的,说明发生了覆盖。
ls -la --time-style=full-iso src/
写入根本没发生的场合,我遇到最多的两个原因:一是执行写入的那个进程运行在受限或临时目录里,写完随进程一起消失;二是当前工作目录不是你以为的那个仓库根,文件确实写成功了,只是躺在另一个路径下。后者用 find 按文件名全盘搜一次通常立刻见分晓。
四、三条纪律:独占共享文件、显式路径提交、落盘核实
排查是善后,纪律才是止损。这三条是我在多会话场景里唯一坚持下来的,别的都可以商量。
纪律一:共享文件单写者独占。 先把仓库里的文件分成两类:只被一个任务碰的”私有文件”,和多个任务都要改的”共享文件”。共享文件典型是路由表、导出索引、注册表、配置入口、依赖锁文件、i18n 词条表。这类文件不允许并发写,必须由一个协调者串行修改。做法很朴素:并发的会话只负责生成各自的私有文件,并把”我需要在注册表里加这一行”作为一条待办报上来,最后由协调者一次性把所有行加进去。
这条纪律的代价是协调者变成瓶颈,收益是你彻底消灭了最难查的那类事故。多数团队一开始不接受,直到丢过一次两小时的工作量。
纪律二:提交只用显式路径,禁止 amend。
git add -- src/content/article/foo.md src/lib/bar.ts
git commit -m "feat: add foo"
不要 git add -A,不要 git add .,不要 git commit -a。宽泛动作在并发场景里会把别人的半成品裹进来,而显式路径的失败是响亮的——路径写错时 git 会直接报错,比静静提交一堆不该提交的东西好得多。这里有个容易忽略的陷阱:有些 shell 或工具链在参数展开时会把不存在的路径静默处理掉,所以提交后必须核对文件清单,见纪律三。
--amend 在单人单会话下很好用,在并发下应当直接禁用。它改写了别人可能已经引用的提交,事故后极难重建时间线。
纪律三:落盘核实,只认磁盘和 git 的输出。
任何”已完成""已创建""已写入”的口头结论都不能作为验收依据,包括你自己的记忆。每一次写入后跑一次固定的三连:
git status --porcelain
git show --stat HEAD
git reflog -5
第一条确认磁盘上有东西且 git 看见了它;第二条确认提交里的文件清单和你的预期一致;第三条确认 HEAD 的移动路径没有意外的 reset 或 amend。这三条命令加起来跑不到一秒,却能拦住绝大多数”以为提交了”的事故。
如果你在编排多个自动化任务,把这三条固化成任务收尾的必跑步骤,比在任务描述里写”请确保文件已保存”有用得多。相关的编排边界可以参考 Agent 日常运维 和 Agent 失败重试机制——重试逻辑设计不当,本身就会变成覆盖事故的放大器:一次失败的写入被重试三遍,其中一遍成功了但内容基于最早的旧快照,结果就是新改动被旧内容盖回去。
五、什么情况下别再折腾:止损点与换路判断
这一节比前面几节更重要,因为多数时间是浪费在”再抢救一下”上。
止损点一:内容不在 git 对象库,也不在 stash,也不在编辑器本地历史。 这三处都空,就意味着那份内容在世界上没有任何副本。这时候继续跑各种恢复命令的期望收益接近零。我的判断是给自己十五分钟上限,到点就承认丢了,然后立刻做两件事:把还记得的改动要点列成清单,趁记忆新鲜重写一遍;同时把导致覆盖的那个共享文件标记为独占,避免半小时后再来一次。重写通常比恢复快,而且第二遍的代码往往更好。
止损点二:工作区已经乱到你说不清每个文件属于谁。 症状是 git status 里几十个改动文件,你无法逐个说出它是哪个任务产生的。这时候不要试图在原地梳理。把当前状态整体存成一个临时提交或 stash 保底,然后从一个你确认干净的提交重新拉一个工作区,把确认需要的改动一个一个搬过去。听起来倒退,实际上比在污染状态里排查快。
止损点三:同一类冲突在同一批任务里出现第三次。 这说明问题不在操作,在架构——你把不该并发的东西并发了。这时候正确动作是停下来改并行策略,而不是继续修单次事故。换路的选项有三个:一是给每个任务开独立工作区做物理隔离(这就是 worktree 那篇的主题);二是把并发度降到一,用速度换确定性;三是重新切分任务边界,让每个任务的文件集合互不相交。第三个投入最大但收益最持久。
一个反向的止损信号: 如果你发现自己在写”下次一定注意”,那就是没有真正止损。纪律必须体现为可执行的步骤或者检查命令,写在任务收尾流程里,而不是体现为决心。
六、避坑清单:为什么会踩,怎么避
坑一:用 git add -A 收尾。 为什么会踩——它省事,单人开发时从来没出过问题,肌肉记忆很强。怎么避——把它从你的收尾习惯里彻底删掉,改成显式路径。如果嫌路径长,先 git status --porcelain 打印出来,再从里面挑,多花五秒。
坑二:并发任务共写一个导出索引或注册表。 为什么会踩——这类文件通常只需要加一行,看起来风险极低,于是没人觉得需要协调。怎么避——把”只需要加一行”当成危险信号:一行的改动最容易被整文件覆盖抹掉且事后毫无痕迹。统一收归协调者串行追加。
坑三:相信”已完成”的口头回执。 为什么会踩——回执读起来非常确定,而验证要多敲命令。怎么避——把验证做成零成本:写一个两行的脚本或者 shell 别名,一条命令跑完前面那个三连。人只会做省力的事,所以把正确的事变省力。
坑四:在并发中用 --amend 或 reset --hard。 为什么会踩——单人场景下这是清理历史的标准手法。怎么避——并发期间把这两个命令视为禁用;确实要清理,等所有会话都停下来、状态确认干净之后再做,并且做之前先打一个临时分支当锚点。
坑五:忽略 .gitignore 与新建文件的组合。 为什么会踩——ignore 规则往往是很久以前写的,某个宽泛的通配可能正好覆盖了你新建的目录。这里的行为差异要分清:显式点名一个被忽略的文件时,git 会提示这些路径被 ignore 规则拦下并拒绝加入(提示你用 -f),是响亮的失败;而 git add -A、git add . 这类批量动作会把被忽略的路径直接跳过,一声不响。所以恰恰是”省事”的那种写法才会让你悄无声息地漏掉文件。怎么避——新建目录下的第一个文件提交后,一定 git show --stat HEAD 核一眼;有疑问用 git check-ignore -v <文件> 直接问 git 是哪条规则、第几行拦的。
坑六:把重试当成幂等。 为什么会踩——重试机制通常是为网络错误设计的,遇到 ETIMEDOUT、ECONNRESET 这类瞬时故障重试确实对;但写文件不是幂等操作,基于旧快照的重试会覆盖新内容。怎么避——写入类动作重试前必须重新读一次目标文件当前内容,或者干脆只允许失败上报不允许自动重试。
坑七:多个会话共用一份凭据与配额,出错时误判成并发 bug。 为什么会踩——并发时突然有会话报 401、403 或者提示额度已用尽,看起来像并发导致的混乱。怎么避——先把这类问题和覆盖事故分开:状态码类问题属于账号与配额侧,各家产品的规则不同且会调整,以官方最新说明为准,可以对照 多账号额度池调度 和 API 成本监控 去查,不要往覆盖事故里归因。另外提醒一句现实约束:部分海外工具与模型的官方服务对中国大陆有区域限制、不支持直连,市面上存在第三方中转但我不背书也不推荐具体渠道,用这类通道时凭据泄露和可用性都是你自己承担的风险。
坑八:并发跑依赖安装。 为什么会踩——两个会话各自装依赖看起来互不相干。怎么避——包管理器的缓存目录和锁文件是全局共享的,并发安装既会写坏锁文件也可能污染缓存。依赖安装永远串行,且只由一个会话负责。
收束:一份可以贴在任务收尾处的自检清单
多会话改一个仓库的核心矛盾不是技术难题,是”谁在什么时候写了哪个文件”这条信息没人掌握。所有纪律本质上都是在把这条信息重新变得可知。
排查时记住顺序:冻结现场,判定丢的是内容还是引用,再区分没落盘还是被覆盖。三步走完你才知道该用哪一行处置动作。想深化单会话内的上下文与任务管理,可以接着看 Claude Code 上下文管理。
每次收尾过一遍这七条:
- 这次改的文件里,有没有属于共享文件的?有就确认它是由协调者串行改的。
git add用的是显式路径吗?- 本次提交期间有人用过
--amend或reset --hard吗? git status --porcelain输出是否符合预期,有没有意外残留?git show --stat HEAD里的文件清单和你的预期逐个对上了吗?git reflog -5里有没有你无法解释的 HEAD 移动?- 新建的目录和文件真的进了提交,而不是被 ignore 规则拦下了?
七条全过再宣布完成。这是判断,不是规范文本——但它是我在多次事故之后收敛出来的最小集合,删任何一条都会漏掉一类事故。