返回博客

多 Session 并行写同一文件,git add 会把别人的 WIP 偷进你的 commit

在多个 AI agent session 同时运行的开发环境里,最容易撞上的隐患不是合并冲突,而是静默盗用:你以为只提交了自己的两行,commit 里却夹带了别人还没完成的半截代码。更糟的是,如果对方的源码目录还没被 git add 跟踪,你的 commit 就变成"registry 里 import 了一个不存在于 HEAD 的符号"——在干净环境里直接构建失败,而你看到的状态完全正常。

现象

多个 session 同时向一份共享 registry 文件(如组件清单、barrel 导出)追加各自的行。工作树状态是:

HEAD 内容 + 他人未提交 WIP + 我的新增行

此时执行 git add <file> && git commit,提交的内容是整个工作树文件——他人的 WIP 一起进了你的 commit。而他人的组件源码目录可能还是 ?? 未跟踪状态,这一 SHA 在 CI 上 build 失败。

另一个反直觉的情况:你想用 git checkout HEAD -- <file> 撤销,会直接销毁他人工作树里的未提交内容,同样不可接受。

根因

git add工作树取快照,而工作树此刻混合了三方内容。Git 不知道哪些行属于谁,它只管文件当前是什么就提交什么。

坑:git add 在并行 session 下是不安全的

多 session 同时修改同一文件时,git add <file> 必然将所有人的未提交变更一次性打包进 index。如果另一个 session 的源码目录尚未被追踪,你的 commit 就成了引用幽灵模块的破损快照,该 SHA 在干净环境 npm run build 直接报"找不到模块"。

正解

用 git plumbing 命令绕过工作树,把"HEAD 内容 + 仅我的增量"构造成 blob,直接写进 index:

import subprocess
 
def head(p):
    return subprocess.run(
        ["git", "show", f"HEAD:{p}"],
        capture_output=True, text=True, check=True
    ).stdout
 
def stage(p, content):
    sha = subprocess.run(
        ["git", "hash-object", "-w", "--stdin"],
        input=content, capture_output=True, text=True, check=True
    ).stdout.strip()
    subprocess.run(
        ["git", "update-index", "--cacheinfo", f"100644,{sha},{p}"],
        check=True
    )
 
p = "lib/registry.ts"
c = head(p)                          # 取 HEAD 版本(无他人 WIP,无我的)
anchor = '  { slug: "accordion" },\n'
assert c.count(anchor) == 1          # 唯一性自检:锚点必须在 HEAD 里!
c = c.replace(anchor, anchor + '  { slug: "my-new-comp" },\n', 1)
stage(p, c)

然后提交时不带 pathspec

# 自检:只有我预期的文件
git diff --cached --name-only
 
# 自检:0 行他人内容
git diff --cached | grep '^+' | grep -i "他人特征关键词"
 
# 无 pathspec 提交!pathspec 会从工作树重新 stage,覆盖刚才的 index 操作
git commit -m "feat: add my-new-comp to registry"

提交后工作树原封不动,他人 WIP 仍悬挂为未提交状态。git log 里你的 commit 干干净净,只有自己的两行。

正解:hash-object + update-index,全程零接触工作树

git hash-object -w --stdin 把内容写进 object database 返回 SHA;git update-index --cacheinfo 把这个 SHA 直接注册进 index,整个过程不读、不写工作树文件。无 pathspec 的 git commit 提交的是 index 里的内容,不是工作树。

进阶:index 被他人 staged 占用时

如果并行 session 已经抢先 git add 把它们的文件塞进了共享 index,无 pathspec commit 又会把它们 staged 的内容一起提交。此时要独立临时 index

import os
env = dict(os.environ, GIT_INDEX_FILE="/tmp/my_idx")
 
# 从 HEAD 初始化临时 index
subprocess.run(["git", "read-tree", "HEAD"], env=env, check=True)
 
# 在临时 index 里做所有 hash-object / update-index 操作 ...
 
# 构造 commit 对象,用 update-ref 原子更新引用
old = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
tree = subprocess.run(["git", "write-tree"], env=env, capture_output=True, text=True).stdout.strip()
commit = subprocess.run(["git", "commit-tree", tree, "-p", old, "-m", MSG],
                        capture_output=True, text=True).stdout.strip()
subprocess.run(["git", "update-ref", "refs/heads/master", commit, old])
# old 不匹配时自动失败(他人已 commit),重读 HEAD 重来即可
坑:GIT_INDEX_FILE 未 unset,git status 全面撒谎

用完临时 index 后如果忘记 unset GIT_INDEX_FILE,之后所有 git status / git ls-files 都在操作临时文件,真实 .git/index 一直陈旧。已经 commit 进 HEAD 的文件会显示为 ?? 未跟踪,未提交计数虚高近一倍。你以为 HEAD 不自洽、源码没进去,其实 commit 对象完全正确。

核查 HEAD 真相要用 git ls-tree -r <sha> -- <path>,它读 commit 对象,不读 index。修复:unset GIT_INDEX_FILE; git reset -q

三条提交前必跑的自检

  1. 锚点必须在 HEAD 内容里能唯一匹配,不能用他人新增的行当锚点(它不在 HEAD)。
  2. git diff --cached --name-only 输出 == 你预期的文件集合。
  3. git diff --cached 的所有 + 行里,0 行含他人特征关键词。

一句话外卖

并行 session 修改同一文件时,git add 是危险的——用 hash-object + update-index 把"HEAD + 仅我的增量"直接注入 index,彻底绕开工作树竞争。