Files
daymade 776b760c10 fix(github-sensitive-data-cleanup): 关闭 git 输出解码崩溃类 + 文档精确化(审阅轮 2) (#330)
代码(审阅 HIGH,已实跑复现):
- 全部 4 个脚本的 13 处 subprocess text=True 解码统一补 errors="replace"
  ——blob 通道(git grep, grep_all_commits)至今 strict 解码,GBK 编码
  源文件含命中行时 verify/scan 照样 UnicodeDecodeError 崩掉无报告;
  Lesson 9 的处方此前只落在 message 通道,现对该类整体闭环
- 验证:GBK 源文件含泄漏 → FAILED 且 blob+message 双通道各自定位
  commit hash(此前崩溃点);scan_repo 同仓 exit 0;GBK message 不崩

文档(审阅 LOW×2 + INFO×1):
- tooling_notes/SKILL.md/Lesson 7 的 git log 字面命令与实际
  --format=%H%x1f%B%x1e 不符,改为行为描述(hash 标注记录格式)
- tooling_notes/Lesson 9/CHANGELOG 补 commit_message_commits 前 10 截断说明
- CHANGELOG 修正 #328 的 --yes 修复面(Step 4 两块 + reference 节一块)
- Lesson 9 补记 blob 通道同类缺陷的发现与闭环 + 跨编码检测边界
  (errors=replace 防崩不让 UTF-8 pattern 命中 GBK 字节,归 Layer 4)

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 12:21:35 +08:00

215 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Backup and rewrite git history to remove sensitive strings.
Creates a git bundle backup, then runs `git filter-repo --replace-text`.
Usage:
uv run --with gitpython scripts/rewrite_history.py \
--repo /path/to/repo \
--replacements /tmp/sensitive-replacements.txt \
--backup /tmp/repo-backup.bundle
# also rewrite commit MESSAGES (entity leaks live there too — file content
# can be clean while the commit message still names the private entity):
uv run --with gitpython scripts/rewrite_history.py \
--repo /path/to/repo \
--replacements /tmp/sensitive-replacements.txt \
--message-replacements /tmp/sensitive-replacements.txt \
--backup /tmp/repo-backup.bundle
"""
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
def get_current_heads(repo_path: Path) -> dict:
"""Capture current local branch refs for the report."""
result = subprocess.run(
["git", "-C", str(repo_path), "show-ref", "--heads"],
capture_output=True,
text=True, errors="replace",
check=False,
)
heads = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) == 2:
heads[parts[1]] = parts[0]
return heads
def create_backup(repo_path: Path, backup_path: Path) -> None:
"""Create a git bundle backup of all refs and verify it."""
backup_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"git",
"-C",
str(repo_path),
"bundle",
"create",
str(backup_path),
"--all",
]
subprocess.run(cmd, check=True)
# 与 create 一样带 -C:从非 git 目录调用时 bundle verify 会因找不到
# 仓库而失败(错误信息指错方向),且 RuntimeError 要能被调用点接住
verify = subprocess.run(
["git", "-C", str(repo_path), "bundle", "verify", str(backup_path)],
capture_output=True,
text=True, errors="replace",
check=False,
)
if verify.returncode != 0:
raise RuntimeError(f"Backup verification failed: {verify.stderr}")
def check_clean_working_tree(repo_path: Path) -> None:
"""Abort if there are uncommitted changes or untracked files."""
result = subprocess.run(
["git", "-C", str(repo_path), "status", "--short"],
capture_output=True,
text=True, errors="replace",
check=False,
)
if result.stdout.strip():
print(
"Working tree is not clean. Commit, stash, or remove the following "
"before rewriting history:\n",
file=sys.stderr,
)
print(result.stdout, file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Rewrite repo history to remove sensitive strings.")
parser.add_argument("--repo", required=True, help="Path to the git repository.")
parser.add_argument("--replacements", required=True, help="Path to git-filter-repo replacements file.")
parser.add_argument(
"--message-replacements",
default=None,
help="Optional replacements file for commit MESSAGES "
"(git filter-repo --replace-message). File content and commit "
"messages leak differently; pass the same file to cover both.",
)
parser.add_argument("--backup", required=True, help="Path for the output git bundle backup.")
parser.add_argument(
"--yes",
action="store_true",
help="Confirm you have read the warnings and want to rewrite history.",
)
args = parser.parse_args()
repo_path = Path(args.repo).resolve()
replacements_path = Path(args.replacements).resolve()
message_replacements_path = (
Path(args.message_replacements).resolve() if args.message_replacements else None
)
backup_path = Path(args.backup).resolve()
if not (repo_path / ".git").is_dir():
print(f"Not a git repository: {repo_path}", file=sys.stderr)
sys.exit(1)
if not replacements_path.is_file():
print(f"Replacements file not found: {replacements_path}", file=sys.stderr)
sys.exit(1)
if message_replacements_path is not None and not message_replacements_path.is_file():
print(
f"Message replacements file not found: {message_replacements_path}",
file=sys.stderr,
)
sys.exit(1)
filter_repo_bin = shutil.which("git-filter-repo")
if not filter_repo_bin:
print(
"git-filter-repo not found on PATH. Install with `brew install git-filter-repo`.",
file=sys.stderr,
)
sys.exit(1)
version_check = subprocess.run(
[filter_repo_bin, "--version"],
capture_output=True,
text=True, errors="replace",
check=False,
)
if version_check.returncode != 0:
print(
f"git-filter-repo found but not executable: {version_check.stderr}",
file=sys.stderr,
)
sys.exit(1)
check_clean_working_tree(repo_path)
# Safety: confirm the user wants to proceed.
print("=" * 60)
print("DESTRUCTIVE OPERATION: This will rewrite git history.")
print(f"Repo: {repo_path}")
print(f"Backup will be written to: {backup_path}")
print(f"Replacements file: {replacements_path}")
print("=" * 60)
if not args.yes:
print("Re-run with --yes to confirm.", file=sys.stderr)
sys.exit(1)
old_heads = get_current_heads(repo_path)
print("Creating backup bundle...")
try:
create_backup(repo_path, backup_path)
except (subprocess.CalledProcessError, RuntimeError) as e:
print(f"Backup failed: {e}", file=sys.stderr)
sys.exit(1)
print(f"Backup created: {backup_path}")
print("Running git-filter-repo...")
cmd = [
filter_repo_bin,
"--force",
"--replace-text",
str(replacements_path),
]
if message_replacements_path is not None:
cmd += ["--replace-message", str(message_replacements_path)]
try:
subprocess.run(cmd, cwd=str(repo_path), check=True)
except subprocess.CalledProcessError as e:
print(f"History rewrite failed: {e}", file=sys.stderr)
print(f"Your backup is still available at: {backup_path}", file=sys.stderr)
sys.exit(1)
new_heads = get_current_heads(repo_path)
report = {
"repo": str(repo_path),
"backup": str(backup_path),
"replacements_file": str(replacements_path),
"old_heads": old_heads,
"new_heads": new_heads,
}
report_path = backup_path.with_suffix(".json")
with report_path.open("w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print("History rewrite complete.")
print(f"Report written to: {report_path}")
print("NEXT STEPS:")
print(" 1. Run verify_cleanup.py")
print(" 2. Run safe_push.py")
if __name__ == "__main__":
main()