工作区含 price.py、test_price.py、notes.txt;仅前两份修改进入选定的暂存导出并测试、提交;无关笔记修改继续保留,提交中的笔记保持原字节。

小提交应当讲清一个完整变化

有用的提交应当回答仓库行为发生了什么变化。它包含实现、能够说明该行为的验证,以及使用变化所需的文档。这里的“小”指修改理由有明确边界,而不是行数越少越好。例如,修正免运费门槛,需要同时修改计算函数和对应的回归断言;旁边正在编写的无关笔记应当继续留在工作区。把这些边界说明白,之后的读者才知道哪些变化必须一起存在。 暂存区决定这次留下的检查点 Git 区分工作区、暂存区和已经提交的文件树。编辑文件只会改变工作区;执行添加操作,才会把当时的内容放进暂存区。此后继续编辑,不会自动更新已经暂存的版本。因此,在当前目录运行测试成功,并不能证明即将提交的快照也能通过:测试可能依赖尚未暂存的修复、导入或配置。审阅时应看暂存差异,包括新增和删除;部分暂存造成实质差异时,应验证选中的那个快照。 同一个目录里有两件独立工作时,暂存区可以帮助保留边界。只选择当前目的需要的文件或代码块,同时检查它依赖的测试数据、导入、配置和说明是否齐全。只有几行实现,却遗漏必需的数据库结构变更,并不是可用的独立检查点。反过来,一次机械重命名即使涉及很多文件,只要变化理由一致,仍然可以是一个完整事项。文件数量不能替代对依赖关系的判断。 下面的小实验只有一组自包含文件,因此只复制改动文件就足够;真实仓库的暂存快照还必须包含未改动的已跟踪依赖、可执行模式以及必需的子模块。若不完整导出又意外从工作区导入依赖,就破坏了检查原本想建立的隔离,测试通过仍不能证明实际提交可用。 坏掉的样子 混合提交把本来无关的决策绑在一起。回退界面变化时,可能顺带移除藏在同一提交里的可靠性修复。随意按文件拆开也未必更好:如果第一个提交已经调用新接口,第二个提交才定义接口,中间状态就不能运行。应该保留有意义的中间版本;如果一组修改必须按顺序共同审阅,就明确写出依赖,而不是用很多碎片制造可独立交付的假象。 小提交能够减少审阅者需要同时理解的信息,却不保证反向操作没有冲突,更不保证生产回退安全。后续代码可能已经依赖之前的行为,数据库迁移也可能改变 Git 无法恢复的数据。revert 会记录一个新的反向变化,与中间发生的修改仍可能冲突。即使文本合并成功,也要重新检查最终行为及部署条件,不能把命令成功当作业务状态已经恢复。 二分定位同样需要历史版本上可靠的好坏判定。某个中间提交无法编译,不等于目标缺陷就是在这里引入的。跳过无法测试的版本,有时只能得到多个可能候选。保持检查点可运行有助于定位,但提交够小、标题够清楚,都不能修好一个会随机失败的测试,也不能补齐历史环境缺失的依赖。 暂存字节决定提交内容。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 把下面代码保存为 small-commits.py,安装 Python 3.7+ 和 Git 2.32+ 后执行 python3 small-commits.py。保留断言检查:不要使用 -O,并让 PYTHONOPTIMIZE 保持未设置状态。脚本创建临时仓库,隔离全局 Git 配置并使用空 hooks 目录,只在该仓库创建两个本地提交。实验约定:小计低于五十时收取五个单位运费,达到五十就免运费。初始函数错误地把恰好五十也算进了收费区间。 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess import sys with TemporaryDirectory() as directory: repo = Path(directory) / 'repo' repo.mkdir() hooks = Path(directory) / 'empty-hooks' hooks.mkdir() env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull} def git(*args): return subprocess.check_output([ 'git', '-c', 'user.name=Example', '-c', 'user.email=example@example.invalid', '-c', 'commit.gpgsign=false', '-c', f'core.hooksPath={hooks}', '-c', 'core.autocrlf=false', *args], cwd=repo, env=env, text=True) git('init', '-q', '-b', 'main') (repo / 'price.py').write_text('def shipping(subtotal):\n return 5 if subtotal <= 50 else 0\n') (repo / 'test_price.py').write_text('from price import shipping\nassert shipping(49) == 5\n') (repo / 'notes.txt').write_text('unrelated draft\n') git('add', 'price.py', 'test_price.py', 'notes.txt') git('commit', '-qm', 'Add shipping calculation and its initial check') test = 'from price import shipping\nassert shipping(49) == 5\nassert shipping(50) == 0\n' (repo / 'test_price.py').write_text(test) failed = subprocess.run([sys.executable, '-B', 'test_price.py'], cwd=repo, capture_output=True, text=True) assert failed.returncode != 0 and 'AssertionError' in failed.stderr (repo / 'price.py').write_text('def shipping(subtotal):\n return 5 if subtotal < 50 else 0\n') (repo / 'notes.txt').write_text('unrelated draft, still being edited\n') git('add', 'price.py', 'test_price.py') staged = git('diff', '--cached', '--name-only').splitlines() assert staged == ['price.py', 'test_price.py'] snapshot = Path(directory) / 'index-snapshot' snapshot.mkdir() for name in staged: (snapshot / name).write_text(git('show', ':' + name)) subprocess.run([sys.executable, '-B', 'test_price.py'], cwd=snapshot, check=True) git('commit', '-qm', 'Make the free-shipping threshold inclusive') assert git('status', '--porcelain') == ' M notes.txt\n' assert git('show', 'HEAD:notes.txt') == 'unrelated draft\n' print(json.dumps({'regression_failed_before_fix': True, 'staged_files': staged, 'staged_test_passed': True, 'unrelated_edit_preserved': True})) 新增的边界断言首先会在旧函数上失败。修正之后,脚本仅暂存 price.py 和 test_price.py,再把这两个文件的暂存文本复制到另一目录,在那里执行断言,成功后才提交。输出同时记录修复前的失败和暂存快照的成功,因而能区分“测试存在”与“测试确实运行并通过”这两件事。 ...

