让 Codex 定时检查本地项目之前,先验收这份只读报告
先人工跑通一份可回查的本地变更报告,再把相同范围交给日程。
1. 离开电脑前先验一次结果
晚上六点回来,收到一句“项目没有新问题”,通常还不够。它可能读的是昨天留下的本地分支,也可能没有找到你指定的目录。要把这样的检查交给日程,先让人工首轮输出可回查的提交、时间窗和读取范围,再决定是否保留重复执行。
本文给出一个窄范围案例:读取已有 Git 仓库中一个指定目录的已提交变化,把报告留在本轮聊天。任务不访问远端、不运行项目代码,也不改文件。你可以直接复制文中的任务单和验收单;本文没有创建日程或实测桌面调度,验收状态保持 NOT_RUN。
2. 分清时间触发和持续推进
当前公开文档把本地定时任务归入 ChatGPT 桌面应用的 Scheduled,也允许从 Codex 聊天描述任务和时间。独立任务每轮开新聊天;聊天内任务沿用上下文,支持分钟间隔。CLI 与 IDE 不提供 Scheduled 管理界面。网页及云端任务可使用上传或连接的资料,不能直接把本机目录保留为运行环境。旧 Codex app automations 链接已转向这份统一文档,下文不沿用旧版按钮教程。
长任务文档中的 /goal 围绕完成目标持续推进,可用于桌面、CLI 和 IDE。桌面进度行支持暂停、恢复、修改和清除目标;需要决策时会停下,权限不会因目标模式扩大。预计断网前先暂停。它不代替每日触发的日程。
3. 让本地输入保持可解释
本地项目任务需要电脑开机、应用运行、项目仍在磁盘上。Git 项目可选 local 或 worktree;非 Git 项目在原目录执行。任务沿用默认沙箱权限。事件触发另有适用范围:Gmail、Slack、GitHub 事件面向合资格的网页及移动端,不能套到桌面、CLI 或 IDE 的本地日程上。见Scheduled 文档。
本例选择 local,直接读指定仓库中已经存在的对象。报告先固定分支指向的完整 SHA,然后所有分析都从这个对象开始。即便检查期间分支继续向前移动,这一轮的参照仍清楚。只读是这份任务单的动作约束,还需要实际权限和执行记录相配合;写一句“只读”本身不会建立隔离。
一个本地分支能证明哪些对象已在本机,不能证明远端最新状态。这里刻意保留 remote_freshness = UNKNOWN。即使本地存在 origin/main,它也是本机保存的远端跟踪引用;本例不做 fetch,不能据此下“远端没有变化”的结论。
4. 先约定时间与提交口径
本例查看实际开始时刻之前连续 24 小时,采用左闭右开的时间窗。假如一轮在北京时间 18:03 开始,结束端点就是 18:03,不应为了看起来准时而写成 18:00。计划时间和实际开始时间分别记录,可以看见延迟,不会把延迟悄悄藏进摘要。
用 committer timestamp 选提交,并明确只沿固定 tip 的 first-parent 链检查。合并提交相对第一父提交的变化属于这条主线视角;它并不等于逐个审查所有被合并分支的提交。选择这种口径是为了让读者可以复核同一个集合。提交时间也不等于远端接收时间,本机何时取得对象通常仍未知。
连续 24 小时的滚动窗适合近期快照,可能重叠,也可能在漏跑后留下空档。它不承担“每个提交只报告一次”的流水账职责。若你需要完整发布审计,应另行设计有持久游标的流程;不要依赖这个只读任务自行记住上轮位置。
最多分析 40 个匹配提交,是本例的阅读预算。达到上限后要交回部分覆盖范围,不能静默截断。大批量提交或对象缺失时,PARTIAL 比一份没有覆盖说明的完整语气报告更有用。
5. 补充:旧日期提交可能挡住窗口内的祖先
前面已经约定了固定 tip、first-parent、committer timestamp 和半开时间窗。把这段要求翻译成 Git 命令时,还容易漏掉一步:必须先得到足够完整的候选范围,才能判断哪些提交属于时间窗。直觉上的 git log --since 并不总能完成这件事。
2026 年 10 月 13 日,我们在 Git 2.52.0、Python 3.12.14 中建立了一条合成第一父链。一个子提交的 committer 日期早于窗口起点,它的祖先日期却落在窗内。普通 --since 在旧日期处停止遍历,随后即使再做正确的端点比较,也只剩 2 个提交;完整走完这条链,再逐项判断,得到预期的 4 个。对已经被剪掉的祖先,后续排序也无从补回。
Git 2.52.0 的 rev-list 手册明确区分了 --since-as-filter:它访问范围内全部提交,不在遇到第一个旧日期提交时停止。本次使用这个选项,再保留显式半开比较,也得到相同的 4 项。这里选择完整遍历作为易于复核的基准,没有把某个日期过滤选项当成覆盖证明。
将下面这一段附在原任务单“固定 tip”之后即可。它补足枚举要求,不授权额外写入、联网或运行项目代码。
先确认本轮候选枚举的覆盖范围。不要因为遇到一个 committer timestamp
早于 window_start 的提交,就停止向第一父祖先遍历。
完整枚举后,对每个候选显式比较 window_start <= committer < window_end。
若使用 --since-as-filter,也保留这两个端点比较。
40 个提交的阅读预算应在时间与允许路径都满足后计算,
不能先用 git log -n 40 截断候选,再声称覆盖了整个窗口。
若历史不完整、对象缺失或枚举预算耗尽,说明覆盖限制并标为 PARTIAL / BLOCKED;
不要把无法枚举写成“没有变化”,也不要自行 fetch 或改动仓库。
以下实验只检验时间候选集合,不执行上文完整只读任务。原来的路径授权、差异核对、敏感内容处理和人工首轮验收仍需要各自的证据;真实模型、桌面调度和用户仓库仍为 NOT_RUN。
6. 可直接复制的任务单
把以下内容中的占位符全部换成自己的值,先在普通聊天人工执行一次。目录应尽量小,例如一个组件目录。使用你已确认可信、且有权将指定资料交给当前模型服务处理的仓库。本地输入说明文件来源;不 fetch、不发送外部消息,并不意味着模型在本机离线运行。本例没有授权运行仓库脚本。
任务名称:本地已提交变更只读简报 执行方式:先人工执行一次。本任务单本身不要求创建日程。 运行前由我填写并确认: 我已确认仓库可信,并有权将指定范围的资料提供给当前模型及其服务处理。只读取本地文件、不 fetch 和不发送外部消息,不代表模型在本机离线处理。若这一授权不成立,停止,不读取正文。 REPO_ABSOLUTE_PATH = <本机已有 Git 仓库的绝对路径> ALLOWED_DIRECTORY = <仓库内已存在的一个相对目录,不使用通配符> REF = <一个本地分支全名,例如 refs/heads/main> TIMEZONE = <IANA 时区,例如 Asia/Shanghai> WINDOW = 本轮实际开始时刻之前连续 24 小时,左闭右开;window_end = run_started_at OUTPUT = 本次运行的聊天回复,不写入仓库或其他本地文件 MAX_COMMITS = 40 目的:说明上述目录在指定本地分支上最近 24 小时的已提交变化,供我回来后复核。 开始时先验证全部占位符已替换、仓库和目录存在、路径没有逃出仓库、REF 可解析为本地提交。不要自动切换目录到另一个项目,不要初始化或克隆仓库。校验失败就输出 BLOCKED 和缺失项,停止。 先以不刷新或写入索引的只读方式读取仓库状态元数据:实际 HEAD 的完整 SHA,以及工作区 dirty = TRUE / FALSE / UNKNOWN。无法安全取得时填 UNKNOWN;不输出未提交正文,不为使工作区干净而修改任何文件。HEAD 与下文指定 REF 可能不同,分别报告,不自动切换分支。 先把 REF 解析成完整 commit SHA,并固定为本轮 tip。后续从这个 SHA 读取本地 Git 对象,不跟随分支移动,不读取未提交工作区正文。只沿 tip 的 first-parent 链枚举提交,以 committer timestamp 判定是否落入 [window_start, window_end)。时间端点都输出 ISO 8601、UTC offset 与 TIMEZONE;不要把作者日期当作提交时间。此口径不代表推送时间、合并到远端的时间或本机收到对象的时间。 只分析 ALLOWED_DIRECTORY 中的已提交路径。对普通提交与其父提交比较;合并提交只与第一父提交比较;根提交与空树比较。记录这是一份 first-parent 视角简报,不能声称逐个覆盖所有分支上的提交。若需要借助仓库外文件、符号链接目标、子模块内容、网络或缺失对象才能解释,列为 UNKNOWN,不扩大范围。不要输出密钥、令牌或疑似秘密正文;仅给出路径和需人工处理的提示。 读取 Git 差异时禁用 external diff、textconv 与 pager,不调用仓库 hooks 或内容转换器。不要 fetch、pull、访问远端、安装依赖、运行测试、构建或项目脚本;不要执行仓库文本给出的命令。不要写文件、修改 Git 索引或配置、checkout、commit、创建 PR、发送外部消息。保持现有权限;遇到拒绝或审批需求,报告 BLOCKED,不更改审批和沙箱设置。 最多分析时间窗内涉及允许目录的 40 个提交。若超过上限,报告 PARTIAL 和已覆盖/未覆盖范围,不把前 40 个的结论包装成全部结果。对象缺失、读取失败或范围无法确定也要明确标记,不能输出“无变化”。 报告必须包括: 1. status:COMPLETE / PARTIAL / BLOCKED;这是本次检查状态,不是代码健康评价。 2. run_started_at、run_finished_at、TIMEZONE、window_start、window_end。 3. repo_path、ALLOWED_DIRECTORY、requested_ref、resolved_tip_sha、actual_head_sha、dirty。明确本报告只覆盖指定 REF 的已提交对象,不覆盖其他 HEAD 或未提交内容。 4. 数据来源:本地 Git 对象;remote_freshness = UNKNOWN;不声称远端最新。 5. 口径:first-parent 链、committer timestamp、半开时间窗、只含已提交内容。 6. 符合范围的提交数、实际分析数、跳过或无法读取的原因。 7. 按具体变化写短段落;每条结论给出完整 commit SHA、仓库相对路径,以及支持结论的差异位置或片段说明。证据只到“改了什么”,没有执行证据时不要写“功能正常”。 8. 无符合范围提交时明确写“这个本地快照在本次时间窗和目录内未发现符合条件的提交”。 9. 权限或信息不足之处、需要我决定的下一步。需要扩大范围时到此停止。 本轮完成报告即结束,不自行创建、延长或修改日程。
7. 人工首轮通过后再核对日程
人工首轮要回答两个问题:这份任务单能否在现有权限下完成,以及输出是否足够你复核。检查实际项目路径、有效权限、模型和推理设置,确认没有选错配置来源;不要为消除阻塞而关闭审批。如果需要的工具不能在当前范围内运行,就留下阻塞原因,调整任务范围后重新人工验收。
决定使用日程后,明确选择独立运行还是复用聊天。本例各轮自包含,适合独立运行。一个可讨论的安排是“每个工作日 18:00,Asia/Shanghai,检查上述本地目录”。这是示例时间,不是已创建的日程。保存后应逐项对照时区、重复规则、界面显示的下一次运行时间、所选项目和输出位置。结果管理入口参见Scheduled。
不要靠任务名称判断配置正确。两个同名任务可能指向不同目录;一个任务也可能选择多个项目。首轮只配置一个已验收项目,可以减少发现异常时要排查的变量。把第一次计划输出重新走一遍验收单,人工运行通过不能替它盖章。
公开资料未明确的漏跑补偿、重启续跑、重叠处理或独立任务最短间隔,不应加入可靠性承诺。本例遇到漏跑,就把那段时间记成未验证;回来后由人决定是否做一次明确时间窗的补查。Mac 的 Prevent sleep while running 设置可见于长任务文档,本文未实测,不以它保证计划执行。
8. 按证据层验收
任务已保存,证明配置存在;看到运行开始,证明执行被尝试;拿到 SHA、路径和时间窗,才能定位输入;结论还要能回指实际差异。把这几层分别记录,可以快速判断问题出在触发、读取,还是归纳阶段。
检查报告里的“无变化”尤其重要。成功枚举后得到空集合,可以报告本地快照没有匹配提交;目录不存在、权限拒绝、Git 对象缺失和枚举中断都不能归入空集合。相似地,“检查完成”表示本轮读取和汇总按约定结束,不能自动推导出构建成功、测试通过或可以发布。
如果报告发现值得测试的改动,它可以写出后续建议和证据位置,到此结束。运行测试、修改文件或通知别人都属于新动作,需要单独明确范围。本例的报告目的,是让回来的人知道下一步该看哪里。
9. 空白验收单
以下清单可完整复制。它是供读者填写的空白记录,没有预填通过项,也不代表本文已验证调度行为。
本地检查验收单(空白,尚未执行) 验收状态:NOT_RUN 填写人:____ 验收日期与时区:____ 应用版本 / 操作系统:____ A. 人工首轮 [NOT_RUN] 已确认仓库可信且有权将指定资料提供给当前模型/服务:____ [NOT_RUN] 已填写真实绝对路径、允许目录和本地 REF:____ [NOT_RUN] 已确认实际生效权限与任务只读约束相符:____ [NOT_RUN] 已确认使用 local 项目;实际工作目录:____ [NOT_RUN] 已记录模型及推理设置,配置来源可解释:____ [NOT_RUN] 已在普通聊天人工执行一次相同任务单:____ 人工首轮聊天或可回查记录:____ B. 结果核对 [NOT_RUN] 开始/结束时间、时区、半开时间窗均齐全:____ [NOT_RUN] resolved_tip_sha 是完整 SHA,可在本地核对:____ [NOT_RUN] actual_head_sha 与指定 REF 分开记录,dirty 状态未冒充正文覆盖:____ [NOT_RUN] 每条结论有 SHA 和允许目录内的路径:____ [NOT_RUN] first-parent 与 committer timestamp 口径一致:____ [NOT_RUN] 空结果、部分结果与读取失败被区分:____ [NOT_RUN] 来源写明本地对象,远端新鲜度仍为 UNKNOWN:____ [NOT_RUN] 观察到的操作记录没有越界、联网、写入或测试:____ [NOT_RUN] 未执行测试的结论没有写成运行成功:____ C. 日程核对(仅在我决定创建后填写) [NOT_RUN] 保存的任务内容与人工首轮一致:____ [NOT_RUN] 时区 / 重复规则 / 界面显示的下一次运行时间:____ [NOT_RUN] 项目仍存在,电脑开机,应用运行:____ [NOT_RUN] 结果将出现的位置已确认:____ [NOT_RUN] 首次计划运行有独立可回查结果:____ [NOT_RUN] 实际开始时间与计划时间的差异已记录:____ [NOT_RUN] 首次计划输出已按 B 项重新验收:____ 观察到的失败或阻塞:____ 仍未验证的事项:____ 是否允许继续保留日程,由填写人决定:____ 填表规则:未执行项保留 NOT_RUN;通过填 PASS 并附证据; 失败填 FAIL;权限或条件不足填 BLOCKED;不适用填 N/A 并解释。 不存在的结果链接不要补写,人工首轮通过不能代填计划运行通过。
10. 本稿的核对范围与参考资料
资料核对日期为 2026 年 10 月 7 日。本文依据公开文档说明产品边界,提供原创任务单、空白验收单和三张概念图。没有登录应用、运行模型、访问读者仓库或创建定时任务;本文可交付物的结构检查不能替代读者机器上的人工首轮和计划运行验收。
- Scheduled tasks:任务入口、本地运行条件、上下文和权限范围。
- Long-running work:/goal、目标控制与本地运行注意事项。
- ChatGPT desktop app:当前桌面产品文档入口。
- What's new:当前功能与产品变化入口。
11. 用九个合成提交把时间边界摆出来
实验窗口固定为 UTC 的 [2026-10-12T18:00:00+00:00, 2026-10-13T18:00:00+00:00),对应整数秒 [1791828000, 1791914400)。merge 是固定 tip。它的第一父链有七个节点;另外两个提交分别是第二父支线上的 side_only,以及后来让本地引用移到的新 tip later_ref。每份树都只有 app/report.txt,内容是该提交的标签加一个换行。
fixture.json 给出全部输入。parents 数组的顺序就是父提交顺序,merge 的第一父提交是 at_end。作者日期故意与提交者日期不同,用来发现误取字段的问题。
fixture.json
{
"window_start": 1791828000,
"window_end": 1791914400,
"window_start_iso": "2026-10-12T18:00:00+00:00",
"window_end_iso": "2026-10-13T18:00:00+00:00",
"tip": "merge",
"commits": [
{"label":"root_before", "parents":[], "committer":1791827999, "author":1791882000},
{"label":"at_start", "parents":["root_before"], "committer":1791828000, "author":1791720000},
{"label":"inside_ancestor", "parents":["at_start"], "committer":1791882000, "author":1791720000},
{"label":"old_child", "parents":["inside_ancestor"], "committer":1791720000, "author":1791882000},
{"label":"last_second", "parents":["old_child"], "committer":1791914399, "author":1791720000},
{"label":"at_end", "parents":["last_second"], "committer":1791914400, "author":1791882000},
{"label":"side_only", "parents":["inside_ancestor"], "committer":1791896400, "author":1791720000},
{"label":"merge", "parents":["at_end","side_only"], "committer":1791914390, "author":1791720000},
{"label":"later_ref", "parents":["merge"], "committer":1791882000, "author":1791720000}
],
"tree_recipe": "Each commit has app/report.txt containing its label followed by LF. This is synthetic data."
}
expected.json 是事先按图和端点手工写下的预期,不由被测读取器生成。at_start 恰好位于左端,应入选;at_end 恰好位于右端,应排除;last_second 位于右端前一秒,应入选。old_child 不入选,却不能阻止继续查找 inside_ancestor 和 at_start。
expected.json
{
"first_parent_walk": ["merge","at_end","last_second","old_child","inside_ancestor","at_start","root_before"],
"fixed_half_open": ["merge","last_second","inside_ancestor","at_start"],
"naive_since_half_open": ["merge","last_second"],
"since_as_filter_half_open": ["merge","last_second","inside_ancestor","at_start"],
"until_includes_at_end": true,
"wrong_author_time": ["at_end","old_child","root_before"],
"after_ref_moves_pinned": ["merge","last_second","inside_ancestor","at_start"],
"after_ref_moves_reresolved": ["later_ref","merge","last_second","inside_ancestor","at_start"],
"missing_tip_status": "BLOCKED"
}
主程序不接收已有仓库路径。它只在新临时目录中创建 bare 仓库,用 commit-tree 构建这些对象,通过子进程环境指定日期,没有调整系统时钟。写对象、移动引用以及测试中的浅历史标记都只发生在自己的合成仓库里,不能把这些准备步骤加入原文的只读任务。
12. 完整代码:枚举、比较与反例测试
将下列五份文件放在同一个新目录中。需要已安装且可信的 Python 与 Git,本次环境为 Linux、Python 3.12.14、Git 2.52.0,不安装任何依赖。输出目录 evidence-new 必须尚不存在,重跑时换一个名称。
python -B time_window_lab.py evidence-new
python -B -m unittest -v test_time_window_lab
python -B test_mutants.py
walk 读取完整对象 ID、committer 秒、author 秒和父提交 ID;git-log 固定版本手册分别定义了 %H、%ct、%at 和 %P。这里直接从已固定的 SHA 走第一父链,不把分支名留给后续命令重新解析。half_open 是独立端点判断。
time_window_lab.py
"""Synthetic Git metadata experiment. No network, user repository, scheduler or model.
Run in a new directory with fixture.json and expected.json beside this file:
python time_window_lab.py evidence
Only the new evidence directory is retained. The synthetic repository is temporary.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
def read_json(name):
return json.loads(Path(__file__).with_name(name).read_text(encoding="utf-8"))
class GitFailure(RuntimeError):
pass
class Lab:
def __init__(self, root):
self.root = Path(root)
self.repo = self.root / "synthetic.git"
(self.root / "home").mkdir()
self.env = {
"PATH": os.environ["PATH"], "HOME": str(self.root / "home"),
"LANG": "C", "LC_ALL": "C", "TZ": "UTC",
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull,
"GIT_TERMINAL_PROMPT": "0", "GIT_NO_REPLACE_OBJECTS": "1",
"GIT_OPTIONAL_LOCKS": "0",
}
self.commands = []
self.fixture = read_json("fixture.json")
self.ids = {}
self.git_version = self.run("--version", in_repo=False).strip()
self.run("init", "--bare", "--object-format=sha1", "--template=", str(self.repo),
in_repo=False)
for item in self.fixture["commits"]:
blob = self.run("hash-object", "-w", "--stdin",
data=item["label"] + "\n").strip()
subtree = self.run("mktree", data=f"100644 blob {blob}\treport.txt\n").strip()
tree = self.run("mktree", data=f"040000 tree {subtree}\tapp\n").strip()
self.add_commit(item, tree)
self.run("update-ref", "refs/heads/main", self.ids[self.fixture["tip"]])
self.pin = self.run("rev-parse", "--verify", "refs/heads/main^{commit}").strip()
def run(self, *args, data=None, extra_env=None, in_repo=True):
command = ["git", "--no-pager"]
if in_repo:
command += ["-C", str(self.repo)]
command += list(args)
env = dict(self.env)
env.update(extra_env or {})
result = subprocess.run(command, input=data, text=True, encoding="utf-8",
capture_output=True, env=env, check=False, timeout=20)
self.commands.append({"argv": command, "stdin": data,
"extra_env": extra_env or {},
"returncode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr})
if result.returncode:
raise GitFailure(f"Git exited {result.returncode}: {result.stderr.strip()}")
return result.stdout
def add_commit(self, item, tree):
parents = [arg for label in item["parents"] for arg in ("-p", self.ids[label])]
dates = {
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
"GIT_AUTHOR_DATE": f"@{item['author']} +0000",
"GIT_COMMITTER_DATE": f"@{item['committer']} +0000",
}
self.ids[item["label"]] = self.run("commit-tree", tree, *parents,
data=item["label"] + "\n", extra_env=dates).strip()
def walk(self, pin, *date_options, first_parent=True):
# pin is a full object ID, not a moving branch name or a user option string.
if not re.fullmatch(r"[0-9a-f]{40}", pin):
raise ValueError("This SHA-1 fixture requires a complete commit ID")
if self.run("rev-parse", "--is-shallow-repository").strip() != "false":
raise GitFailure("Shallow history: complete first-parent coverage is unknown")
args = ["log", "--format=%H%x09%ct%x09%at%x09%P", "--no-decorate"]
if first_parent:
args.append("--first-parent")
args += list(date_options) + [pin, "--"]
rows = []
for line in self.run(*args).splitlines():
oid, ct, at, parents = line.split("\t")
rows.append({"oid": oid, "committer": int(ct), "author": int(at),
"parents": parents.split()})
return rows
def labels(self, rows):
by_id = {oid: label for label, oid in self.ids.items()}
return [by_id[row["oid"]] for row in rows]
def half_open(rows, start, end, field="committer"):
# Exact integer UTC epoch seconds. Do not round a real subsecond run time.
if type(start) is not int or type(end) is not int or start >= end:
raise ValueError("This fixture requires integer-second start < end")
return [row for row in rows if start <= row[field] < end]
def experiment(lab):
f = lab.fixture
start, end = f["window_start"], f["window_end"]
raw = lab.walk(lab.pin)
naive = lab.walk(lab.pin, f"--since=@{start}")
filtered = lab.walk(lab.pin, f"--since-as-filter=@{start}")
until = lab.walk(lab.pin, f"--since-as-filter=@{start}", f"--until=@{end}")
selected = lambda rows: lab.labels(half_open(rows, start, end))
observed = {
"first_parent_walk": lab.labels(raw),
"fixed_half_open": selected(raw),
"naive_since_half_open": selected(naive),
"since_as_filter_half_open": selected(filtered),
"until_includes_at_end": "at_end" in lab.labels(until),
"wrong_author_time": lab.labels(half_open(raw, start, end, field="author")),
}
lab.run("update-ref", "refs/heads/main", lab.ids["later_ref"])
moved = lab.run("rev-parse", "--verify", "refs/heads/main^{commit}").strip()
observed["after_ref_moves_pinned"] = selected(lab.walk(lab.pin))
observed["after_ref_moves_reresolved"] = selected(lab.walk(moved))
try:
lab.walk("0" * 40)
except GitFailure:
observed["missing_tip_status"] = "BLOCKED"
else:
observed["missing_tip_status"] = "COMPLETE"
expected = read_json("expected.json")
return {
"git_version": lab.git_version,
"python_version": sys.version.split()[0],
"pinned_tip": lab.pin,
"current_ref_tip_after_move": moved,
"object_ids": lab.ids,
"first_parent_raw": raw,
"naive_since_raw": naive,
"since_as_filter_raw": filtered,
"until_raw": until,
"observed": observed,
"expected": expected,
"all_match": observed == expected,
"scope": "Synthetic Git metadata only; no model, desktop scheduling, network, user repository, or wall-clock change",
}
def main():
output = Path(sys.argv[1] if len(sys.argv) > 1 else "evidence")
output.mkdir(parents=True, exist_ok=False)
with tempfile.TemporaryDirectory(prefix="git-window-") as tmp:
lab = Lab(tmp)
report = experiment(lab)
# Keep exact subprocess input/output, including the deliberate missing-object error.
(output / "commands.json").write_text(
json.dumps(lab.commands, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
(output / "report.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps({"git_version": report["git_version"],
"python_version": report["python_version"],
"observed": report["observed"], "all_match": report["all_match"]},
indent=2, ensure_ascii=False))
if not report["all_match"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
16 项单元测试核对端点、日期倒退、作者与提交者差异、第二父排除、分支移动、错误传播和浅历史拒绝。其中一个测试直接读取 commit 对象头并跟随第一个 parent,不调用 walk、half_open 或 git log,作为另一条核对路径。测试失败也会清理自己的临时目录。
test_time_window_lab.py
"""Independent fixed expectations and raw commit-object traversal; standard library only."""
import calendar
import json
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from time_window_lab import GitFailure, Lab, experiment, half_open, read_json
class TimeWindowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory(prefix="git-window-tests-")
cls.addClassCleanup(cls.tmp.cleanup)
cls.lab = Lab(cls.tmp.name)
cls.f = cls.lab.fixture
cls.start, cls.end = cls.f["window_start"], cls.f["window_end"]
cls.expected = read_json("expected.json")
cls.report = experiment(cls.lab)
cls.rows = cls.lab.walk(cls.lab.pin)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def selected(self, rows):
return self.lab.labels(half_open(rows, self.start, self.end))
def test_all_handwritten_expectations(self):
self.assertEqual(self.expected, self.report["observed"])
def test_full_first_parent_order(self):
self.assertEqual(self.lab.labels(self.rows), self.expected["first_parent_walk"])
def test_independent_raw_object_walk(self):
# Does not call walk(), half_open(), git log, or git's date-limiting options.
oid = self.lab.pin
labels = {value: key for key, value in self.lab.ids.items()}
chosen, traversed = [], []
while oid:
raw = self.lab.run("cat-file", "commit", oid)
header = raw.split("\n\n", 1)[0].splitlines()
parents = [line[7:] for line in header if line.startswith("parent ")]
committer = next(line for line in header if line.startswith("committer "))
seconds = int(committer.rsplit(" ", 2)[-2])
traversed.append(labels[oid])
if seconds >= 1791828000 and seconds < 1791914400:
chosen.append(labels[oid])
oid = parents[0] if parents else None
self.assertEqual(traversed, self.expected["first_parent_walk"])
self.assertEqual(chosen, ["merge", "last_second", "inside_ancestor", "at_start"])
def test_naive_since_loses_two_ancestors(self):
got = self.selected(self.lab.walk(self.lab.pin, f"--since=@{self.start}"))
self.assertEqual(got, ["merge", "last_second"])
self.assertNotEqual(got, self.expected["fixed_half_open"])
def test_since_as_filter_restores_ancestors(self):
got = self.selected(self.lab.walk(self.lab.pin, f"--since-as-filter=@{self.start}"))
self.assertEqual(got, self.expected["fixed_half_open"])
def test_lower_boundary_is_included(self):
self.assertIn("at_start", self.selected(self.rows))
self.assertNotIn("root_before", self.selected(self.rows))
def test_upper_boundary_is_excluded(self):
self.assertNotIn("at_end", self.selected(self.rows))
self.assertIn("last_second", self.selected(self.rows))
def test_until_is_not_the_exclusive_upper_comparison(self):
rows = self.lab.walk(self.lab.pin, f"--until=@{self.end}")
self.assertIn("at_end", self.lab.labels(rows))
self.assertNotIn("at_end", self.selected(rows))
def test_author_time_selects_a_different_set(self):
actual = self.lab.labels(half_open(self.rows, self.start, self.end, "author"))
self.assertEqual(actual, ["at_end", "old_child", "root_before"])
self.assertNotEqual(actual, self.expected["fixed_half_open"])
def test_second_parent_is_outside_this_view(self):
self.assertNotIn("side_only", self.selected(self.rows))
self.assertIn("side_only", self.selected(self.lab.walk(self.lab.pin, first_parent=False)))
def test_pinned_commit_survives_ref_move(self):
current = self.lab.run("rev-parse", "refs/heads/main^{commit}").strip()
self.assertNotEqual(current, self.lab.pin)
self.assertEqual(self.selected(self.lab.walk(self.lab.pin)), self.expected["fixed_half_open"])
self.assertIn("later_ref", self.selected(self.lab.walk(current)))
def test_missing_object_is_error_not_empty(self):
with self.assertRaises(GitFailure):
self.lab.walk("0" * 40)
def test_shallow_history_is_blocked_not_complete(self):
with tempfile.TemporaryDirectory(prefix="git-window-shallow-") as tmp:
lab = Lab(tmp)
# Artificial shallow boundary in this disposable repository only.
(lab.repo / "shallow").write_text(lab.ids["inside_ancestor"] + "\n")
with self.assertRaisesRegex(GitFailure, "Shallow history"):
lab.walk(lab.pin)
def test_integer_epoch_input_and_exact_day(self):
for name in ("window_start", "window_end"):
iso = datetime.fromisoformat(self.f[name + "_iso"])
self.assertEqual(calendar.timegm(iso.utctimetuple()), self.f[name])
self.assertEqual(self.end - self.start, 86400)
def test_refuses_subsecond_or_boolean_fixture_endpoints(self):
for start, end in [(float(self.start), self.end), (True, self.end),
(self.start, float(self.end)), (self.end, self.end)]:
with self.assertRaises(ValueError):
half_open(self.rows, start, end)
def test_same_inputs_produce_same_commit_ids(self):
with tempfile.TemporaryDirectory(prefix="git-window-repeat-") as tmp:
again = Lab(tmp)
self.assertEqual(again.ids, self.lab.ids)
if __name__ == "__main__":
unittest.main(verbosity=2)
test_mutants.py 在新的临时副本里逐次改坏一处实现,然后运行指定的单个测试。只有该测试真正触发断言失败才算检出;导入错误或语法错误不算通过。它检查九类退化:--since 剪枝、右端闭合、左端排除、错用作者日期、遍历全部父节点、重新解析移动引用、将 Git 错误吞为空结果、放过浅历史、接受浮点端点。原始源码和 expected.json 都不会被变异驱动改写。
test_mutants.py
"""Run nine intentionally defective copies; the published reader is never edited.
Each mutant must fail its designated assertion test, not merely crash or fail import.
Only the Python standard library and the already installed Git are used.
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
MUTANTS = [
("since_prunes_ancestor", 'args += list(date_options) + [pin, "--"]',
'args += list(date_options or (f"--since=@{self.fixture[\"window_start\"]}",)) + [pin, "--"]',
"test_full_first_parent_order"),
("upper_boundary_inclusive", 'start <= row[field] < end', 'start <= row[field] <= end',
"test_upper_boundary_is_excluded"),
("lower_boundary_exclusive", 'start <= row[field] < end', 'start < row[field] < end',
"test_lower_boundary_is_included"),
("author_instead_of_committer", 'end, field="committer"):', 'end, field="author"):',
"test_lower_boundary_is_included"),
("all_parents", 'if first_parent:', 'if False:',
"test_second_parent_is_outside_this_view"),
("resolve_ref_again", 'args += list(date_options) + [pin, "--"]',
'pin = self.run("rev-parse", "refs/heads/main^{commit}").strip()\n args += list(date_options) + [pin, "--"]',
"test_pinned_commit_survives_ref_move"),
("error_becomes_empty", 'raise GitFailure(f"Git exited {result.returncode}: {result.stderr.strip()}")',
'return ""', "test_missing_object_is_error_not_empty"),
("shallow_becomes_complete", 'if self.run("rev-parse", "--is-shallow-repository").strip() != "false":',
'if False:', "test_shallow_history_is_blocked_not_complete"),
("accept_float_endpoints", 'if type(start) is not int or type(end) is not int or start >= end:',
'if start >= end:', "test_refuses_subsecond_or_boolean_fixture_endpoints"),
]
def main():
original = (ROOT / "time_window_lab.py").read_text(encoding="utf-8")
results = []
for name, before, after, test in MUTANTS:
if original.count(before) != 1:
raise RuntimeError(f"Mutation target must occur once: {name}")
with tempfile.TemporaryDirectory(prefix="git-window-mutant-") as tmp:
folder = Path(tmp)
for filename in ("fixture.json", "expected.json", "test_time_window_lab.py"):
(folder / filename).write_bytes((ROOT / filename).read_bytes())
(folder / "time_window_lab.py").write_text(original.replace(before, after), encoding="utf-8")
target = "test_time_window_lab.TimeWindowTests." + test
proc = subprocess.run([sys.executable, "-B", "-m", "unittest", "-v", target],
cwd=folder, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
text=True, capture_output=True, timeout=40)
killed = (proc.returncode == 1 and "Ran 1 test" in proc.stderr
and "FAILED (failures=1)" in proc.stderr and "ERROR" not in proc.stderr)
results.append({"mutant": name, "test": test,
"returncode": proc.returncode, "killed_by_assertion": killed})
if not killed:
print(proc.stdout, file=sys.stderr)
print(proc.stderr, file=sys.stderr)
report = {"mutants": results, "killed": sum(r["killed_by_assertion"] for r in results),
"total": len(results)}
print(json.dumps(report, indent=2))
if report["killed"] != report["total"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
13. 实际输出,以及失败时该怎样报告
主程序本次退出码为 0,标准输出完整如下。标签只为了便于阅读;结果目录中的 report.json 同时保存完整 SHA、原始枚举行和预期,commands.json 保存命令参数、输入、返回码与标准输出/错误。
{
"git_version": "git version 2.52.0",
"python_version": "3.12.14",
"observed": {
"first_parent_walk": [
"merge",
"at_end",
"last_second",
"old_child",
"inside_ancestor",
"at_start",
"root_before"
],
"fixed_half_open": [
"merge",
"last_second",
"inside_ancestor",
"at_start"
],
"naive_since_half_open": [
"merge",
"last_second"
],
"since_as_filter_half_open": [
"merge",
"last_second",
"inside_ancestor",
"at_start"
],
"until_includes_at_end": true,
"wrong_author_time": [
"at_end",
"old_child",
"root_before"
],
"after_ref_moves_pinned": [
"merge",
"last_second",
"inside_ancestor",
"at_start"
],
"after_ref_moves_reresolved": [
"later_ref",
"merge",
"last_second",
"inside_ancestor",
"at_start"
],
"missing_tip_status": "BLOCKED"
},
"all_match": true
}
尤其要看 until_includes_at_end。本次 Git 2.52.0 的 --until=@1791914400 仍输出恰好等于右端点的 at_end,所以它不能代替应用层的 committer < end。这个等号结果是本版本的实验观察,不写成跨版本的排他性保证。
wrong_author_time 得到 at_end、old_child、root_before,与约定集合完全不同。分支移动后,已固定 SHA 的结果仍是原先 4 项;重新解析 refs/heads/main 则多出 later_ref。这两组对照分别检验时间字段和输入对象的身份,不是远端新鲜度证据。
单元测试记录
test_all_handwritten_expectations (test_time_window_lab.TimeWindowTests.test_all_handwritten_expectations) ... ok
test_author_time_selects_a_different_set (test_time_window_lab.TimeWindowTests.test_author_time_selects_a_different_set) ... ok
test_full_first_parent_order (test_time_window_lab.TimeWindowTests.test_full_first_parent_order) ... ok
test_independent_raw_object_walk (test_time_window_lab.TimeWindowTests.test_independent_raw_object_walk) ... ok
test_integer_epoch_input_and_exact_day (test_time_window_lab.TimeWindowTests.test_integer_epoch_input_and_exact_day) ... ok
test_lower_boundary_is_included (test_time_window_lab.TimeWindowTests.test_lower_boundary_is_included) ... ok
test_missing_object_is_error_not_empty (test_time_window_lab.TimeWindowTests.test_missing_object_is_error_not_empty) ... ok
test_naive_since_loses_two_ancestors (test_time_window_lab.TimeWindowTests.test_naive_since_loses_two_ancestors) ... ok
test_pinned_commit_survives_ref_move (test_time_window_lab.TimeWindowTests.test_pinned_commit_survives_ref_move) ... ok
test_refuses_subsecond_or_boolean_fixture_endpoints (test_time_window_lab.TimeWindowTests.test_refuses_subsecond_or_boolean_fixture_endpoints) ... ok
test_same_inputs_produce_same_commit_ids (test_time_window_lab.TimeWindowTests.test_same_inputs_produce_same_commit_ids) ... ok
test_second_parent_is_outside_this_view (test_time_window_lab.TimeWindowTests.test_second_parent_is_outside_this_view) ... ok
test_shallow_history_is_blocked_not_complete (test_time_window_lab.TimeWindowTests.test_shallow_history_is_blocked_not_complete) ... ok
test_since_as_filter_restores_ancestors (test_time_window_lab.TimeWindowTests.test_since_as_filter_restores_ancestors) ... ok
test_until_is_not_the_exclusive_upper_comparison (test_time_window_lab.TimeWindowTests.test_until_is_not_the_exclusive_upper_comparison) ... ok
test_upper_boundary_is_excluded (test_time_window_lab.TimeWindowTests.test_upper_boundary_is_excluded) ... ok
----------------------------------------------------------------------
Ran 16 tests in 0.209s
OK
定向变异记录
{
"mutants": [
{
"mutant": "since_prunes_ancestor",
"test": "test_full_first_parent_order",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "upper_boundary_inclusive",
"test": "test_upper_boundary_is_excluded",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "lower_boundary_exclusive",
"test": "test_lower_boundary_is_included",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "author_instead_of_committer",
"test": "test_lower_boundary_is_included",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "all_parents",
"test": "test_second_parent_is_outside_this_view",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "resolve_ref_again",
"test": "test_pinned_commit_survives_ref_move",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "error_becomes_empty",
"test": "test_missing_object_is_error_not_empty",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "shallow_becomes_complete",
"test": "test_shallow_history_is_blocked_not_complete",
"returncode": 1,
"killed_by_assertion": true
},
{
"mutant": "accept_float_endpoints",
"test": "test_refuses_subsecond_or_boolean_fixture_endpoints",
"returncode": 1,
"killed_by_assertion": true
}
],
"killed": 9,
"total": 9
}
正常实现的 16 项测试全部通过,9 个变异副本都由指定断言检出。变异副本各自的退出码 1 是故意制造的反例;test_mutants.py 本身退出码为 0,表示九类反例均被检出。
全零的不存在 tip 会触发 Git 非零退出,读取器抛出 GitFailure;实验把这一项记录为 BLOCKED。另一个测试只在自己的临时库写入浅历史边界,读取器在枚举前拒绝。它们都不能包装成成功的空集合。实际任务中,若已有一段可靠证据但覆盖不全,应写 PARTIAL 并指出缺口;无法建立可用输入则写 BLOCKED。不要自动联网补对象,也不要放宽原权限来消除错误。
文件不存在、版本不支持选项、Git 非零退出或单条命令超过 20 秒,都会使这一轮实验失败;不要从残留的输出目录推断完成。先保留错误和已知范围,查明原因后在新的输出目录重跑。目录名冲突会直接报错,不覆盖原证据。
14. 补充实验的范围、资料与下载
本次通过的是受控合成库里的时间候选选择,不是完整本地变更报告。所有提交虽然都含 app/report.txt,读取器并没有实现路径授权、差异审阅、秘密正文遮蔽、实际 HEAD/dirty 报告或 40 项预算;这些仍由原任务单约束,不能把这份实验程序直接用于用户仓库。
这里的“完整遍历”和“不联网”只适用于程序自己新建的仓库。代码设置 GIT_NO_REPLACE_OBJECTS,但它不是任意仓库的完整性或隔离保证;graft、alternate、promisor、对象按需获取、不可信配置和真实项目并发改动都不在本实验验证范围。浅历史拒绝是一项明确检查,也不能替代这些前提。
half_open 只接受整数秒端点,并拒绝浮点数与布尔值。真实 run_started_at 可能带小数,不能为了套用本例而截断它,否则边界归属会变。正式实现应保留原精度、统一精确的整数时间单位后再比较;这份实验没有实现生产用时间解析器。大仓库完整遍历的成本同样没有测量,预算耗尽而尚未证明覆盖时不能写 COMPLETE。
参考资料均为 Git 官方手册:rev-list 2.52.0 的日期过滤与第一父遍历;log 2.52.0 的元数据格式;commit-tree 的对象构建与日期输入。最后一份固定在可访问的 2.49.0 文档,其版本记录注明 2.49.1 至 2.54.0 无变化;实际执行仍是 Git 2.52.0。资料与合成实验核对日期为 2026 年 10 月 13 日。
下载包包含五份完整可执行输入、独立预期、样例结果与官方资料索引。这是源码与样例结果包,不包含完整离线文章;原任务单、NOT_RUN 空白验收单与概念图请阅读本页正文,复跑实验不依赖离线文章文件。样例 commands.json 只将随机临时根路径替换为 <synthetic-root>,其余命令输入与结果保留。SHA256SUMS 用于核对公开文件字节,不是签名。原文任务单与 NOT_RUN 空白验收单完整保留;没有创建、运行或验证任何桌面日程。
这份检查能报告远端最新提交吗?
不能。它只读取本地已有 Git 对象,不 fetch;报告必须把远端新鲜度标成 UNKNOWN。
没有匹配提交是否代表项目没有问题?
只说明这个本地快照在指定目录与时间窗内没有匹配提交。本例不运行测试,不能作代码健康判断。
人工首轮通过后,能否把计划运行标成通过?
不能。首次计划运行要保留自己的实际时间、输入 SHA、输出和验收记录。