首页
/ get-shit-done 中的 graphify 知识图谱新鲜度检测:commit_stale 三态信号与参数注入防护设计(3170)

get-shit-done 中的 graphify 知识图谱新鲜度检测:commit_stale 三态信号与参数注入防护设计(3170)

2026-09-06 14:53:44作者:昌雅子Ethen

本文基于 get-shit-done 仓库中 #3170 的 changeset 记录,完整解析 /gsd-graphify status 如何从 graphify v0.7+ 图谱的 built_at_commit 字段构建 commit 级陈旧度信号:包括 graphifyStatus() 的四字段返回契约、三态 commit_stale 的语义边界、built_at_commit 的 4–40 位十六进制校验如何阻断 git argv 参数注入,以及面向多开发者的 graphify hook install 配套方案。读完后你可以直接验证图谱是否构建于当前 HEAD,并理解该信号在 mtime 与 commit 双通道陈旧度体系中的定位与回退规则。

特性定位:两种相互正交的陈旧度信号

get-shit-done 通过 gsd-graphify 命令 对项目 .planning/graphs/ 下的知识图谱(graph.json)进行构建、查询与状态检查。在 #3170 之前,/gsd-graphify status 只有一种基于文件修改时间(mtime)的陈旧度判断:图谱文件超过 24 小时未重写即视为 stale: true。这个信号有一个盲区——CI 上几分钟前刚重建、但构建自旧检出的图谱,mtime 上完全"新鲜",其内容却可能早已落后于当前代码。

#3170 在 mtime 信号之外补上了 commit 级信号:graphify v0.7+ 在构建时会把当时的提交哈希写入 graph.jsonbuilt_at_commit 字段,GSD 侧的 graphifyStatus() 读取它并与当前 git HEAD 对比,从而回答一个 mtime 无法回答的问题:这张图谱是从哪个提交构建的?距离现在落后了多少个提交?

两个信号测的是不同的东西,可能给出矛盾结论(例如 CI 构建的图谱 mtime 显示 FRESH 但 commit_stale: true),因此 commands/gsd/graphify.md 明确要求同时呈现两者,交由 Agent 自行判断。

接口契约:graphifyStatus() 新增的四个字段

按 changeset(.changeset/3170-graphify-commit-staleness.md)与 docs/FEATURES.md 第 130 节 的定义,graphifyStatus() 返回以下新字段(仅对 graphify v0.7+ 图谱有效):

字段 类型 说明
built_at_commit string | null 图谱构建来源的提交 SHA(输出为前 7 位短哈希)
current_commit string | null 当前 git HEAD(前 7 位短哈希)
commits_behind number | null 图谱落后 HEAD 的提交数
commit_stale boolean | null 三态:true=陈旧,false=最新,null=信号不可用

其中 commit_stale三态语义是本特性的关键设计:

  • true:图谱构建提交可达且落后 HEAD 至少 1 个提交;
  • false:图谱恰好构建于 HEAD(commits_behind === 0);
  • null:信号不可用——pre-v0.7 图谱(无 built_at_commit)、非 git 检出、或构建提交在当前仓库中不可达(如被 rebase 丢弃)。

null 不等于 false,它是明确的"我不知道"。changeset 要求所有调用方在遇到 null 时回退到既有的 mtime 信号,因此对现有用户零行为变更

源码实现:graphifyStatus() 的完整调用链

实现位于 get-shit-done/bin/lib/graphify.cjs。整个 commit 陈旧度逻辑集中在 graphifyStatus()(第 385–442 行),可拆成四步:

1. 入口守卫与图谱读取

函数先走配置门(isGraphifyEnabled 检查 .planning/config.jsongraphify.enabled === true),再读取 .planning/graphs/graph.json;图谱不存在时返回 { exists: false, ... },JSON 解析失败时返回 { error: 'Failed to parse graph.json' },均不涉及 commit 信号。

2. 十六进制围栏:先校验,再交给 git

// get-shit-done/bin/lib/graphify.cjs (L344-L349)
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
const rawBuilt = (graph.built_at_commit || '').toString().trim();
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;

built_at_commit 来自外部写入的 graph.json,属于不可信输入。它唯一的用途是拼进 git rev-list --count <from>..<to> 的 argv,因此必须确保它不可能以 -- 开头的形态进入 git 参数列表——一个恶意的 graph.json 若写入 --upload-pack=…,就能向 git 注入任意选项。COMMIT_HASH_RE 强制 4–40 位纯十六进制,任何带连字符、空格或散文的值都被直接视为"字段缺失"(置 null),而非透传。源码注释原文明确了这一意图:"Strict 4-40 hex fence … so a hostile graph.json cannot smuggle a --upload-pack=… option into a git argv."

注意区分两种"字段缺失":

  • 格式非法(如 --upload-pack=evil):built_at_commit 输出为 null,不回声该值;
  • 格式合法但不可达(如 rebase 后失效的幽灵哈希):built_at_commit 仍回声短哈希,commits_behindcommit_stalenull,把判断权留给调用方。

3. 读取 HEAD 与提交计数

function readGitHead(cwd) {
  const r = execGit(['rev-parse', 'HEAD'], { cwd });
  if (r.exitCode !== 0) return null;
  return r.stdout.trim() || null;
}

function countCommitsBetween(cwd, from, to) {
  const r = execGit(['rev-list', '--count', `${from}..${to}`], { cwd });
  if (r.exitCode !== 0) return null;
  const n = parseInt(r.stdout.trim(), 10);
  return Number.isFinite(n) ? n : null;
}

从源码结构看,execGit 以参数数组形式调用(来自 shell-command-projection.cjs),不存在字符串拼接 shell 的路径;加上前置的十六进制围栏,from/to 两个引用在语法上就不可能携带选项。计数失败(任一引用不可达、非 git 仓库、git 不在 PATH)返回 null,对应 commit_stale 保持 null