七月 25, 2027 · 2 分钟 · XB Field Notes
九个编码历史状态展示正常 0–3 与 5–6、跳过 4、异常 7–8,并指向首坏 7;退出 200 中止而不分类。位置不表示探针顺序。

git bisect 用自动探针定位回归

自动二分会反复检查历史版本,并把测试结果转成对提交的判断。在简单区间里,它通常能用近似对数级的探针次数替代大量手动检出,但前提是测试可信。包装脚本若把工具缺失或基础设施故障错误归类为产品回归,就可能给出看似明确、实际错误的提交。因此,设计判断契约本身就是调试工作的一部分,不能当作搜索外面无关紧要的准备步骤。 先确定能够复现的问题 选择一个可观察的回归,并用同一个执行器验证计划采用的正常端点和异常端点。某个发布名字被认为没问题,证据弱于在那个准确版本上实际检查成功。输入、工具链、依赖和相关环境应保持足够一致,让源码版本成为真正接受检验的变量;否则即使最后缩小到一个提交,也无法知道改变结果的究竟是代码还是环境。 启动时先给出异常版本,再给出已知正常版本。Git 会选择中间提交,并从当前检出的工作树调用探针。执行器应放在不会随历史切换而消失的稳定路径,或确保每个待测版本都包含可用实现。相对路径脚本在搜索一半时不存在,属于测试设施问题,不是产品恰好从那次提交开始损坏的证据。 按真实退出码契约映射结果 自动运行模式中,零表示正常;一到一百二十七表示异常,但一百二十五是例外,代表当前版本无法测试,需要跳过。其他状态值会中止自动搜索。这与“所有非零都说明发生回归”的简化说法不同,包装脚本必须明确区分结果,而不能把任何失败都交给 Git 当作同一种判断。 文档规定的异常区间包含常用于命令不可执行或不存在的一百二十六和一百二十七。应提前检查必需工具,并把识别出的基础设施失败映射到中止状态,例如二百。不能不看测试工具自身的含义,就原样转交所有非零返回。另外,超出可表示范围的 Shell 退出值可能被截断,因此需要选择明确、受支持的状态,不能随意用一个巨大数字表示致命错误。 跳过适用于某个确实无法测试、结论未知的版本,而不是所有构建失败。如果正在寻找的回归本来就是构建损坏,把构建失败跳过就等于丢掉主要证据。反过来,与症状无关的过时依赖,也不应自动把旧提交判坏。执行器为什么把两类情况区别对待,需要有可检查的依据,并在日志中保留相应原因。 坏掉的样子 测试输出经过管道时,最后的日志命令成功可能掩盖测试失败;清理命令也可能覆盖原状态。应先保存测试结果,再记录诊断,最后明确返回映射后的判断值。标准错误上出现了一段异常文字,并不会阻止 Git 接受意外返回的零。自动化读取的是状态契约,不会根据人类看起来很严重的日志重新推断好坏。 修改源码的测试、复用陈旧构建产物的测试,以及依赖持续变化远端服务的测试,都可能让不同版本之间失去可比性。应隔离生成文件,并保留足够日志解释每次判断。不要为了让探针可重复,就清空宽泛的用户目录;应使用可丢弃且归属明确的测试环境,把清理范围限定到自己创建的路径。 靠近转换边界的跳过,可能留下多个首坏候选。随机失败或非单调变化的判断条件,也会破坏简单边界假设;增加次数不能修复错误标签。归因之前应检查搜索日志,在报告出的边界及相关父级重新测试。提交图、跳过版本和不同构建耗时还意味着,对数级探针次数并不等于固定的实际运行时间。 判断契约也是搜索的一部分。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 在具有 /dev/null 的 POSIX 系统上,使用 Python 3.7 或更新版本,以及 Git 2.32 或更新版本运行下面实验。运行时不要使用 -O、-OO 或非零的 PYTHONOPTIMIZE,以免断言被跳过。临时仓库排除个人 Git 配置和路由变量,关闭钩子与签名,没有远端。九个合成版本编码正常、异常或不可测状态;第七号版本首次变坏,第四号版本专门触发跳过路径。执行器位于检出仓库之外,因此切换提交不会把它移走或替换成旧版本。 ...

