18 KiB
| name | description | version | triggers | date | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| git-repo-hygiene | Use when git 仓库治理:推送积压、.git 膨胀、忽略规则、**密钥/凭证泄露与清历史(filter-repo)**、remote 明文口令、自动快照。 | 1.8.0 |
|
2026-09-11(新增 §7.1 凭据外置:remote 里的明文口令 → ~/.git-credentials(600);§7.2 自动快照 + 扫描闸门 cron) |
git 仓库治理(本机多仓库)
何时用
- 用户问「XX 推 gitea 了么 / 最近的改动都提交了么」
.git体积异常大;git add -A之后仓库膨胀- 自动化脚本(快照/守护)只 commit 不 push,悄悄积压几十个 commit
1. 推送积压审计(2026-09-10:一次问出 4 个仓库全都积压)
逐个仓库看,别只看最显眼那个:
for d in ~/.hermes ~/mc ~/projects/gaokao-site ~/.obsidian/plugins/zhiyi-memory; do
echo "── $d"
git -C "$d" log --oneline @{u}.. 2>/dev/null | wc -l # 未推条数
git -C "$d" remote -v | head -1
git -C "$d" status -sb | head -1 # 有无 upstream
done
- 没设 upstream →
git push -u origin <branch> - remote 指向不可达地址(换网/换 VPN 后失效)→ 改 remote,不要新建仓库
- 仓库内嵌的独立子仓库(
.git在子目录)要单独查、单独推 - 根治而不是补推:找到自动化脚本补 push。本次根因 =
config-protector.sh的 auto-snapshot 只commit不push(积了 18 个);修法:commit 后跟timeout 60 git push origin main >/dev/null 2>&1 || log "push 跳过"—— 网络不通静默跳过,绝不阻塞快照主流程
2. .git 膨胀回收(实测 2.6G → 27M)
git add -A 在大型 vault 上会一次性 stage 几万个文件(本次 46456 个 → .git 涨到 3.2G)。
git reset 只取消索引,对象仍在;而且 reflog 会保护它们,普通 gc 收不走:
git reflog expire --expire=now --all
git gc --prune=now
du -sh .git
- 只删不可达对象,安全;但必须先 expire reflog,否则 prune 不生效
- 清理后四项验证:
git count-objects -vH(loose 应为 0)、git ls-files | wc -l(追踪数不变)、git log --oneline -1(HEAD 完好)、git status -sb(与远端同步)
3. .gitignore 范围(Obsidian vault / 大工作目录)
- 先量规模再决定收什么:
du -sh */+git ls-files | wc -l - 10G 级工作目录不进 git → 迁
~/projects/或走 SMB 备份 - 内层独立仓库要显式排除,否则父仓库会把它当普通目录收进来
- 文本笔记(
.md)适合 git;PDF/Excel/DB/媒体一并排除 git add -A之前先--dry-run数一遍文件数,数量级不对立刻停手
4. 判断远端是否也膨胀
Gitea API 核仓库体积:
curl -s -m 15 'http://<host>:3000/api/v1/repos/<owner>/<repo>' \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('size'))"
本次远端仅 26MB ⇒ 膨胀只发生在本地(push 时未带上臃肿历史),所以本地 gc 即可,不必 force push 重写历史。
5. 密钥从 git 历史清除(2026-09-10 实战:mc vault 4 处真密钥已推 Gitea)
工作区脱敏只挡住未来的 commit;已入库的必须重写历史。完整配方 + 验证代码:references/secret-purge-from-history.md。
三段式(一轮清不干净——实测跑了 3 轮才彻底):
- 第 0 步:先备份。
git bundle create ~/.hermes/backups/<repo>-pre-filterrepo-<date>.bundle --all(27M 仓库秒级完成)。filter-repo 不可逆,没有 bundle 不开工。 - 文字密钥:
git filter-repo --force --replace-text expr.txt,每行literal:<密钥原文>==><REDACTED-secret>。- 必须用
literal:逐字替换。别图省事用正则:sk-[A-Za-z0-9]{20,}会把文档里的https://x.com/task-driven-…这类 URL 片段一起改掉。
- 必须用
- 二进制里的密钥:
--replace-text不处理.pyc/编译产物(本次一个.pyc把 key 当字符串常量嵌着——扫得出来、replace-text 换不掉)。补一轮git filter-repo --force --invert-paths --path-glob '*.pyc' --path-glob '*__pycache__*',把整类构建垃圾连根删(本来就不该入库)。 - 老快照 commit 里的:历史里那种「全库快照」commit 常把整棵旧目录提交过,里面躺着工作区早删掉的密钥(本次第 3 轮又挖出 3 个:Agnes / DeepSeek / Gitee AI)。做完 2、3 再全量扫一遍才暴露。
验证(必做,别信「跑完了」):扫所有 blob,不是只看 HEAD——
git log --all --oneline -S '<密钥原文>' # 应无输出
git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)' \
| awk '$2=="blob"{print $1}' | while read b; do
git cat-file blob "$b" | grep -qF '<密钥原文>' && echo "LEAK $b"; done
再用形状正则全库扫一遍(sk- / tskey- / github_pat_ / ghp_ / nvapi- / AKIA / xox[baprs]- / LTAI / glpat- / AIza),逐条人工判真假:剩下的应当全是 sk-test-… sk-your-… xoxb-workspace-… AKIAIOSFODNN7EXAMPLE 这类文档占位符。
形状正则必须带负向断言:写 (?<![A-Za-z0-9_-])sk-[A-Za-z0-9]{24,}。
不加前缀断言,https://yoheinakajima.com/task-driven-autonomous-agents 里的 sk-driven-…
和正文里 `task-*.md` 这类片段全会被当成密钥(实测 10 条命中里 3 条是这种假阳性)。
命中后逐条判:10-25 位 + 带 test/your/xxxx/example 语义的一律是占位符,本机已确认可忽略的:
sk-test-* sk-your-* sk-xxxxxxxx sk-ant-api03-… sk-deepseek-… sk-or-v1-test/your…
xoxb-workspace-… xoxb-your-… AKIAIOSFODNN7EXAMPLE Bearer eyJhbG…(单测夹具里的假 JWT)。
还要验「远端」,不只验本地(--force 之后最容易在这里翻车——本地干净 ≠ 推上去的是干净那份):
git rev-parse HEAD origin/main # 两者必须相等
git clone --bare --quiet <url> /tmp/verify && cd /tmp/verify
# 在 /tmp/verify 里重跑上面两段 blob 扫描(对象数应与本地一致)
git log --oneline -3 # 应是重写后的新 hash
git cat-file -t <重写前的hash> # 应报 Not a valid object name
远端仓库的旧 pack 对象在 Gitea 服务端 GC 前可能仍被取到 → 想彻底干净要么跑服务端 GC,要么删库重建(反正推的是全量新历史)。
两个必知行为:
git filter-repo跑完会删掉originremote(防误推)→ 强推前先git remote add origin <url>加回来。- 所有 commit hash 都会变 → 只能
git push --force。这是不可逆操作,可能被审批闸拦下:被拦就停手,把命令原文交给用户批准,不要换命令绕过。 - Gitea 侧 force push 只改 ref,旧对象在服务端 GC 前仍可能被取到 → 真正止血是轮换密钥,重写历史只是补刀。清出的每个密钥都要列进轮换清单交用户。
⚠️ 泄漏面不止 git —— 同一批密钥在派生产物里各有一份(2026-09-11 实测)
清完 git ≠ 清干净。同一条 Tailscale key 同时存在于 5 类存储:工作区文件、git 历史/远端、
知识图谱节点名、向量库(LanceDB)内容、同步端旧副本。逐面清单 + 图库/向量库处置 + 生产端到端验证脚本
→ references/credential-leak-blast-radius.md。
▶ 一键复扫脚本:scripts/scan-secret-blast-radius.py(工作区 / git 全对象 / sqlite 图谱 / 向量库四面扫,输出只打前 8 位)。
- 知识图谱:实体名常常就是密钥原文(蒸馏把含 key 的句子当实体)→
SELECT id,name FROM graph_nodes过形状正则; 本次 8 个节点 / 37 条边,还挖出一个审计只扫 git 时漏掉的第二个 key。 - 向量库:事件本体里就是明文(直接按字节扫
memories.lance/data/*.lance)。改动风险高 → 优先靠轮换让它失效, 不急着做写入手术(LanceDB 有损坏前科)。 - 凭证主库对照:清出来的 key 要回头对一下
~/mc/牧尘/claw/key.md——本次图库里那个 key 压根不在主库。 - 收尾一句:清历史/清存储都只是补刀,轮换密钥才是真止血;每个清出的 key 都要进轮换清单交用户。
6. 预防:把脱敏做进写入路径(2026-09-11 织忆实战,清历史只算补刀)
密钥必须死在数据进入派生产物的那一刻,而不是事后从历史里刨。 本次事故链:蒸馏把含密钥的对话当事实入库 → 知识图谱按该文本建实体 → ObsidianSync 用实体标题当文件名/正文 → 镜像被 git 跟踪并推送。 只改文件名、只清历史,都不治根:下一轮蒸馏还会再长出来。
唯一真源:一个 redact 模块(图案正则 + 替换 + ContainsSecret),每个落盘/入库边界各调一次。
同一套规则绝不复制三份 —— 复制出去的那份迟早不同步。
必查的边界(漏任何一个 = 等于没做):
- 入库质量门槛(事实判定前脱敏 + 过滤函数的返回值也要脱敏,调用方拿的是返回值)
- 实体/标题生成(命中就丢实体,连它的关系边一起丢,别留孤立节点)
- 文件名 sanitize + 正文(正文才是泄漏主战场,文件名只是显眼)
- 绕过质量门槛的直写路径(最易漏,本次泄漏的那条记忆正是从这里进去的)
实现细节(Go RE2 无 lookbehind、假阳性护栏、幂等、测试清单)→ references/write-side-redaction.md
配套检测护栏(凭证治理做完必须留检测,否则下轮又长回来):
把 vault 扫描从「只扫文件名」升级成「扫文件内容」+ 每日 cron + 凭证主库目录白名单
—— 见 notes-vault-curation §五。
7. 「这个目录要不要进 git」决策框架(2026-09-11 用户问「笔记文件有必要推到 gitea?」)
先分清两件事——它们不互为备份:
| 用途 | 工具 | 能力边界 |
|---|---|---|
| 多端一致 / 灾难恢复 | WebDAV·SMB 同步 | 覆盖式(keep_newer)→ 改错删错无法回滚,没有历史 |
| 版本历史 / 追溯 | git + 远端 | 能答「这句话三个月前怎么写、谁改的、哪次改坏的」 |
回答模板:进 git 的理由是历史,不是「怕丢」——同一台机器上再放一个 git 远端不算备份(一起挂)。 实证价值:本次定位密钥泄漏范围(哪几个 commit、哪个快照里还躺着)全靠 git 历史。
四条硬边界(缺一条就会变成事故):
.git/绝不能进同步通道(WebDAV/网盘)——多端互相同步.git会直接把仓库搞坏。 上线后用远端目录列表确认:PROPFIND结果里不应出现.git/(只有.gitignore这种文件是正常的)。- 凭证「可以」进 git —— 前提是远端为自建私库(2026-09-11 用户明确纠正,原写法「凭证永不进 git」已作废)
牧尘原话:「敏感资料可以进 gitea,但是属于私库,就我一个人知道,项目也是,除非上传 GitHub,我会单独要求你」。
因此红线不是「进了 git」,而是「去了第三方 / 公开平台」:
- 允许:自建 Gitea 私库(先验
curl -s .../api/v1/repos/<owner>/<repo>→private:true) - 红线:GitHub / 任何第三方公开平台 —— 只有用户单独、明确要求才可上传
- 推论:「发现密钥被 git 跟踪」不等于事故。先问「谁可能读到」再定性,别一上来就按事故跑全流程
- 机制层照旧,但靶心改成防「意外扩散」:凭证库进
.gitignore是防误发布(不是防入库)- 写入侧脱敏(§6)+ 每日内容级密钥扫描(
notes-vault-curation§五之补)。 真正要抓的是自动生成物(镜像/导出/日报/截图)把凭证带到用户没打算的地方——本次事故正是这条。
- 写入侧脱敏(§6)+ 每日内容级密钥扫描(
- 允许:自建 Gitea 私库(先验
- 二进制不进 git(pdf/xlsx/docx/pptx/zip/db)——体积爆炸且无 diff 价值。
- 范围要克制:本次 mc 仓库只跟踪 218 个文件(一个子区 +
.gitignore),161M 工作资料与凭证库全在 ignore 内。 审计口径:git ls-files | awk -F/ '{print $1}' | sort | uniq -c+grep -vE '^\s*#|^\s*$' .gitignore。
什么时候主动叫停:用户开始往被跟踪区放敏感原文(合同/证件/客户名单)时——git 有历史,删了还能翻出来。 处置不是停用 git,而是把跟踪范围收缩到纯知识子目录。
7.1 凭据外置:remote URL 里的明文口令(2026-09-11 实测修完 4 个仓库)
症状:remote.origin.url 长成 http://<user>:<pass>@<host>:3000/... → 口令明文躺在每个 .git/config 里,
随目录打包/同步/备份一起扩散(本次 4 个仓库全中:mc / memoryweave / ~/.hermes / projects/*)。
审计一行(批量找出所有中招仓库):
for d in ~/mc ~/src/memoryweave ~/.hermes ~/projects/*; do [ -d "$d/.git" ] || continue
git -C "$d" remote get-url origin 2>/dev/null | grep -q '@' && echo "⚠️ $d"; done
修法(4 步,顺序不能反):
# 1) 写凭据文件(每行 http://user:pass@host:port)→ 权限 600
chmod 600 ~/.git-credentials
# 2) 启用 helper
git config --global credential.helper store
# 3) 逐个仓库去掉 URL 里的口令
for d in <repo...>; do
clean=$(git -C "$d" remote get-url origin | sed -E 's|://[^@]*@|://|')
git -C "$d" remote set-url origin "$clean"; done
# 4) 验证鉴权仍通(读 + 写都要验)
git -C <repo> ls-remote --heads origin >/dev/null && echo 读OK
git -C <repo> push origin main # 已同步时回 Everything up-to-date 且 exit 0 —— 仍走完整认证
两个坑(都实测过):
- 已有
~/.git-credentials≠ 配好了:本次该文件早已存在(7 月建的),但权限是 664(同机可读) 且credential.helper根本没设置 → 等于白放。改完必须git config --global --get credential.helper复查。 - 机器上没有
git-credential-libsecret时不要硬上 keyring:cron/无人值守下 keyring 可能是锁的, 会让后台推送静默失败。store+ 600 是无头环境最稳的选择。 (诚实口径:仍是明文,只是单点化 + 权限收紧,别对用户说成"加密了"。)
7.2 自动快照:把「扫描闸门」做进定时推送(2026-09-11 建成)
补 §1 的漏洞:只在 agent 干活时才 push 的话,用户自己在编辑器里写一周笔记,远端就掉队、版本历史出现空档。
做法 = 一个脚本(~/.hermes/scripts/<vault>-autopush.sh)+ 每日 no_agent cron,两道闸门:
# 闸门①:密钥扫描未通过 → 不 commit 不 push,输出告警(把泄漏挡在 push 之前)
if ! python3 ~/.hermes/scripts/mc-structure-check.py --secrets-only > /tmp/scan.txt 2>&1; then
echo "⚠️ 已跳过:密钥扫描未通过"; sed 's/^/ /' /tmp/scan.txt | head -15; exit 0; fi
# 闸门②:无变化 → 静默退出(watchdog 模式,不打扰)
[ -z "$(git status --porcelain)" ] && exit 0
git add -A
git -c user.name=<bot> -c user.email=<bot> commit -q -m "chore(vault): 每日自动快照 $(date +%F)"
timeout 90 git push -q origin main 2>/dev/null && echo "✅ 已推 $(git log --oneline -1)" || echo "⚠️ 已提交但推送失败"
- 加
--dry-run分支(只打印待提交项不落盘),交付前自测三态:无变化静默 / 造临时文件能识别 / 真跑无变化时输出必须为空 - cron 用
no_agent→ 干净时零消息;但注意no_agent模式下prompt被完全忽略, 要让告警自带说明就必须写进脚本自己 print(见credential-leak-responsePhase 5)
8. 铁律
- 只在确认规模后
add -A;大仓库一律显式路径git add scripts/ skills/ MEMORY.md - commit message 前缀:
skill(...)/fix(...)/chore(...)/refactor(...)/docs(...) - push 前确认 remote 可达(VPN/局域网);失败要如实报出来,别标成功
- 重写历史(filter-repo / force push)是最后手段,先问用户;开工前
git bundle备份,收工后把清出的密钥列成轮换清单 - 提交前
git diff --stat逐文件核对行数(2026-09-11 实测):在遗留仓库随手跑gofmt -w/black/prettier会把 4 行真改动放大成 352 行 diff。 判据:格式化工具--check/-l列出一大堆本来就没格式化的文件 ⇒ 保持格式化不是本仓库约定, 别单独格式化你碰到的那一个。发现混入噪音:git checkout -- <file>回滚,只重放最小改动。 - 既有测试腐化:先归因再动手,并披露(2026-09-11 实测):AC 要求「X 包测试全绿」
但该包根本编译不过时,先看
git diff --name-only里有没有那个文件——没有就是既有腐化 (典型:函数改名、签名加参后测试没跟)。最小化修(只动测试文件)+ commit message 里显式披露。 副产品结论:一个包长期编译不过 = 它的单测长期没跑过,这个包以前的「测试通过」别当已验证。 - 去破坏性组合命令(2026-09-11 实测):把
git add -A、git filter-repo、git push --force和一大堆&&拼成一条长命令,容易整个被审批闸拦下 → 前面的副作用也没发生。 拆成可独立验证的小命令,每步自己看结果。 另有一种解析层拦截(与审批无关):超长内联命令(大量$(...)/heredoc/巨长单行)会被BLOCKED (hardline): command parser limit or malformed executable payload挡下, 命令根本没执行。恢复路径:命令已被存到~/.hermes/cache/blocked-scripts/blocked-<ts>-<hash>.sh, 按提示bash <该文件>即可;或改写成/tmp/x.sh+bash /tmp/x.sh。别内联原样重试。