把工作区改动保存起来、稍后可以重新应用的栈。

git stash 把改动暂存到别处

Git stash 记录选定的未提交工作,让检出可以暂时回到较早的已提交状态。它适合短暂中断,但保存和恢复都不是对目录中所有文件的无条件承诺。真正需要确认的是包含了什么、工作原来基于哪个版本、目标现在处于什么状态,以及保存条目何时才能移除。只看到命令报告成功,还不足以证明所有想保留的数据都已经纳入快照。 明确快照包含的范围 普通保存操作记录受跟踪文件的工作树变化与索引,再把这些修改退回到 HEAD 对应状态。未跟踪文件默认留下,只有加入相应选项才会一起保存;被忽略文件在包含未跟踪文件时仍然留下,需要更大的包含范围才会纳入。选择之前先检查,因为忽略路径里可能有凭据、大型构建输出或不适合进入 Git 对象的本地数据,不能为了追求“全部收起来”就不看内容。 选定路径、只处理已暂存内容、保留索引等选项,会产生不同结果。不要习惯性组合参数,再假定它们代表同一份快照。应先检查状态与预期差异,为条目写明含义,并查看实际保存的内容,再把原编辑视为已经能够恢复。尤其要区分磁盘文本和索引文本,它们可能在同一路径上分别代表两阶段尚未完成的工作。 stash 使用 Git 提交对象表示,不是对象库之外的特殊文件。最新条目由专用引用指向,更旧的条目通过该引用的日志保留,所以普通分支历史不一定显示它们。忘记使用并不自动等于丢失,但删除保留引用之后,对象最终可能无法恢复。本地分支或 stash 也不会仅凭存在,就提供一份独立的异地备份;保存形式与备份位置是不同问题。 应用与弹出不同 应用命令尝试恢复,同时保留保存条目,适合先审阅恢复结果,再决定是否删除副本。默认不能期待原来的已暂存与未暂存区别自动重建;索引选项会额外尝试恢复这一状态,但如果冲突或目标变化妨碍恢复,仍可能失败。应检查索引和磁盘各自的结果,而不只看最终文件里是否出现了某段文字。 弹出操作只在应用成功后才从列表移除条目。发生冲突时,保存项保留,但能够干净应用的路径可能已经改变。因此,非零返回不代表整个工作树完全没动。再次操作前应查看索引和文件,不能在部分恢复的目录上反复弹出同一条目,指望重复执行自动修复第一次冲突。 条目不属于某个分支或关联工作树私有。描述中出现的原分支名称是背景,不是强制只能在那里恢复的规则。随着新增和删除,数字选择器还会变化,所以敏感操作前要检查并记录准确对象。在 Shell 中传入带花括号的选择器时应引用,避免外层语法干扰。需要删除的究竟是哪份内容,应由对象证据确认,而不是仅凭它此刻排在第一位。 坏掉的样子 在变化后的基线上恢复可能冲突;即便文本没有冲突,也可能需要业务适配。应依据最终代码意图解决,暂存具体路径并验证结果,不能反复向半恢复状态应用同一份变化。stash 应用没有一个通用中止命令,能够自动撤销任何已经产生的部分效果,因此“先试试,不行就一键回去”不是可靠的恢复设计。 如果适合回到原始基线继续,可以使用建立分支的恢复命令:它从记录的起点创建分支,再应用保存工作。通过 stash 日志指定条目且应用成功时,条目也会被移除。这是与记录基线有关的恢复流程,不要求必须有两份 stash,也不存在超过某个时间就必须采用的固定期限。应按当前基线与恢复意图判断,而不是按存放时长机械选择。 移除应发生在验证之后,不能代替验证。清空整个列表可能丢弃无关工作,已经删除的对象以后还可能被回收。需要持续评审的工作,应在适合的分支形成有意提交,并遵守项目的备份与共享规则。stash 也不承诺捕获编辑器未保存缓冲区、任意文件系统元数据或构建使用的全部外部资源,不能把它当作整套开发环境的恢复镜像。 快速验证 使用 Python 3 和 Git 2.28 或更新版本运行下面实验。全部修改与移除都发生在可丢弃仓库,使用示例身份,关闭钩子及签名,排除个人 Git 配置和路由变量,没有远端。样例故意让索引与工作文件包含不同值,再检查保存范围、索引恢复、冲突后的条目保留,以及验证后才删除的顺序。 import os import subprocess import tempfile from pathlib import Path 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 tempfile.TemporaryDirectory(prefix="git-stash-demo-") as directory: root = Path(directory) def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Stash Example", "-c", "user.email=stash@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=root, env=env, text=True, capture_output=True, check=check, ) git("init", "-q", "-b", "main") (root / ".gitignore").write_text("ignored.txt\n") (root / "note.txt").write_text("base\n") git("add", ".gitignore", "note.txt") git("commit", "-qm", "base") (root / "note.txt").write_text("staged\n") git("add", "note.txt") (root / "note.txt").write_text("working\n") (root / "new.txt").write_text("untracked\n") (root / "ignored.txt").write_text("ignored\n") git("stash", "push", "-qm", "tracked only") saved = git("rev-parse", "refs/stash").stdout.strip() print("default-leaves-untracked:", (root / "new.txt").exists() and (root / "ignored.txt").exists() and (root / "note.txt").read_text() == "base\n") git("stash", "apply", "--index", saved) restored = (git("show", ":note.txt").stdout == "staged\n" and (root / "note.txt").read_text() == "working\n") assert restored print("index-and-work-restored:", restored) print("apply-keeps-entry:", git("rev-parse", "stash@{0}").stdout.strip() == saved) assert git("rev-parse", "stash@{0}").stdout.strip() == saved git("stash", "drop", "-q", "stash@{0}") git("stash", "push", "-qu", "-m", "tracked and untracked") saved = git("rev-parse", "stash@{0}").stdout.strip() print("include-untracked-not-ignored:", not (root / "new.txt").exists() and (root / "ignored.txt").exists()) (root / "note.txt").write_text("upstream\n") git("add", "note.txt") git("commit", "-qm", "new base") conflict = git("stash", "pop", "stash@{0}", check=False) print("conflicting-pop-keeps-entry:", conflict.returncode != 0 and bool(git("ls-files", "-u").stdout) and git("rev-parse", "stash@{0}").stdout.strip() == saved) (root / "note.txt").write_text("upstream\nworking\n") git("add", "note.txt", "new.txt") assert not git("ls-files", "-u").stdout assert git("show", ":note.txt").stdout == "upstream\nworking\n" assert (root / "new.txt").read_text() == "untracked\n" git("commit", "-qm", "retain both fixture values") assert git("rev-parse", "stash@{0}").stdout.strip() == saved git("stash", "drop", "-q", "stash@{0}") print("resolved-and-verified:", not git("status", "--porcelain").stdout and not git("stash", "list").stdout) 六个判断都应为真。默认保存留下未跟踪和忽略文件;带索引应用恢复两个受跟踪状态,同时保留条目。包含未跟踪文件仍不会包含忽略文件。后续冲突弹出保留对应条目;样例明确决定保留两项预期值,提交并验证结果之后,才删除已经核对的那个保存项。最后由 Python 清理包括忽略文件在内的全部演示数据,不操作真实项目的保存列表。 ...

