mirror of
https://github.com/daymade/claude-code-skills.git
synced 2026-09-14 16:15:21 +08:00
776b760c10
代码(审阅 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>
215 lines
6.9 KiB
Python
215 lines
6.9 KiB
Python
#!/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()
|