核心推导只有三行:

if (builtAt && head) {
  commitsBehind = countCommitsBetween(cwd, builtAt, head);
  if (commitsBehind !== null) commitStale = commitsBehind > 0;
}

4. 返回结构

return {
  exists: true,
  last_build: stat.mtime.toISOString(),
  node_count: (graph.nodes || []).length,
  edge_count: (graph.edges || graph.links || []).length,
  hyperedge_count: (graph.hyperedges || []).length,
  stale: age > STALE_MS || Boolean(autoUpdateStale),   // mtime 通道(24h 窗口)
  age_hours: Math.round(age / (60 * 60 * 1000)),
  built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
  current_commit: head ? head.slice(0, 7) : null,
  commits_behind: commitsBehind,
  commit_stale: commitStale,
  last_build_auto_update: lastBuildAutoUpdate || null,
};

两个通道并存于同一返回对象:stale 是 mtime 通道(STALE_MS = 24h,并叠加自动重建失败/进行中的状态),commit_stale 是 commit 通道,两者互不替代。

命令行用法与渲染规则

在启用 graphify 的项目中执行:

node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify status

或直接在 Claude Code 中运行 /gsd-graphify statuscommands/gsd/graphify.md 的 Step 2b 定义了 Source commit: 行的渲染规则:

条件 渲染
commit_stale === false Source commit: <built_at_commit> (current)
commit_stale === true Source commit: <built_at_commit> (<commits_behind> commits behind HEAD)
commit_stale === null(提交不可达/无 git) Source commit: <built_at_commit> (freshness unknown)
built_at_commit === null(pre-v0.7 图谱) 整行省略,不渲染 "Source commit: unknown"

docs/CONFIGURATION.md 的 "Commit-based staleness" 小节进一步强调:回答架构相关问题时应同时呈现两个信号,因为 CI 刚重建的旧检出图谱会出现 "mtime FRESH 但 commit_stale: true" 的组合。

测试证据:边界场景全覆盖

回归测试位于 tests/graphify-visualization.test.cjsstaleness describe 块,文件头标注 "Regression for #3170"),覆盖了 changeset 承诺的全部语义边界:

  • 构建于 HEADcommits_behind=0, commit_stale=false,短哈希正确截断为 7 位;
  • 落后 5 个提交:先记录构建哈希、连续空提交 5 次,断言 commits_behind=5, commit_stale=true,且 current_commit 反映 HEAD 而非构建提交;
  • pre-v0.7 图谱(无 built_at_commit):四个新字段全部为 null,断言注释明确 "null means 'we do not know', not 'fresh'";
  • rebase 后失效的构建哈希(幽灵哈希 000…001):built_at_commit 回声短哈希,commits_behind=null, commit_stale=null
  • 参数注入围栏built_at_commit: '--upload-pack=evil' 被拒绝且不回声built_at_commit=null),恶意值永远到不了 git;
  • 非 git 目录built_at_commit 仍回声(图谱字段本身可信),current_commit/commits_behind/commit_stale 全为 null

测试使用 helpers.cjscreateTempGitProject / commitEmpty 在临时 git 仓库中构造真实提交历史,因此计数逻辑是打真实 git 验证的,而非 mock。

配套文档:graphify hook install 与多开发者场景

changeset 的另一半内容落在 docs/CONFIGURATION.md 的 "Multi-developer setup" 小节:多人同仓并行重建图谱时,graph.json 的并发写入会产生合并冲突。解决方案是在启用 graphify 后、每个 clone 里执行一次:

graphify hook install

该命令安装一个 git merge driver 对并发的 graph.json 写入做并集合并(knowledge graph 中出现无冲突标记的并集结果),并注册 post-commit 重建 hook;它写入 .gitattributes、在 .git/config 注册 graphify merge-driver。单人项目可以跳过,执行也无害。此机制随上游 graphify v0.7.0 引入,与 built_at_commit 新鲜度信号同源——v0.7 既是 commit 信号的起点,也是并行重建治理的起点。

相关配置项(同文档 Graphify Settings 表):

设置 类型 默认 说明
graphify.enabled boolean false 启用项目知识图谱,true/gsd-graphify.planning/graphs/ 构建并查询图谱
graphify.build_timeout number (秒) 300 /gsd-graphify build 允许的最大耗时
graphify.auto_update boolean false 启用后由 hooks/gsd-graphify-update.sh 在默认分支提交后以分离后台进程自动重建图谱,并写 .planning/graphs/.last-build-status.jsonstatus 中的 last_build_auto_update 字段即来源于此文件

小结

#3170 用一个外部字段(graphify v0.7+ 写入的 built_at_commit)补全了 GSD 图谱新鲜度检测的最后一个盲区:图谱内容与提交历史的对应关系。实现上有三处值得借鉴的克制设计——

  1. 三态而非布尔commit_stale: null 把"不可知"与"已知最新"严格区分,调用方可无损回退到 mtime 通道,升级零行为变更;
  2. 不可信输入先围栏后使用:4–40 位十六进制正则挡在 git 调用之前,使参数注入在类型层面不可能发生,且有专门的回归测试锁定;
  3. 双信号并存而非替换:mtime 与 commit 通道各测一件事、可同时呈现并给出矛盾时的判断指引(见 CHANGELOG.mddocs/RELEASE-v1.41.0.md 中的对应发布记录)。

在启用 graphify 的项目中,一次 /gsd-graphify status 即可同时看到 mtime 与 commit 两个维度的新鲜度;多人仓库再配合一次 graphify hook install,即可让并行重建不再触发 graph.json 冲突。

登录后查看全文
热门项目推荐
相关项目推荐