十二月 11, 2027 · 2 分钟 · XB Field Notes
把一次提交的改动搬到另一分支成为新的提交。

git cherry-pick 重新应用一次提交

cherry-pick 把选定提交引入的改动应用到当前开发线,适合旧发布分支只需要一个修复、不希望同时接收整条开发分支的场景。它不会搬走或删除源提交,也不会为目标建立与源分支的合并关系。移植后的结果必须在目标环境中重新审阅;源分支上已经通过的测试,只能说明原来的组合,不能直接证明换了依赖与历史之后仍然正确。 选择的是相对父提交的变化 对于普通的单父提交,相关变化是它相对父级的差异。Git 利用可用历史和合并机制,把这份变化应用到目标,而不是简单地用源提交的完整文件快照覆盖目标。目标原本已有的其他修改应继续保留,除非选定变化或人工解决明确要求改变它们。审阅时既要看搬来了什么,也要确认没有误把目标自己的工作一起替换掉。 通常,cherry-pick 会在目标末端后面记录新提交。父级不同,结果目录树也可能不同,因此对象身份会变化。原作者身份和作者日期通常保留,而提交者表示实际记录这次移植的人;消息不一定变化。不能把这项操作解释成必然换作者、必然改写消息,更不能根据对象标识不同就断言原作者署名已经丢失。 默认行为会创建提交,但不提交选项会把变化留在索引与工作树中,供审阅或组合;使用允许快进的选项时,满足条件的操作还可以直接前进到已有源提交。这些明确模式说明,“所有 cherry-pick 都一定生成新对象”也不是普遍成立的规则。解释一次具体结果时,应把实际参数与所处历史一起纳入判断。 移植之前检查依赖 修复可能依赖目标分支尚不存在的接口、数据结构、配置或前置重构。补丁在文本上干净落地,并不能证明行为正确。应阅读源变化及其前提,在真正的维护分支运行受影响行为测试,并只引入该修复实际必需的依赖。如果需要适配,说明为何不能原样采用,以及新分支上的实现如何满足同一修复目标。 在共享分支之间干净应用时,-x 会把源提交标识写进新消息,方便追踪,但它不是等价性或批准状态的密码学证明。手动解决冲突后也要检查最终消息,确认来源关系没有丢失。原提交的签名不会自动成为新对象的有效签名;目标要求签名时,应遵循目标发布流程,而不是把保留作者信息等同于继承了所有验证证据。 坏掉的样子 发生冲突时,HEAD 停在最后一个已成功创建的提交,Git 用 CHERRY_PICK_HEAD 记录当前难以应用的源提交。能够干净应用的路径可能已经更新,冲突路径则在索引中保留未合并条目。文本冲突标记便于查看,但二进制冲突及其他类型不能只靠搜索标记字符串识别,还应检查工作区状态和索引,确认当前到底有哪些未完成事项。 解决时应确定目标需要的行为,暂存具体已解决路径,运行相关测试,再继续序列。文件已经暂存只说明冲突被标记为解决,不证明代码逻辑正确。中止会返回序列开始之前的状态;退出序列管理则只是遗忘操作状态,并不执行相同的回退。开始前应保证工作树干净且状态明确,不能把中止功能当作保存无关未提交工作的替代品。 目标已经包含某项变化时,应用结果可能为空。跳过之前,要确认目标确实具有预期行为,而不是只看到类似代码就作判断。选取合并提交还会多一个语义选择:主线参数指定以编号中的哪个父级作为差异基准。这不是通用冲突绕过开关,必须依据想移植的实际变化选择,而且结果不会重现原合并提交的多父级拓扑。 快速验证 使用 Python 3 与 Git 2.28 或更新版本运行下面实验。它建立私有临时仓库,排除个人 Git 配置和继承的 Git 路由变量,使用示例身份,关闭钩子与签名,没有远端。一条目标线能够接收修复;另一条故意把同一行改成不同内容,用来观察冲突停止及中止恢复。全部修改都限于这份临时数据,不触碰当前项目的提交或文件。 import os import subprocess import tempfile from pathlib import Path 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 tempfile.TemporaryDirectory(prefix="git-pick-demo-") as directory: def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Backport Example", "-c", "user.email=backport@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=directory, env=env, text=True, capture_output=True, check=check, ) def commit(text, message): (Path(directory) / "value.txt").write_text(text) git("add", "value.txt") git("commit", "-qm", message) git("init", "-q", "-b", "main") commit("old\n", "base") base = git("rev-parse", "HEAD").stdout.strip() git("switch", "-qc", "source") (Path(directory) / "value.txt").write_text("fixed\n") git("add", "value.txt") git("-c", "user.name=Original Example", "-c", "user.email=original@example.invalid", "commit", "-qm", "fix value") source = git("rev-parse", "HEAD").stdout.strip() git("switch", "-q", "main") git("commit", "--allow-empty", "-qm", "target progress") target = git("rev-parse", "HEAD").stdout.strip() git("cherry-pick", "-x", source) print("change-applied:", (Path(directory) / "value.txt").read_text() == "fixed\n") print("new-object-on-target:", git("rev-parse", "HEAD").stdout.strip() != source and git("rev-parse", "HEAD^").stdout.strip() == target) print("author-preserved:", git("show", "-s", "--format=%ae", "HEAD").stdout.strip() == "original@example.invalid") print("committer-is-backporter:", git("show", "-s", "--format=%ce", "HEAD").stdout.strip() == "backport@example.invalid") print("provenance-recorded:", source in git("show", "-s", "--format=%B", "HEAD").stdout) git("switch", "-qc", "conflict", base) commit("other\n", "target alternative") before = git("rev-parse", "HEAD").stdout.strip() conflict = git("cherry-pick", source, check=False) print("conflict-stopped:", conflict.returncode != 0 and bool(git("ls-files", "-u").stdout) and git("rev-parse", "CHERRY_PICK_HEAD").stdout.strip() == source and git("rev-parse", "HEAD").stdout.strip() == before) git("cherry-pick", "--abort") print("abort-restored:", git("rev-parse", "HEAD").stdout.strip() == before and (Path(directory) / "value.txt").read_text() == "other\n" and not git("status", "--porcelain").stdout) 七个判断都应为真。干净目标包含修复后的值,新提交的父级是目标原来的末端;作者仍为源作者,提交者是移植者,消息记录来源标识。另一条目标线停在冲突状态,索引存在未合并条目;中止后,它恢复原文本、原末端和干净状态。这些结果验证改动转移、身份信息与恢复机制,不冒充真实业务修复已经在维护分支完成验收。 ...

