加急修复不必把尚未完成的功能开发从当前目录赶走。关联工作树可以从选定的发布提交开始,而原目录保留已暂存修改、未暂存编辑和未跟踪文件。这个流程真正成功,需要同时证明修复在正确基线上通过验证,以及原工作完整保留。仅仅多创建一个目录,既不能证明基线正确,也不能证明所有未完成状态没有受到影响。

先选基线,再开另一个目录

检查当前工作树,并确定需要修复的准确发布版本。一个方便使用的分支名称,不一定就是线上部署的提交。如果必须刷新远端跟踪引用,应获取指定远端之后,再记录实际解析到的对象。获取更新的是公共仓库引用,并不是只影响输入命令的那个目录;多个工作树之间仍然共享这部分状态。

创建时明确指定目录、新分支名称和起点。新的热修复分支可以避免与其他目录已检出的分支竞争。如果只是检查、不准备提交,分离工作树也可能足够。不要因为计划使用的名称已经存在,就直接采用重置分支或强制选项;先检查已有工作树和引用,弄清它们是否保存了其他工作,不能让方便命名变成覆盖状态的理由。

新工作树检出的是所选起点中的已提交内容,不会复制原目录的功能改动或未跟踪笔记。这有利于在没有功能开发偶然修复或新增依赖的情况下复现发布问题,但也意味着编辑器缓冲区、本地配置文件等不会自动出现在新目录。需要哪些文件,应依据真正的测试环境准备,而不能假定整个开发上下文被一起复制过去。

在真实热修复目录复现与验证

先确认新 HEAD,再运行针对性复现,随后修改代码,并在同一热修复目录运行相同检查和相关的更广范围测试。在原功能目录跑出的成功,只能证明另一份检出的情况。测试输出应关联准确的提交、目录和配置,让后来审阅的人能够知道观察结果对应哪一套输入,而不是只看到一个没有上下文的成功提示。

放在各目录内部的构建产物是不同文件,但外部状态仍可能竞争。两个服务可能绑定相同端口,两套测试可能写同一个数据库,共享缓存也需要正确的区分键。工作树不是容器、虚拟机或安全边界;运行不熟悉分支的构建命令前,应检查它会执行什么以及访问哪些资源,不能把目录不同解释成任何副作用都彼此隔离。

只暂存预期修复,并在提交前检查差异。新提交通过公共对象库对其他目录可见,但原功能分支和索引不需要因此移动。保留原状态是要求时,应明确复核这些事实,而不是靠“工作树本来就独立”推断没有问题。提交在本地可见,也不意味着分支已经分发或修复已经部署;发布仍然是单独受授权约束的步骤。

坏掉的样子

目录名称可能像某个发布版本,实际 HEAD 却指向另一个位置。应核对解析后的版本,而不是信任文件夹标签。另一个常见错误,是把公共仓库配置当成当前工作树私有配置:为热修复调整的设置,可能影响其他工作树,即便那些目录里的文件完全没有变化。遇到差异时,应同时检查文件和共享配置,而不只比较源码。

结束前检查关联工作树的状态、未跟踪文件以及有价值的忽略产物。普通状态不会显示被忽略的文件,受跟踪差异为空,也不证明目录里没有值得保存的数据。需要保留的日志或制品,应在移除之前移到目录之外。还要停止继续使用该目录的进程,因为 Git 状态干净并不说明服务已经退出,也不说明没有文件仍然被打开。

对确认过的路径使用普通移除命令。遇到未提交或普通未跟踪工作时,它会拒绝,而不会默认这些内容可丢弃;应调查拒绝原因,不要习惯性补上强制参数。移除删除的是关联检出和注册信息,不是分支本身。清理失效管理信息则针对目录已经消失的记录,不是删除活动工作区的替代命令,两种操作不能混用。

快速验证

在 POSIX 系统上使用 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")
    (feature / ".gitignore").write_text("ignored.log\n")
    git(feature, "add", "calc.py", "draft.txt", ".gitignore")
    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)
    ignored = hotfix / "ignored.log"
    ignored.write_text("useful build log\n")
    print("ordinary-status-hides-ignored:", not git(hotfix, "status", "--porcelain").stdout
          and "ignored.log" in git(hotfix, "status", "--short", "--ignored").stdout)
    saved_log = root / "saved-build.log"
    ignored.rename(saved_log)
    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"
          and saved_log.read_text() == "useful build log\n")

七个观察都应为真。热修复从已提交的基线内容开始,能够复现故障,并保留原功能分支、索引文本、磁盘文本和未跟踪笔记。新的未跟踪评审记录会阻止移除,移到关联目录之外后,还检查一份被忽略的日志:普通状态不显示它,加入忽略项检查才可见。保存日志后,普通移除才成功,热修复分支、评审记录和日志仍在。只有这些检查完成之后,外层临时目录管理器才有意清理整套演示数据。

延伸阅读

来源

  • Git worktree 参考,显式创建、检出保护、移除和失效记录清理,已核验 2026-10-15。
  • Git status 参考,暂存与工作树差异,以及未跟踪和忽略文件,已核验 2026-10-15。