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

130 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""
Safely push a rewritten history to GitHub.
Checks repo visibility with `gh repo view`, warns about public forks, and uses
--force-with-lease first, falling back to --force only when the remote ref is
stale because of the local rewrite. Never adds --no-verify.
Usage:
uv run --with gitpython scripts/safe_push.py \
--repo /path/to/repo --remote origin --branch main
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
def get_remote_repo_info(repo_path: Path) -> dict | None:
"""Use gh repo view (cwd inference) to get visibility and fork count.
We do NOT pass a remote name here. `gh repo view` resolves the repository
from the current working directory's git remote, which is the only reliable
way to avoid guessing owner/repo from a remote name or URL string.
"""
result = subprocess.run(
["gh", "repo", "view", "--json", "visibility,isPrivate,stargazerCount,forkCount,owner,name"],
cwd=str(repo_path),
capture_output=True,
text=True, errors="replace",
check=False,
)
if result.returncode != 0:
print(f"gh repo view failed: {result.stderr}", file=sys.stderr)
return None
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(f"Could not parse gh output: {result.stdout}", file=sys.stderr)
return None
required_keys = ["visibility", "isPrivate", "stargazerCount", "forkCount", "owner", "name"]
missing = [k for k in required_keys if k not in data]
if missing:
print(
f"gh repo view returned incomplete metadata (missing: {', '.join(missing)}). "
"Aborting rather than using fallback values.",
file=sys.stderr,
)
return None
return data
def push(repo_path: Path, remote: str, branch: str) -> None:
"""Push with --force-with-lease; fall back to --force on stale-info error."""
lease_cmd = ["git", "-C", str(repo_path), "push", remote, branch, "--force-with-lease"]
result = subprocess.run(lease_cmd, capture_output=True, text=True, errors="replace", check=False)
if result.returncode == 0:
print("Push succeeded with --force-with-lease.")
return
# If the lease failed because the remote ref is stale (common after a local
# rewrite where the old remote ref no longer exists locally), fall back to
# plain --force exactly once. This is the only allowed fallback.
if "stale info" in result.stderr.lower():
print("--force-with-lease reported stale info (expected after local rewrite).")
print("Falling back to --force exactly once...")
force_cmd = ["git", "-C", str(repo_path), "push", remote, branch, "--force"]
result2 = subprocess.run(force_cmd, capture_output=True, text=True, errors="replace", check=False)
if result2.returncode == 0:
print("Push succeeded with --force.")
return
print(result2.stdout)
print(result2.stderr, file=sys.stderr)
sys.exit(1)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Safely push rewritten history to GitHub.")
parser.add_argument("--repo", required=True, help="Path to the git repository.")
parser.add_argument("--remote", required=True, help="Remote name (usually origin).")
parser.add_argument("--branch", required=True, help="Branch to push (usually main).")
parser.add_argument(
"--yes",
action="store_true",
help="Confirm you accept the risks of force-pushing.",
)
args = parser.parse_args()
repo_path = Path(args.repo).resolve()
if not (repo_path / ".git").is_dir():
print(f"Not a git repository: {repo_path}", file=sys.stderr)
sys.exit(1)
info = get_remote_repo_info(repo_path)
if not info:
print("Could not verify repository visibility. Push aborted.", file=sys.stderr)
sys.exit(1)
print("=" * 60)
print(f"Repository: {info['owner']['login']}/{info['name']}")
print(f"Visibility: {info['visibility']} (private={info['isPrivate']})")
print(f"Stars: {info['stargazerCount']}")
print(f"Forks: {info['forkCount']}")
print("=" * 60)
if not info["isPrivate"] and info["forkCount"] > 0:
print(
f"WARNING: This is a PUBLIC repository with {info['forkCount']} forks. "
"Force-pushing will not update those forks.",
file=sys.stderr,
)
if not args.yes:
print("Re-run with --yes to confirm the push.", file=sys.stderr)
sys.exit(1)
push(repo_path, args.remote, args.branch)
if __name__ == "__main__":
main()