侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 195 篇文章
  • 累计收到 7 条评论

git diff 看见改动时,退出码就是 1

2026-10-15 / 0 评论 / 7 阅读

git diff 看见改动时,退出码就是 1

命令行里敲下 git diff,终端出现红绿两行,脚本却没有因此失败。这件事很容易记反:看见差异,并不等于命令退出码是 1。默认的 git diff 会把补丁写到标准输出,但即使存在差异,退出状态仍是 0。要让差异变成状态码,得显式加 --exit-code,或者用 --quiet。

「diff 有输出」和「命令失败」很容易被连在一起。两者在终端里看着挨得很近,脚本判断的却是另一个通道。标准输出是给人看的补丁,退出码是给调用方看的结果。把这两个信号混为一谈,最常见的后果是 shell 检查把正常改动当成错误;反过来,只看命令成功,也可能漏掉本来想捕捉的变更。

这里还要分清比较的两端。未暂存改动出现在普通 git diff 里;执行 git add 后,它从这条命令的结果里消失,却会出现在 git diff --cached 中。差异没有消失,只是从「工作区对暂存区」挪到了「暂存区对最近一次提交」。自动化检查若只跑普通 git diff --exit-code,恰好在暂存之后执行,就会把暂存内容漏过去。要检查将进入提交的内容,--cached 不能省。

--exit-code 保留补丁输出,额外把有差异映射成状态 1;--quiet 同样用状态表达差异,但不打印补丁。前者适合还要查看内容的调用场景,后者适合只问「有没有变化」。如果把输出重定向掉,再读 $?,仍然可以判断,只是调试时少了补丁线索。两种写法表达的范围必须一致,免得一个比较工作区、另一个比较暂存区。

下面的脚本只在临时目录里初始化本地仓库,不读取当前项目,也不连远端。它先提交一份基线,再改动文件,分别检查默认命令和显式状态选项。暂存后还会再测一次,因为不带 --cached 的 git diff 比较的是工作区与暂存区,不会报告已经暂存的差异。

#!/usr/bin/env python3
"""Verify Git diff exit statuses in an isolated temporary repository."""

from pathlib import Path
import subprocess
import tempfile

def run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        ["git", *args],
        cwd=repo,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )

def expect(label: str, result: subprocess.CompletedProcess[str], status: int) -> None:
    if result.returncode != status:
        raise SystemExit(
            f"{label}: expected exit {status}, got {result.returncode}; "
            f"stderr={result.stderr.strip()!r}"
        )

def main() -> None:
    with tempfile.TemporaryDirectory(prefix="git-diff-exit-code-") as temp:
        repo = Path(temp)
        for args in (("init", "-q"), ("config", "user.name", "Verifier"),
                     ("config", "user.email", "verifier@example.invalid")):
            result = run(repo, *args)
            if result.returncode != 0:
                raise SystemExit(result.stderr.strip() or f"git {' '.join(args)} failed")

        tracked = repo / "note.txt"
        tracked.write_text("before\n", encoding="utf-8")
        result = run(repo, "add", "note.txt")
        if result.returncode != 0:
            raise SystemExit(result.stderr.strip() or "git add failed")
        result = run(repo, "commit", "-qm", "initial")
        if result.returncode != 0:
            raise SystemExit(result.stderr.strip() or "git commit failed")

        clean_default = run(repo, "diff")
        expect("git diff (clean)", clean_default, 0)
        if clean_default.stdout:
            raise SystemExit("clean git diff unexpectedly printed a patch")

        tracked.write_text("after\n", encoding="utf-8")
        changed_default = run(repo, "diff")
        expect("git diff (changed)", changed_default, 0)
        if "-before\n+after\n" not in changed_default.stdout:
            raise SystemExit("git diff did not print the expected changed lines")

        changed_exit_code = run(repo, "diff", "--exit-code")
        expect("git diff --exit-code (changed)", changed_exit_code, 1)
        if not changed_exit_code.stdout:
            raise SystemExit("git diff --exit-code did not print its patch")

        result = run(repo, "add", "note.txt")
        if result.returncode != 0:
            raise SystemExit(result.stderr.strip() or "git add failed")
        staged_default = run(repo, "diff", "--cached")
        expect("git diff --cached (staged)", staged_default, 0)
        if not staged_default.stdout:
            raise SystemExit("git diff --cached did not print the staged patch")

        staged_exit_code = run(repo, "diff", "--cached", "--exit-code")
        expect("git diff --cached --exit-code (staged)", staged_exit_code, 1)

        print("git diff (clean): exit 0, stdout empty")
        print("git diff (changed): exit 0, stdout has patch")
        print("git diff --exit-code (changed): exit 1")
        print("git diff --cached (staged): exit 0, stdout has patch")
        print("git diff --cached --exit-code (staged): exit 1")
        print("verification: passed")

if __name__ == "__main__":
    main()

脚本把提交身份只写进这个临时仓库的配置,不改全局 Git 设置;仓库和文件由 TemporaryDirectory 管理,运行结束后会清掉。每条 Git 命令都用参数数组交给 subprocess,没有经由 shell 再解释一次。这样测到的是 Git 进程本身的返回码,不会把引号展开、管道或当前目录里的状态混进结果。整个验证过程都在新建目录里完成,原项目的暂存区不会被碰到。对照结果来自真实 Git 进程的 returncode,而不是根据补丁文本猜状态。

shell 里需要按意图处理这个 1。把 git diff --exit-code 裸放在开启 set -e 的脚本中,有差异时会沿着非零退出路径停止;若差异本身是预期信号,就应把命令放进条件判断。这样写时,条件里的 1 表示发现差异;若还要区分 Git 自身报错,则应读取状态并单独处理,而不是把所有非零结果都当成「有改动」。

本机实跑结果如下,退出码也由脚本逐项断言,不是根据补丁内容推测:

git diff (clean): exit 0, stdout empty
git diff (changed): exit 0, stdout has patch
git diff --exit-code (changed): exit 1
git diff --cached (staged): exit 0, stdout has patch
git diff --cached --exit-code (staged): exit 1
verification: passed

脚本里捕获了标准输出,没有把补丁全文塞进日志,判断仍然检查了改动行确实存在。实际接进 shell 时,git diff --exit-code 返回 1 的含义是「发现差异」,不是 Git 执行故障;若开启 set -e,裸跑它可能让脚本提前退出。适合直接判断的写法是 if git diff --quiet; then ...; else ...; fi,但要先确定比较范围,已暂存内容应改用 git diff --cached --quiet。我更愿意明确写出这个范围,不拿默认值猜意图。

所以标题里的 1 有条件:git diff 看见改动时默认不是 1,显式启用退出码选项才是。把补丁输出留给人看,把 --exit-code 留给自动化判断,脚本行为就不必靠终端颜色来猜。

    🤞 分享