十一月 25, 2027 · 2 分钟 · XB Field Notes
忽略模式与索引状态共同决定普通 Git 路径处理。

.gitignore 控制未跟踪路径的处理

Git 忽略规则影响普通路径发现和添加操作如何处理尚未跟踪的文件,适合管理生成产物、编辑器状态和本地配置。但它不会形成访问控制边界,不会把已进入索引的文件移出去,也不会抹掉更早的提交。因此,应该把忽略文件看成具有可验证匹配规则的项目约定,不能把它当作敏感文件绝对进不了历史的保证。 坏掉的样子 某个文件被跟踪之后,再为它添加忽略规则,并不会停止跟踪。继续修改它并执行普通添加操作,新内容依然可能进入暂存区。这就是为什么泄漏配置后才把文件名补进 .gitignore,不能完成修复。索引里有没有这个路径、工作区文件现在是什么内容、历史提交保留了哪些对象,是三个不同状态,需要分别观察,而不是只看文件是否还显示在默认状态列表中。 否定规则也容易令人误判。排除整个目录,会阻止 Git 继续遍历该目录,后面针对某个子文件的例外便无法挽救它。可以只忽略目录里的内容,让父目录仍能被访问;也可以先逐层恢复被排除的父目录,再恢复后代。若 Git 根本不会进入某个目录,藏在里面的嵌套忽略文件也无从发挥作用,规则写得再靠近文件也没有帮助。 优先级并不是“看上去更精确的模式一定获胜”。Git 对命令行模式、目录忽略文件、仓库本地排除项和全局排除项定义了顺序;同一优先级内部,以最后匹配的模式为准。更深目录的忽略文件,可以针对它所管理的路径覆盖上层规则。因此,模式的写法与来源位置都重要,不能仅比较哪个字符串更长,就推导最终结果。 快速验证 在安装了 Git 的环境中运行下列 Python 脚本。它创建临时仓库,执行后自动删除;对子进程关闭系统与全局 Git 配置,并且只使用虚构样本文件。记录中的版本为 Git 2.50.1。即使从已有项目目录启动,实际 Git 操作也都以临时目录为工作目录,不会修改启动位置的仓库: from pathlib import Path from tempfile import TemporaryDirectory import os,subprocess env=dict(os.environ,GIT_CONFIG_NOSYSTEM='1',GIT_CONFIG_GLOBAL=os.devnull) with TemporaryDirectory(prefix='git-ignore-lab-') as directory: root=Path(directory) def git(*args, expected=0): result=subprocess.run(['git',*args],cwd=root,env=env,text=True,capture_output=True) assert result.returncode==expected,(args,result.returncode,result.stderr) return result.stdout def write(path,content='fixture\n'): target=root/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content) git('init','-q') git('config','user.name','Ignore Lab') git('config','user.email','ignore@example.invalid') git('config','commit.gpgsign','false') write('tracked.txt','first version\n') git('add','--','tracked.txt');git('commit','-qm','Track fixture') write('build/keep.txt');write('build/output.bin') write('.gitignore','/build/\n!/build/keep.txt\n') git('check-ignore','-q','build/keep.txt') print('excluded parent prevents child exception') write('.gitignore','/build/*\n!/build/keep.txt\n*.log\n/root-only.txt\ntracked.txt\n') git('check-ignore','-q','build/keep.txt',expected=1) rule=git('check-ignore','-v','build/keep.txt') assert ':2:!/build/keep.txt\t' in rule git('check-ignore','-q','build/output.bin') print('ignoring contents permits keep.txt; verbose output includes its negation') write('debug.log');write('nested/debug.log');write('nested/.gitignore','!debug.log\n') write('root-only.txt');write('nested/root-only.txt') git('check-ignore','-q','debug.log') git('check-ignore','-q','nested/debug.log',expected=1) git('check-ignore','-q','root-only.txt') git('check-ignore','-q','nested/root-only.txt',expected=1) print('nested exception and root anchor checked') write('tracked.txt','second version\n') git('add','-A') assert 'tracked.txt' in git('diff','--cached','--name-only').splitlines() git('check-ignore','-q','tracked.txt',expected=1) assert 'tracked.txt' in git('check-ignore','--no-index','-v','tracked.txt') git('rm','--cached','--','tracked.txt') assert (root/'tracked.txt').is_file() assert 'tracked.txt' not in git('ls-files').splitlines() git('cat-file','-e','HEAD:tracked.txt') print('tracked file still staged; cached removal keeps disk copy and old commit') git('add','-f','--','build/output.bin') assert 'build/output.bin' in git('ls-files').splitlines() print('force-add bypasses ignore rules') 第一项确认,排除 build/ 会使子文件例外失效。修改后的规则只排除目录里面的条目,因此允许 keep.txt,同时继续排除 output.bin。安静模式检查的是实际忽略结果,退出码一表示被检查的路径没有被忽略。这里不是根据文件名或规则长相猜测,而是把预期写成了真实 Git 命令的返回值断言。 ...