七月 7, 2027 · 2 分钟 · XB Field Notes
轻量引用直接指向提交;附注引用先指向标签对象,再由它指向提交。

git tag 把一次发布固到提交

发布标签为某个源码版本提供一个容易交流的名字。它与分支不同,创建新提交时不会自动前进。但 Git 并没有从物理上禁止标签变化:拥有相应权限的人仍能替换或删除标签引用。可靠的发布约定来自几件事共同作用:记录对象标识、坚持发布名称不可变,以及限制谁能够更新发布引用。不能仅凭“用了标签”就断言任何人拿到的代码一定相同。 轻量标签直接指向提交;附注标签经独立标签对象指向目标。标签不随新提交前进,但发布名称稳定仍需权限与策略。签名信任、制品来源和远端分发是不同检查。 两种标签 轻量标签是直接指向某个对象的引用,通常指向提交。附注标签则指向单独的标签对象,其中保存目标、打标签者身份、时间、说明,以及可选的签名。打标签的人并不一定是提交作者;有了这些元数据,也不意味着附注已经经过密码学签名。发布记录需要说明谁作出发布决定,而不是把提交作者与发布者混为一谈。 对于附注标签,git rev-parse refs/tags/v2.4.1 得到标签对象标识,git rev-parse 'refs/tags/v2.4.1^{commit}' 则解引用到目标提交。当说明或签名身份也是发布证据的一部分时,应同时记录二者。只比较提交会漏掉标签留言或签名者变化;只比较可读名称,则连源码是否一致也无法确定。对象标识与名称各自承担不同的核对职责。 为什么给发布签名 git tag -v 使用配置的签名后端及本地信任材料验证签名标签。密码学验证成功之后,还需要确认该密钥确实属于项目发布策略认可的人。一个陌生人的有效签名,并不能证明维护者批准了这次发布。密钥的来源、授权范围和撤销状态,都应由实际发布流程明确管理,而不是靠命令里出现“成功”两个字代替。 源码标签有签名,也不能直接证明下载的二进制就是从该源码构建的。制品校验和、构建来源记录及构建过程,需要补上这条独立关系。另外,不要把验签命令接到 head 后面,再依赖管道最后一项的退出码:只截取几行输出可能掩盖验签失败。发布检查应保留并判断验签命令自身的结果,显示多少行只是界面选择。 移动要格外小心 修正尚未分享的本地标签,与替换消费者已经获取的标签,是两种不同操作。标签一旦分发,应优先使用新版本名称,并记录旧版本被撤回的原因。修补安全缺陷也不要求重写旧标签;保留原引用反而有助于识别受影响部署。服务端的引用保护应阻止未经授权的发布标签更新与删除,不能只靠客户端的习惯维持这个约定。 如果特殊情况下确实批准替换,应公布准确的新旧对象标识,并只协调那个明确的引用。把所有标签一起强制获取,会覆盖其他无关的本地引用,不应成为常规修复建议。消费者需要知道自己持有的是哪一个对象,不能让两个不同提交在没有说明的情况下继续共享同一个发布名称。 坏掉的样子 latest 这类浮动名称可能在两次构建之间解析到不同提交。即便界面保留方便阅读的名字,也应为每个制品和每次部署记录真正解析到的提交标识。同名不保证两次构建一致,而固定提交也不会自动固定外部依赖或编译器版本。源码定位与可复现构建有关,却不能互相替代。 另一种故障是发布标签只存在于某个本地克隆。普通分支推送不一定传输它;--follow-tags 处理的是从推送提交可达、符合条件的附注标签,不是本地全部标签。应使用 git ls-remote --tags 核对准确的远端引用及对象标识,不能对版本号作模糊子串匹配,否则可能把另一个版本当成目标。远端可见只能证明分发,不能证明构建正确或签名可信。 快速验证 在 POSIX 系统上用 Python 3 及 Git 运行下面的实验。它创建独立临时仓库,使用示例身份,为演示明确关闭签名,并且不配置远端。所有提交和标签都位于这个临时仓库,不会给当前项目打标签;退出后由临时目录管理器清理。命令通过参数列表调用,不需要拼接或解释来自外部的 Shell 文本。 ...

