---
date: 2026-05-11
tags: [git, patch-id, ops-script, bug, off-by-one]
---

# `git diff-tree --stdin` 静默丢弃最后一行（没有 trailing newline）

## 场景

写 `scripts/ops/sweep-remote-stale.py` 的 L2 patch-id 等价检测：

```python
shas = ["a32f0cf6...", "7658145d..."]
proc = subprocess.run(
    ["git", "diff-tree", "-p", "--stdin"],
    input="\n".join(shas),  # ← 注意没有末尾 newline
    capture_output=True, text=True,
)
# 然后 proc.stdout | git patch-id --stable
```

输入是 `"sha1\nsha2"`（两个 SHA + 中间一个 newline，末尾**没有** newline）。
预期：输出 2 个 commit 的 diff，2 行 patch-id。
实际：只输出第 1 个 commit 的 diff，**第 2 行 SHA 被静默丢弃**。

## 不直观的事

`git diff-tree --stdin` 把 stdin 当成"一系列**行**"读，**每行必须以 newline 结尾**。
最后一行没有 newline → 被视为"不完整"，直接丢。**没有 stderr 警告、没有非零退出码**。

```bash
# 重现 bug
$ printf 'a32f0cf6\n7658145d' | git diff-tree -p --stdin | git patch-id --stable
1e79f57572504c5cd... a32f0cf6d51ddef...      # 只有第 1 个

# 修复：末尾加 newline
$ printf 'a32f0cf6\n7658145d\n' | git diff-tree -p --stdin | git patch-id --stable
1e79f57572504c5cd... a32f0cf6d51ddef...
bbcc02054bd29b4ee... 7658145d3c441bd...      # 两个都有
```

## 触发 + 后果链

```python
diff = run_git(["log", "...", "--format=%H"])     # 带 trailing \n
shas = diff.strip().splitlines()                  # .strip() 去掉了 trailing \n
input_str = "\n".join(shas)                       # 中间 N-1 个 \n，没尾 \n
# → diff-tree 只处理 N-1 个 commit
# → patch-id 只输出 N-1 行
# → returned list 是 N-1 个 patch-id，少了一个
```

L2 等价检测代码：
```python
if ids is not None and ids and all(i in develop_patch_ids for i in ids):
    return "L2: patch-id equivalent"
```

- 分支只有 1 个 commit → ids 变成空 list → `and ids` 短路 → L2 返回 None → 被当作"未被吸收"保留（**漏报**）
- 分支有 N>1 个 commit → ids 是 N-1 个，每个都在 develop → L2 返回 True（**可能误报**：万一漏掉的那个其实有真新内容）

实战影响（FFOA 项目）：W20 sweep 一开跑漏了 18 个真 zombie（都是单 commit 的 squash zombie）。
修复后 baseline 1094→1095，新发现 18 条可删分支。

## 解法

**任何 `git diff-tree --stdin` 输入都要末尾加 `\n`**：

```python
proc = subprocess.run(
    ["git", "diff-tree", "-p", "--stdin"],
    input="\n".join(shas) + "\n",   # ← 关键
    ...
)
```

同样的坑在用 `rev_list.strip()` 后喂 stdin 时也会触发——`.strip()` 把 trailing
newline 去掉了，要补回去：
```python
rev_list = run_git(["rev-list", ...]).strip()
proc = subprocess.run(["...", "diff-tree", "-p", "--stdin"], input=rev_list + "\n", ...)
```

## 顺手加 `--no-merges`

`git diff-tree -p --stdin` 对 merge commit 输出"combined diff"而 `git patch-id` 不
一定能稳定处理。`git log -p --no-merges | git patch-id` 在 ad hoc 验证里被发现是
最稳的形式。脚本里相应加 `--no-merges` 到 `rev-list` 和 `git log` 调用。

## 验证

```bash
# 修复后 W20 实跑
$ python3 scripts/ops/sweep-remote-stale.py | grep "baseline:"
  baseline: 1095 patch-ids   # 修复前 1094，差 1 = 之前丢掉的最老 commit

$ python3 scripts/ops/sweep-remote-stale.py | grep "Results:"
Results: assigned=6 orphan=13 kept=27   # 修复前 3+8（同样 epoch 应是 3+8+18=29 vs 27+19=46 总匹配）
```

## 适用范围

任何把 `--stdin` 喂给 git 命令的代码——`git update-ref --stdin` /
`git cat-file --batch` / `git diff-tree --stdin` / `git rev-list --stdin` 都按
行解析输入。**统一规则**：input 字符串末尾确保有 `\n`，否则最后一项被丢。

## 关联

- 触发本 bug 的 PR：#304（sweep-remote-stale.py 首版）
- 修复 commit：紧跟在本 learning 之后
- 实战发现路径：用户主动指出"`chore/iam-rules-rollout-2026-04-25` 还领先两个提交"
  → 手动算 patch-id 发现脚本只看到 1 个 → 复现 stdin 边界 → 定位根因
- 没造成损失：本次实跑 8 个被删的都是 L1 fast-forward 路径，不走 patch-id，纯安全
