From 2887ea8acd072275b364ee42e97542c26e8a6639 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 13:09:12 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20AGC=20=E9=97=B9=E9=92=9F?= =?UTF-8?q?=EF=BC=9A=E7=BA=A0=E6=AD=A3"=E6=9C=AA=20fetch=20=E7=9A=84?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E4=B9=9F=E5=9C=A8=E5=85=B6=E5=88=97"?= =?UTF-8?q?=E8=BF=99=E4=B8=80=E5=81=87=E5=89=8D=E6=8F=90=20+=20=E8=87=AA?= =?UTF-8?q?=E6=8A=93=E7=BC=BA=E5=A4=B1=E5=AF=B9=E8=B1=A1=EF=BC=88=E8=89=AF?= =?UTF-8?q?=E6=80=A7=20tag=20=E4=B8=8D=E5=86=8D=E8=AF=AF=E7=BA=A2=EF=BC=89?= =?UTF-8?q?+=20=E6=8A=A5=E9=94=99=E8=A1=A5=E4=BF=AE=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-15 驳倒了我这条前提,**他是对的,我照他的办法重测复现了**。 ## 一★ 我写过"未 fetch 的对象也在其列(这一点我实测过)" —— **不成立** `git log ` **必须先有这个对象**才能走可达历史。本地没有 ⇒ `fatal: bad object`(退出码 128)。 实测(`/tmp` 一次性仓库,clone **之后**才把新提交推到新 ref): ``` $ git cat-file -e 953c6138 → 没有 $ git log --oneline 953c6138 -- agc.json fatal: bad object 953c6138a7320d73773d9b1253042d6680a97364 ``` ★ 我那次"实测过"大概是测到了**对象恰好在本地**的情形 —— 那一轮我推的 tag 指向的提交 **同时也在 main 上**,clone 时就跟着下来了。**又是"读数器没先被证明是好的", 而这次我把一次假读数写成了"实测过"。** 这比单纯写错更坏:它让那句错话带着证据的外衣。 ## 二★ 于是代码实现的规则与那段理由**相反**:它**确实**给日常 tag 设了卡 未 fetch 的 ref 落进 `unresolved` ⇒ 红。实测(旧行为,把 self-fetch 去掉后): 远端加一条**完全良性**的 tag-only ref ⇒ ``` not ok 6 … 这几条远端 ref 的 tip 拿不到、也抓不回来 ``` 方向我仍认("查不了 ≠ 干净"),但**理由改了**: 真正的不变量是"**这个 clone 必须拿到远端每一条 ref 的对象,否则本条红**"。 不改这句,读的人会以为良性 tag 是"无事发生",第一次撞红时当误报消掉 —— **那正是这条判据最可能被消掉的路径。** ## 三★ `git fetch` 修不好它,只有 `--tags` 行 pi 实测,我也复现: ``` $ git fetch origin → 分支的对象有了;**tag-only 的还是没有** $ git fetch --tags origin → 这才有 ``` (tag 跟随只跟随"指向本地已有对象的 tag"。) **所以我让这条判据自己把缺的对象拿回来**,而且是**精确抓**: ``` git fetch --no-tags origin +refs/tags/:refs/agentmail-probe/ ``` **不碰用户的 ref、不拉全仓、不动工作树**;判完 **`update-ref -d` 删掉探针 ref**(不留垃圾)。 拿不到才报红,并且**报错自带修法**(本仓规矩),且修法里写明**只 `git fetch` 不够**。 抓过对象时必须**说出来**(`(本条本次临时抓了 N 条…)`): 一条判据在对仓库做写操作,读者有权知道"这个绿是在什么前提下拿到的"—— 不说就等于让判据偷偷改仓库。 顺带改掉两处错话: 1. 旧报错写"annotated tag 指向 tag 对象,**或该对象本地没有且远端也不可达**"—— 后半句是错的:sha 是从 `ls-remote` 拿的,**远端当然可达它**;真因是**本地没有这个对象**。 2. 旧报错**没给修法** —— 而"报错自带修法"是本仓的规矩(AGC 那条正文自己就这么写)。 **不给修法的红会被当噪音**,而这条判据最不需要的就是被当噪音。 ## 四、验证(`/tmp` 一次性仓,共享仓只读) | 情形 | 旧 | 新 | |---|---|---| | 良性 tag-only ref(不含该路径) | **红(误红)** | **ok 6**,并打印"临时抓了 1 条" | | **真泄露**在 tag-only ref 上(本地无该对象) | 红,但理由错("抓不回来") | **红,点名 `[refs/tags/leak-orphan]`** | | annotated tag / 旁支 / 干净 main | 红 / 红 / 绿 | 同(未退化) | 探针 ref 每次判完 **0 条残留**;用户的 ref 未被改(当时只有 `refs/heads/localfull` 与 `refs/remotes/origin/main`)。 ## 五、★★ 我又把共享仓改坏了 —— 这次是 `.git/config` 里的 `origin` URL 做上面那些对照时我在 `/tmp/w*` 里跑 `git remote set-url origin /tmp/q-origin`, **以为那是 clone** —— 但 `/tmp/w*` 是我用 `git clone /home/program/agentmail` 建的, 而那棵树里 **`.git` 是指向共享仓的 worktree 指针**(`/tmp/pi-verify-head`、`/tmp/wt-p2` 同)。 `git remote set-url` 写的是 `$GIT_COMMON_DIR/config` ⇒ **改了共享仓的配置**。 后果:`git ls-remote origin` 变 `'/tmp/q-origin' does not appear to be a git repository`, AGC 判据红。 **恢复方式(对着远端真值,不是对着记忆)**: ``` $ git ls-remote https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git refs/heads/main 6702cc2f5e7a582726485a1cba717632202c74d0 ← 与本地 origin/main 一致 $ git remote set-url origin https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git $ 复核:ls-remote == refs/remotes/origin/main ✓ ``` 改动面:只动了 `.git/config` 的一个 key,其余节(`core.hooksPath`、`user`、`branch.main`) 未被触碰;两个 `/tmp` worktree 现在都解析到正确的 origin。 ★ 教训(**与上次弄坏 `refs/remotes/origin/main` 是同一族、这是第二次**): **在共享仓里"以为自己在副本里"是最危险的一类操作。** 上次我损坏的是 ref,这次损坏的是 config —— 两次都是**写操作打在共享 git 目录上**。 可执行的自保:**在 /tmp 造隔离副本前,先断言它有自己的 `.git` 目录**: ``` [ -d /tmp/xxx/.git ] # 不是文件(worktree 的 .git 是**文件**,指向共享 gitdir) git -C /tmp/xxx rev-parse --git-common-dir # 必须等于 /tmp/xxx/.git ``` `git clone <本地路径>` 会因为 hardlink/worktree 语义把 `.git` 指回共享仓 —— **所以"克隆一份来试"这个动作本身就可能已经在碰共享状态**。 下次做这类对照,先建一个**真 bare 源**再 clone,或直接断言上面两条。 --- .../electron/test/criteria-hygiene.test.mjs | 77 ++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index 4f40497..50ba4f4 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -410,15 +410,43 @@ test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是 * 可达性那一侧我立了"问不到 ⇒ 红",**覆盖面**这一侧却把 * "我没枚举到的 ref"**静默当成干净** —— **"看不到 ⇒ 绿"**。 * - * 修法(pi 给的,**不需要 fetch 任何对象**):`ls-remote` 问**全部 ref**, - * 对**每一条**的 tip 都查该路径是否在其可达历史里。查明"远端有哪些 ref"是 - * `ls-remote` 的免费信息,没有理由只问一条。 + * 修法(pi 给的):`ls-remote` 问**全部 ref**,对**每一条**的 tip 都查该路径是否在其 + * 可达历史里。查明"远端有哪些 ref"是 `ls-remote` 的免费信息,没有理由只问一条。 * * ⚠️ 我**没有**把"远端 ref 集合恰好等于 HEAD + refs/heads/main"写成不变量 —— * 那会在加第一个 tag / 第一条正常旁支时误红(**为了抓泄露而给日常操作设卡**, * 与 `pre-push` 删 ref 那次同族:**一道闸消费了不属于它管辖的东西**)。 * 真正要判的性质是"**有没有哪条 ref 的可达历史里有那个 blob**", * 它对**任意** ref 集合都成立。 + * + * ★★ 但我当时写的**理由**是错的,pi 2026-09-15 驳倒了它,我照他的办法重测也复现了: + * 我写过"`git log -- <路径>` 查的是从该 sha 可达的全部历史,**所以未 fetch 的 + * 对象也在其列**(这一点我实测过)"。**不成立**: + * `git log ` **必须先有这个对象**才能走可达历史;本地没有 ⇒ + * `fatal: bad object `(退出码 128)。 + * 实测(`/tmp` 一次性仓库,clone 之后才把新提交推到新 ref): + * $ git cat-file -e → 没有 + * $ git log --oneline -- agc.json + * fatal: bad object 953c6138… + * 我那次"实测过"大概是测到了**对象恰好在本地**的情形(那一轮我推的 tag 指向的提交 + * 同时也在 main 上,clone 时就跟着下来了)—— + * **又是"读数器没先被证明是好的",而且这次我把一次假读数写成了"实测过"。** + * + * ⇒ 于是**代码实现的规则和那段理由相反**:未 fetch 的 ref 会落进 `unresolved` ⇒ 红。 + * 也就是说**同事在远端新建一条完全良性的 tag,只要这个 clone 没 fetch 到,这条判据就红**。 + * 我不反对这个方向("查不了 ≠ 干净",方向安全),但**理由必须改成这个说法**: + * 真正的不变量是"**这个 clone 必须拿到远端每一条 ref 的对象,否则本条红**"。 + * 否则读那段理由的人会以为良性 tag 是"无事发生",第一次撞红时会当成误报去消掉它 —— + * 那正是这条判据最可能被消掉的路径。 + * + * ★★ 而且**照最自然的做法 fetch 也修不好它**(pi 实测,我也复现): + * $ git fetch origin → 分支的对象有了;**tag-only 的还是没有** + * $ git fetch --tags origin → 这才有 + * (tag 跟随只跟随"指向本地已有对象的 tag",所以不在任何分支上的 tag 普通 fetch 拉不下来。) + * + * ⇒ 所以这条判据**自己把缺的对象拿回来**(做法见下):精确抓**那一条** ref 到 + * `refs/agentmail-probe/*` 命名空间 —— **不碰用户的 ref、不拉全仓、不动工作树**, + * 拉完再判。拿不到才报红,并且**报错自带修法**(本仓规矩)。 */ const lsr = git(['ls-remote', 'origin']); assert.equal(lsr.status, 0, @@ -442,24 +470,45 @@ test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是 + `\n 原始输出:${JSON.stringify(lsr.stdout.slice(0, 200))}`); /* - * 对**每一条**远端 ref 的 tip 查该路径。`git log -- <路径>` 查的是 - * **从该 sha 可达的全部历史**,所以未 fetch 的对象也在其列(这一点我实测过才这么写)。 - * 解析不了的(annotated tag 指向 tag 对象等)**报 unknown 而不是绿**。 + * 对**每一条**远端 ref 的 tip 查该路径(`git log -- <路径>` = 从该 sha 可达的历史)。 + * **本地没有那个对象就抓那一条**(精确,见上),抓完再查。抓不到 ⇒ unknown(红),不当绿。 */ + const probeRef = ref => `refs/agentmail-probe/${ref.replace(/^refs\//, '').replace(/\//g, '-')}`; const published = []; const unresolved = []; + const fetched = []; for (const { ref, sha } of remoteRefs) { - const lg = git(['log', '--oneline', sha, '--', AGC_PATH]); - if (lg.status !== 0) { unresolved.push(`${ref}(${sha.slice(0, 8)})`); continue; } + let lg = git(['log', '--oneline', sha, '--', AGC_PATH]); + if (lg.status !== 0) { + // 本地缺这个对象 ⇒ 精确抓这一条(--no-tags 免得顺带拉别的 tag) + const dst = probeRef(ref); + const f = git(['fetch', '--no-tags', 'origin', `+${ref}:${dst}`]); + lg = git(['log', '--oneline', sha, '--', AGC_PATH]); + if (f.status !== 0 || lg.status !== 0) { + unresolved.push(`${ref}(${sha.slice(0, 8)})` + + (f.status !== 0 ? `\n fetch 也失败:${(f.stderr || '').trim().split('\n')[0].slice(0, 90)}` : '')); + continue; + } + fetched.push(`${ref}(抓到 ${dst},本次判定后删除)`); + } if (lg.stdout.trim() !== '') { published.push(`[${ref}] ${sha.slice(0, 8)}\n` + lg.stdout.trim().split('\n').map(l => ' ' + l).join('\n')); } } + // 探针 ref 只为本条判据存在 —— 用完即删,不给仓库留垃圾(也不影响上面已收集的结论) + for (const { ref } of remoteRefs) git(['update-ref', '-d', probeRef(ref)]); + assert.deepEqual(unresolved, [], - '这几条远端 ref 的 tip **查不了可达历史**(`git log -- <路径>` 失败)——\n' + '这几条远端 ref 的 tip **拿不到、也抓不回来** ——\n' + ' ★ 按"不知道"处理,**不当绿**:**查不了 ≠ 干净**。\n' - + ' (常见成因:annotated tag 指向 tag 对象,或该对象本地没有且远端也不可达。)\n' + + ' ★ 真因不是"远端不可达"(sha 是从 `ls-remote` 拿的,**远端当然可达它**),\n' + + ' 而是"**本地没有这个对象**",且 `git log ` 必须先有对象。\n' + + ' ★ 修法(**只 `git fetch` 不够** —— 它默认只抓 `refs/heads/*`,\n' + + ' 不在任何分支上的 tag 抓不下来):\n' + + ' git fetch --tags origin\n' + + ' 本条本来会自己精确抓那一条,这次没成 —— 所以是远端/网络/权限的问题,\n' + + ' 不是"少 fetch 了一下"。确认远端可达再跑。\n' + ` ${unresolved.join('\n ')}`); assert.deepEqual(published, [], @@ -471,4 +520,12 @@ test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是 + ' 2) 历史清理(filter-repo)+ 强推,并与所有 clone 过的人对齐;\n' + ' 3) 查清是哪次 push 绕过了 `.githooks/pre-push`(`--no-verify`?没装的机器?)。\n' + ` 命中:\n ${published.join('\n ')}`); + if (fetched.length > 0) { + /* + * 本次为了判定**临时抓过对象**,必须说出来:这是一条判据在对仓库做**写操作**, + * 读者有权知道"绿是在什么代价/什么前提下拿到的"。不说 = 让判据偷偷改仓库。 + */ + console.log(` (本条本次临时抓了 ${fetched.length} 条远端 ref 的对象:` + + fetched.map(s => s.split('(')[0]).join('、') + ')'); + } });