六月 11, 2027 · 1 分钟 · XB Field Notes
HEAD 选中 topic,topic 指向 C,main 指向 B。父级箭头从新提交指向父提交。

git 分支是一个会移动的标签

Git 分支为一条历史线的末端提供名字。新建分支通常只是增加一个指向既有提交的引用,不会再复制一份全部受跟踪文件。两个名字可以先指向同一提交,再分别前进。这让分支创建成本很低,但不意味着名字内部装着历史,更不意味着删除指向某个提交的全部引用后,内容仍会永远保留。理解分支时,需要把名称、对象和保留条件分开。 在附着的 topic 上提交,只会移动该分支引用。父级箭头保留共享祖先。未诞生 HEAD 尚无提交;分离 HEAD 直接指向提交。新建分支不会保存未提交编辑。 它是引用,不是固定的存储文件 本地分支属于 refs/heads/ 引用命名空间,具体表示方式是实现细节:引用可能独立存储、被打包,或使用其他受支持的后端。不要假定一定存在某个松散文件,就直接编辑 Git 内部目录来移动分支。应使用 Git 的引用命令,让它遵守仓库实际的存储结构和更新规则,而不是把一种磁盘布局当作永久接口。 提交记录目录树和父提交,沿着父级关系向前追溯,就得到分支末端可达的历史。因此,两条分支可以共享大部分祖先,却拥有不同的末端。分支名称不是提交身份的一部分,新增或重命名分支也不会改写它指向的提交。图中的对象关系解释了共享历史,名称只是人们访问这些对象的入口。 创建分支与切换分支也不同。git branch topic 在当前提交创建名称,但当前分支不会改变;git switch topic 才选中它,并按需要更新工作树。git switch -c topic 把创建与切换合在一起。记录新工作之前,应检查真正活动的分支,不能因为名称已经存在,就误以为接下来的提交一定会记在那条线上。 HEAD 决定哪个名字前进 第一次提交之前,HEAD 可以指向尚未诞生的分支名,此时分支引用还无法解析为提交。这与分离状态不同:名字已被选中,只是没有首个提交对象。仓库已有提交之后,通常的附着状态下,HEAD 通过符号引用指向当前分支。创建提交时,该分支前进到新对象,其他原本共享旧末端的分支保持不动;新提交的父级关系保留祖先。分支也可能因合并或其他明确更新引用的操作而移动,并不是只有普通提交才能改变它。因此,判断某个名字为什么变化,需要检查实际发生的操作,而不只看提交按钮是否被点击。 ...

二月 5, 2027 · 2 分钟 · XB Field Notes
仓库边界内有保留原值的先前提交节点和空的当前树节点;独立运行时文件位于仓库外。

凭据应留在仓库历史之外