十一月 15, 2027 · 1 分钟 · XB Field Notes
同一仓库驻留在自己目录里的第二个检出。

git worktree 一次开出多个工作区

加急修复不必把尚未完成的功能开发从当前目录赶走。关联工作树可以从选定的发布提交开始,而原目录保留已暂存修改、未暂存编辑和未跟踪文件。这个流程真正成功,需要同时证明修复在正确基线上通过验证,以及原工作完整保留。仅仅多创建一个目录,既不能证明基线正确,也不能证明所有未完成状态没有受到影响。 先选基线,再开另一个目录 检查当前工作树,并确定需要修复的准确发布版本。一个方便使用的分支名称,不一定就是线上部署的提交。如果必须刷新远端跟踪引用,应获取指定远端之后,再记录实际解析到的对象。获取更新的是公共仓库引用,并不是只影响输入命令的那个目录;多个工作树之间仍然共享这部分状态。 创建时明确指定目录、新分支名称和起点。新的热修复分支可以避免与其他目录已检出的分支竞争。如果只是检查、不准备提交,分离工作树也可能足够。不要因为计划使用的名称已经存在,就直接采用重置分支或强制选项;先检查已有工作树和引用,弄清它们是否保存了其他工作,不能让方便命名变成覆盖状态的理由。 新工作树检出的是所选起点中的已提交内容,不会复制原目录的功能改动或未跟踪笔记。这有利于在没有功能开发偶然修复或新增依赖的情况下复现发布问题,但也意味着编辑器缓冲区、本地配置文件等不会自动出现在新目录。需要哪些文件,应依据真正的测试环境准备,而不能假定整个开发上下文被一起复制过去。 在真实热修复目录复现与验证 先确认新 HEAD,再运行针对性复现,随后修改代码,并在同一热修复目录运行相同检查和相关的更广范围测试。在原功能目录跑出的成功,只能证明另一份检出的情况。测试输出应关联准确的提交、目录和配置,让后来审阅的人能够知道观察结果对应哪一套输入,而不是只看到一个没有上下文的成功提示。 放在各目录内部的构建产物是不同文件,但外部状态仍可能竞争。两个服务可能绑定相同端口,两套测试可能写同一个数据库,共享缓存也需要正确的区分键。工作树不是容器、虚拟机或安全边界;运行不熟悉分支的构建命令前,应检查它会执行什么以及访问哪些资源,不能把目录不同解释成任何副作用都彼此隔离。 只暂存预期修复,并在提交前检查差异。新提交通过公共对象库对其他目录可见,但原功能分支和索引不需要因此移动。保留原状态是要求时,应明确复核这些事实,而不是靠“工作树本来就独立”推断没有问题。提交在本地可见,也不意味着分支已经分发或修复已经部署;发布仍然是单独受授权约束的步骤。 坏掉的样子 目录名称可能像某个发布版本,实际 HEAD 却指向另一个位置。应核对解析后的版本,而不是信任文件夹标签。另一个常见错误,是把公共仓库配置当成当前工作树私有配置:为热修复调整的设置,可能影响其他工作树,即便那些目录里的文件完全没有变化。遇到差异时,应同时检查文件和共享配置,而不只比较源码。 结束前检查关联工作树的状态、未跟踪文件以及有价值的忽略产物。普通状态不会显示被忽略的文件,受跟踪差异为空,也不证明目录里没有值得保存的数据。需要保留的日志或制品,应在移除之前移到目录之外。还要停止继续使用该目录的进程,因为 Git 状态干净并不说明服务已经退出,也不说明没有文件仍然被打开。 对确认过的路径使用普通移除命令。遇到未提交或普通未跟踪工作时,它会拒绝,而不会默认这些内容可丢弃;应调查拒绝原因,不要习惯性补上强制参数。移除删除的是关联检出和注册信息,不是分支本身。清理失效管理信息则针对目录已经消失的记录,不是删除活动工作区的替代命令,两种操作不能混用。 快速验证 使用 Python 3 与 Git 2.28 或更新版本运行下面实验。临时仓库排除个人 Git 配置和继承的 Git 路由,关闭钩子及签名,没有远端。功能目录故意同时保留三种未完成工作;热修复测试先失败,再经过一行修复后成功。全部内容都是合成测试数据,不会改变当前项目的编辑、分支或发布状态。 import os import subprocess import sys import tempfile from pathlib import Path 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 tempfile.TemporaryDirectory(prefix="git-hotfix-demo-") as directory: root = Path(directory) feature, hotfix = root / "feature", root / "hotfix" feature.mkdir() def git(tree, *args, check=True): return subprocess.run( ["git", "-c", "user.name=Hotfix Example", "-c", "user.email=hotfix@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=tree, env=env, text=True, capture_output=True, check=check, ) def test(): return subprocess.run([sys.executable, "-B", "-c", "from calc import double; assert double(4) == 8"], cwd=hotfix, text=True, capture_output=True) git(feature, "init", "-q", "-b", "feature") (feature / "calc.py").write_text("def double(n):\n return n * 2 + 1\n") (feature / "draft.txt").write_text("base\n") git(feature, "add", "calc.py", "draft.txt") git(feature, "commit", "-qm", "release baseline") baseline = git(feature, "rev-parse", "HEAD").stdout.strip() (feature / "draft.txt").write_text("staged\n") git(feature, "add", "draft.txt") (feature / "draft.txt").write_text("unstaged\n") (feature / "idea.txt").write_text("untracked idea\n") git(feature, "worktree", "add", "-qb", "hotfix", str(hotfix), baseline) print("clean-baseline:", git(hotfix, "rev-parse", "HEAD").stdout.strip() == baseline and (hotfix / "draft.txt").read_text() == "base\n" and not (hotfix / "idea.txt").exists()) broken = test() print("regression-reproduced:", broken.returncode != 0 and "AssertionError" in broken.stderr) (hotfix / "calc.py").write_text("def double(n):\n return n * 2\n") fixed = test() assert fixed.returncode == 0 and not fixed.stderr, fixed git(hotfix, "add", "calc.py") git(hotfix, "commit", "-qm", "fix double") fixed_commit = git(hotfix, "rev-parse", "HEAD").stdout.strip() print("feature-preserved:", git(feature, "rev-parse", "HEAD").stdout.strip() == baseline and git(feature, "symbolic-ref", "--short", "HEAD").stdout.strip() == "feature" and git(feature, "show", ":draft.txt").stdout == "staged\n" and (feature / "draft.txt").read_text() == "unstaged\n" and (feature / "idea.txt").read_text() == "untracked idea\n") artifact = hotfix / "review-notes.txt" artifact.write_text("reviewed\n") refused = git(feature, "worktree", "remove", str(hotfix), check=False) print("dirty-removal-refused:", refused.returncode != 0 and artifact.exists()) saved = root / "saved-review.txt" artifact.rename(saved) git(feature, "worktree", "remove", str(hotfix)) print("checkout-removed-branch-kept:", not hotfix.exists() and git(feature, "rev-parse", "hotfix").stdout.strip() == fixed_commit) print("review-notes-preserved:", saved.read_text() == "reviewed\n") 六个观察都应为真。热修复从已提交的基线内容开始,能够复现故障,并保留原功能分支、索引文本、磁盘文本和未跟踪笔记。新的未跟踪评审记录会阻止移除,移到关联目录之外后,普通移除才成功,热修复分支及保存的记录仍在。只有这些检查完成之后,外层临时目录管理器才有意清理整套演示数据。 ...

十一月 6, 2027 · 2 分钟 · XB Field Notes
对历史做二分查找,找出首次让测试失败的提交。

git bisect 收敛第一个坏提交

二分排查回答一个明确的历史问题:在选定历史中,某个可复现观察从哪里开始由正常变成异常?它不会仅凭给出一个提交,就自动确定根因。搜索依赖正确的端点标签和一致的中间判断。应把报告出的边界当作需要检查的证据,再确认边界两侧行为,并解释哪项变化造成了结果差异,不能把一行定位输出当作完整故障分析。 依据观察选择端点,而不是日期 至少一个已知正常提交和一个已知异常提交可以建立搜索;更多正常版本还可以进一步约束提交图。最近经过验证的正常版本,通常比不必要地选择很古老的基线更能缩小范围。日期不是搜索顺序:Git 使用祖先关系,作者时间戳不一定反映提交在图上的位置。因此,“最早”应放在这次历史边界内解释,而不是直接按墙上时间排序。 赋予标签之前,应在两个端点运行同一份复现。明确什么算目标症状,包括边界输入与相关配置。只检查任意命令有没有失败,可能把产品回归与解释器缺失、服务不可用或构建环境不兼容混在一起。一个无法测试的版本,其结论是未知,而不是自动判坏;如果输入条件没有保持一致,后续再精细的搜索也无法补回缺失的可比性。 使用干净、可丢弃的检出,或专门的关联工作树。二分通常会把 HEAD 切到选定版本并更新文件,因此应提前保存未完成工作,不能认为搜索会自动创建备份。探针和必需证据也应放在不会随历史检出而消失或改变的地方,让测试程序自身的变化与被测源码版本分开,避免把旧脚本缺失当成源码故障。 每次都判断同一个性质 观察当前版本之后,按复现结果标记正常或异常。某个版本由于与目标症状无关的原因无法分类时,可以跳过。靠近转换点的跳过可能使答案不唯一,此时应报告剩余候选集合,而不是把最先显示的对象包装成已经确定的责任提交。未知本身也是有效调查结果,不能为了快速收束而强行填上好坏标签。 简单二分直觉要求所选历史中的转换足够稳定。问题先引入、后修复、再重新引入时,对这个判断条件来说已经不是单一的单调时间线;随机失败的测试也会制造类似的误导。观察与假设矛盾时,应细化问题或区间,而不是把不一致结果塞进方便解释的故事。搜索算法不会替你决定这些不一致到底意味着什么。 合并较多时,搜索全部祖先还是只沿第一父级集成线,会改变问题本身。第一父级搜索可能找到集成分支因某次合并而出现故障的点,而不是主题分支最初引入代码的提交。同样,限制文件路径可能排除真正相关的配置或调用方变化。只有这些限制符合调查目标时才应用,不能只为了让候选数量看起来更少而缩小问题。 坏掉的样子 一个错误标签就可能排除真正边界。结束当前排查之前保存搜索日志;发现判断有误时,保留原证据,修正决定记录,重置会话后重放修正日志。重放恢复的是判断过程,不会重新执行测试,也不能证明当时的环境现在仍然有效。若探针或依赖发生变化,需要重新验证对应结论,不能把可重放等同于已复测。 找到候选后,在候选及相关父级运行准确的复现。普通线性边界应表现为候选异常、父级正常;合并提交则可能需要检查多个父级和组合行为。还应阅读补丁及受影响调用链。即便候选只是暴露了更早就存在的问题,也仍需解释因果关系,不能只凭它改变了症状,就把全部根因归给那次提交。 不要为了验证猜测,就立刻在共享或有未提交修改的分支撤销候选。修正实验应放在受控检出中,并单独审阅和测试。记录发现之后,二分重置会结束会话,通常回到开始时的检出;还要核对当前分支和版本,而不能把最后用于诊断的历史位置误认成日常开发分支,随后把新工作提交到错误位置。 快速验证 使用 Python 3 与 Git 2.28 或更新版本运行下面实验。临时仓库使用示例身份,关闭钩子及签名,排除个人 Git 配置和路由变量,没有远端。七个版本中,第三号版本把数值上限从包含等号改成不包含等号;探针专门检查边界输入十。这样测试的是一个明确行为,而不是根据提交消息或预设对象标识给版本贴标签。 import os import subprocess import sys import tempfile from pathlib import Path 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 tempfile.TemporaryDirectory(prefix="git-bisect-boundary-") as directory: root = Path(directory) repo, log = root / "repo", root / "bisect.log" repo.mkdir() def git(*args): return subprocess.run( ["git", "-c", "user.name=Boundary Example", "-c", "user.email=boundary@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, env=env, text=True, capture_output=True, check=True, ) def verdict(): result = subprocess.run([sys.executable, "-B", "-c", "from limit import accepts; print(int(accepts(10)))"], cwd=repo, text=True, capture_output=True, check=True) assert result.stdout in ("0\n", "1\n"), result return "good" if result.stdout == "1\n" else "bad" git("init", "-q", "-b", "main") commits = [] for number in range(7): operator = "<" if number >= 3 else "<=" (repo / "limit.py").write_text(f"def accepts(n):\n return n {operator} 10\n") (repo / "revision.txt").write_text(f"{number}\n") git("add", "limit.py", "revision.txt") git("commit", "-qm", f"revision {number}") commits.append(git("rev-parse", "HEAD").stdout.strip()) endpoints = [] for revision in (commits[0], "main"): git("switch", "-q", "--detach", revision) endpoints.append(verdict()) git("switch", "-q", "main") print("endpoints-confirmed:", endpoints == ["good", "bad"]) git("bisect", "start", commits[-1], commits[0]) try: while len(git("rev-list", "refs/bisect/bad", "--not", "--glob=refs/bisect/good-*").stdout.splitlines()) > 1: git("bisect", verdict()) found = git("rev-parse", "refs/bisect/bad").stdout.strip() log.write_text(git("bisect", "log").stdout) print("boundary-identified:", found == commits[3]) finally: git("bisect", "reset") checks = [] for revision in (found, f"{found}^"): git("switch", "-q", "--detach", revision) checks.append(verdict()) git("switch", "-q", "main") print("candidate-and-parent-retested:", checks == ["bad", "good"]) try: git("bisect", "replay", str(log)) print("log-replays-boundary:", git("rev-parse", "refs/bisect/bad").stdout.strip() == found) finally: git("bisect", "reset") print("original-branch-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main" and git("rev-parse", "HEAD").stdout.strip() == commits[-1] and not git("status", "--porcelain").stdout) 五个判断都应为真。在这份线性且没有跳过的样例中,循环逐次记录正常或异常,直到只剩一个候选。最终定位第三号版本,独立复测确认候选在边界输入上失败、父级成功。重放保存的日志可以到达同一结果,但不会再次执行探针;最后恢复 main。补丁也解释了观察:上限处的相等情况被排除,正是这次回归的具体来源。 ...

十月 24, 2027 · 2 分钟 · XB Field Notes
项目重建网络,同时保留命名数据卷和稳定服务名。

Compose 项目把资源声明与外部输入组合起来

Docker Compose 把服务声明转化成以项目管理的容器、网络和存储连接。文件描述有用关系:服务运行哪个镜像,配置如何传入,以及使用哪些存储或网络。复现整套环境仍依赖镜像可用性、变量值、宿主能力和既有数据等外部输入。文件进入版本控制,并不自动固定这些输入;只有把它们识别清楚,声明才真正可审阅、可解释。 坏掉的样子 调用 shell 提供不同变量时,相同 Compose 文本可能解析成不同结果。环境文件参与插值,但不等于其中所有值都会自动注入每个容器。服务级 environment 声明和 env_file 设置有各自作用。应同时检查渲染配置和实际运行服务,不能因为某个文件里写了一个值,就认定它必然是最终生效值。 网络地址也是常见陷阱。Compose 网络中的服务可以通过服务名解析彼此,并使用容器端口;发布到宿主的端口属于另一条访问路径。如果客户端容器请求 localhost,通常请求会回到客户端自身,而不是兄弟 API。硬编码容器 IP 也会让替换更脆弱,稳定服务名更适合表达本来就属于服务关系的目标。 项目生命周期不意味着所有资源具有相同寿命。普通 down 删除项目容器和网络,命名卷则通常保留,除非明确要求删除卷。重建网络会改变其身份,也可能改变分配地址。数据仍在,不代表旧网络仍在;新容器已经创建,也不代表连接的数据卷为空。排查重建行为时,需要分别观察这些资源。 快速验证 使用宿主 Python、Docker Compose 和固定 Python 镜像运行脚本。实测为 Compose 5.1.1、Docker 29.3.1、Linux arm64。生成的 Compose 文档使用 YAML 接受的 JSON 语法,不需要额外 Python 解析器,并采用唯一项目名。API 只发布到宿主回环地址,由引擎自动分配可用端口: ...

八月 18, 2027 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现一次聚焦提交的审阅过程。

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

有用的提交应当回答仓库行为发生了什么变化。它包含实现、能够说明该行为的验证,以及使用变化所需的文档。这里的“小”指修改理由有明确边界,而不是行数越少越好。例如,修正免运费门槛,需要同时修改计算函数和对应的回归断言;旁边正在编写的无关笔记应当继续留在工作区。把这些边界说明白,之后的读者才知道哪些变化必须一起存在。 暂存区决定这次留下的检查点 Git 区分工作区、暂存区和已经提交的文件树。编辑文件只会改变工作区;执行添加操作,才会把当时的内容放进暂存区。此后继续编辑,不会自动更新已经暂存的版本。因此,在当前目录运行测试成功,并不能证明即将提交的快照也能通过:测试可能依赖尚未暂存的修复、导入或配置。审阅时应看暂存差异,包括新增和删除;部分暂存造成实质差异时,应验证选中的那个快照。 同一个目录里有两件独立工作时,暂存区可以帮助保留边界。只选择当前目的需要的文件或代码块,同时检查它依赖的测试数据、导入、配置和说明是否齐全。只有几行实现,却遗漏必需的数据库结构变更,并不是可用的独立检查点。反过来,一次机械重命名即使涉及很多文件,只要变化理由一致,仍然可以是一个完整事项。文件数量不能替代对依赖关系的判断。 坏掉的样子 混合提交把本来无关的决策绑在一起。回退界面变化时,可能顺带移除藏在同一提交里的可靠性修复。随意按文件拆开也未必更好:如果第一个提交已经调用新接口,第二个提交才定义接口,中间状态就不能运行。应该保留有意义的中间版本;如果一组修改必须按顺序共同审阅,就明确写出依赖,而不是用很多碎片制造可独立交付的假象。 小提交能够减少审阅者需要同时理解的信息,却不保证反向操作没有冲突,更不保证生产回退安全。后续代码可能已经依赖之前的行为,数据库迁移也可能改变 Git 无法恢复的数据。revert 会记录一个新的反向变化,与中间发生的修改仍可能冲突。即使文本合并成功,也要重新检查最终行为及部署条件,不能把命令成功当作业务状态已经恢复。 二分定位同样需要历史版本上可靠的好坏判定。某个中间提交无法编译,不等于目标缺陷就是在这里引入的。跳过无法测试的版本,有时只能得到多个可能候选。保持检查点可运行有助于定位,但提交够小、标题够清楚,都不能修好一个会随机失败的测试,也不能补齐历史环境缺失的依赖。 快速验证 把下面代码保存为 small-commits.py,安装 Git 后执行 python3 small-commits.py。脚本创建临时仓库,隔离全局 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
以观察、判断和行动三个面板呈现文档变化的验证过程。

把文档变成可以审阅的代码资产

指南即使和应用放在同一仓库,也可能写着一个从未正确运行过的命令。把文档按代码方式管理,提供的是发现和审阅这类错误的条件:可比较的源文件、版本历史、明确的维护者、页面预览,以及随变更一起运行的检查。它不会自动让文字正确。真正有价值的联系,是把文档中的说法和读者实际使用的那个实现对应起来,并留下能够支持该说法的证据。 为读者使用的版本写清契约 说明应当对应具体的软件版本。开发分支上复制出来的命令,可能使用已发布安装包尚未提供的参数。页面需要让读者看见版本选择;可行时,把实现变化与受影响的操作说明放在同一次审阅中。重点检查默认值、权限、输入文件、预期输出以及完成后需要清理的内容。静态页面构建成功,只覆盖其中一部分,并不能证明操作步骤适合当前软件。 对于项目已经存在权威来源的机械事实,应当复用该来源。依据配置结构生成参数表,可以减少手工维护同一事实的次数,但生成器也可能遗漏默认值、错误理解类型,或者列出最终安装包不包含的选项。因此,生成器自身的变化仍需审阅,重要示例也应与实际构建出的程序核对。自动生成的参考表与面向人的解释解决不同问题,任何一方都不能替另一方保证正确。 坏掉的样子 常见假象是把示例另抄一份写进测试,测试继续通过,而页面上显示的命令已经发生漂移。文档格式允许时,应直接提取展示内容执行;或者让页面和验证共同来自同一个经过检查的输入。还要避免教读者使用没有正式说明的缩写参数:解析器今天可能接受某个前缀,明天增加另一个同前缀参数后,原命令就变得不明确。完整选项通常更容易稳定说明。 执行目录也是契约的一部分。作者电脑里已有的文件、缓存的依赖或继承的凭据,都可能让步骤只在作者环境里成功。可复现实验应创建自己的无害输入,并说明工具前提。不能因为代码块在 Markdown 中,就用发布凭据执行任意贡献者提交的 shell 片段;文档检查只应拥有示例实际需要的权限。权限隔离不能替代结果检查,但能让验证边界更明确。 链接、拼写和格式检查都有价值,却不能判断步骤是否解决了读者的问题。命令可能退出成功,结果顺序却不对,或者修改了另一份文件。应当核对有业务意义的输出,保留对使用前提、失败恢复和目标读者起点的人工审阅。否则,一排绿色检查只能证明某些规则满足了,不能证明整篇指南已经成为可信操作手册。 快速验证 将下面代码保存为 docs-code.py,执行 python3 docs-code.py。示例只使用 Python 标准库和临时目录。它先创建键顺序为 z、a 的 JSON 输入,再创建一份简短 Markdown 指南。指南中的 fenced 命令故意使用不存在的 --sort-fields 参数,让错误发生在真正展示给读者的命令上,而不是另写一段与文档无关的失败测试。 from pathlib import Path from tempfile import TemporaryDirectory import difflib import json import re import shlex import subprocess import sys with TemporaryDirectory() as directory: root = Path(directory) (root / 'sample.json').write_text('{"z":2,"a":1}\n') doc = root / 'guide.md' old = '# Format JSON\n\n```sh\npython3 -m json.tool --sort-fields sample.json\n```\n' doc.write_text(old) def execute_document(): command = re.search(r'```sh\n([^\n]+)\n```', doc.read_text())[1] args = shlex.split(command) assert args[:3] == ['python3', '-m', 'json.tool'] return subprocess.run([sys.executable, *args[1:]], cwd=root, capture_output=True, text=True) failed = execute_document() assert failed.returncode == 2 and 'unrecognized arguments' in failed.stderr doc.write_text(old.replace('--sort-fields', '--sort-keys')) passed = execute_document() assert passed.returncode == 0 assert list(json.loads(passed.stdout)) == ['a', 'z'] delta = ''.join(difflib.unified_diff(old.splitlines(True), doc.read_text().splitlines(True), fromfile='before/guide.md', tofile='after/guide.md')) print(json.dumps({'broken_command_exit': failed.returncode, 'corrected_command_exit': passed.returncode, 'parsed_keys': list(json.loads(passed.stdout)), 'doc_diff': delta})) 脚本提取代码块里的单行命令,不经过 shell,而是拆成参数列表,调用当前解释器实际提供的 JSON 工具。第一次运行应以状态码二退出,并报告无法识别的参数。随后把文档修正为 --sort-keys,再次提取并执行。第二次必须成功,解析输出后的键顺序也必须变成 a、z;只看退出码无法证明排序要求已经满足。 ...

三月 29, 2027 · 1 分钟 · XB Field Notes