凭据一旦提交到 Git,就成为版本化对象关系的一部分,协作者和自动化系统都有可能复制它。从最新目录树删除文件,并不会删除先前包含它的提交。实际应建立的边界,是让凭据值不进入源码历史,再通过获得授权的运行时机制供应。仓库配置可以说明怎样取得秘密,而不需要把秘密本身写进可复制的源文件。 坏掉的样子 一个看起来干净的检出,可能掩盖历史版本中已经暴露的值。新克隆通常会收到可达历史,包括已删除文件的旧版本。不能说 Git 在任何清理策略下都会永远保留所有对象,但“删掉今天的文件”确实不等于清理历史。即使重写一个仓库,也无法召回已经独立分发出去的克隆、分叉、缓存和构建产物,清理某一位置不能代表所有副本都已消失。 面对真实泄漏,应先撤销或替换相关凭据,并确认哪些系统会接受它。删除一行改变的是发布内容,不是认证状态。历史重写应单独协调,因为它会改变提交标识,也可能打断协作者的工作。说明事件时,不能再把原值复制到问题单、提交说明、扫描报告或事故笔记里,否则处理过程本身又制造了新的传播位置。 运行时注入也不会自动保密。环境值可能被子进程继承或被诊断工具采集;文件可能被具有足够访问权限的身份读取。应限制消费服务的权限,控制交付通道,避免把真实值写入日志和生成结果。即使文件位于仓库外,也仍然需要明确的拥有者与生命周期,不能把“没有提交到 Git”当作全部秘密管理工作已经完成。 删除改变目录树,不改变旧认证或已分发副本。运行时分离仍需要访问与生命周期控制。 快速验证 在安装了 Git 的 POSIX 系统上运行下列 Python 脚本,并保持断言启用;不要使用 -O 或 PYTHONOPTIMIZE。它使用明确的虚构标记,绝不使用任何有效凭据;两个仓库和运行时文件都位于同一个临时目录,退出后一起清理。夹具排除继承的 GIT_* 覆盖值、系统及全局配置和模板钩子,原文报告使用 Git 2.50.1,后续保留的完整围栏记录使用 Git 2.52.0 与 Python 3.12.14;本轮源码复核没有重新执行: from pathlib import Path from tempfile import TemporaryDirectory import io,os,stat,subprocess,sys,tarfile env={key:value for key,value in os.environ.items() if not key.startswith('GIT_')} env.update(GIT_CONFIG_NOSYSTEM='1',GIT_CONFIG_GLOBAL=os.devnull) with TemporaryDirectory(prefix='credential-repo-lab-') as directory: base=Path(directory);repo=base/'source';repo.mkdir() def git(*args,cwd=repo,binary=False): result=subprocess.run(['git',*args],cwd=cwd,env=env,capture_output=True,text=not binary) assert result.returncode==0,result.stderr return result.stdout git('init','-q','--template=');git('config','user.name','Credential Lab') git('config','user.email','credentials@example.invalid') git('config','commit.gpgsign','false') dummy='EXAMPLE-ONLY-NOT-A-REAL-SECRET\n' (repo/'fixture.env').write_text(dummy) (repo/'app.txt').write_text('read the configured credential file at runtime\n') git('add','--','fixture.env','app.txt');git('commit','-qm','Synthetic exposure fixture') original=git('rev-parse','HEAD').strip() git('rm','--','fixture.env');git('commit','-qm','Remove fixture from current tree') assert not (repo/'fixture.env').exists() assert git('show',original+':fixture.env')==dummy clone=base/'clone';git('clone','-q','--no-local',str(repo),str(clone)) assert not (clone/'fixture.env').exists() assert git('show',original+':fixture.env',cwd=clone)==dummy print('current file absent; historical fixture readable in source and clone') archive=git('archive','--format=tar','HEAD',binary=True) with tarfile.open(fileobj=io.BytesIO(archive),mode='r:') as bundle: assert bundle.getnames()==['app.txt'] print('current-tree archive excludes fixture; Git history still contains it') runtime=base/'runtime';runtime.mkdir(mode=0o700) secret_file=runtime/'credential';secret_file.touch(mode=0o600) secret_file.write_text(dummy) assert stat.S_IMODE(secret_file.stat().st_mode)==0o600 child_env=dict(env,CREDENTIAL_FILE=str(secret_file)) child="from pathlib import Path; import os; p=Path(os.environ['CREDENTIAL_FILE']); assert p.read_text(); print('credential loaded without printing its value')" result=subprocess.run([sys.executable,'-c',child],cwd=clone,env=child_env,text=True,capture_output=True) assert result.returncode==0,result.stderr assert dummy.strip() not in result.stdout+result.stderr print(result.stdout.strip()) 第一项确认,两个当前工作区都没有样本文件,但都能通过早先的提交读取它的原始内容。克隆使用普通 Git 传输路径,关闭本地硬链接优化,因此验证的是另一个实际建立起来的对象数据库,不只是两个路径恰巧指向同一份文件。脚本在内部比较虚构标记,结果只说明是否保留,没有打印它的值,保留了检查与泄漏输出之间的区别。 ...

九月 14, 2026 · 1 分钟 · XB Field Notes