backup_on_write.py 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """PreToolUse hook (GLOBAL): 在 AI 写/改已存在的源码文件前,自动备份为 <file>.bak。
  4. 项目无关:仅依据 stdin 传入的文件路径做备份,可在任意项目中生效。
  5. 设计原则(对齐本项目 Rules 铁律:无 Git,.bak 是唯一回滚手段):
  6. - 非阻塞:任何情况下都 exit 0,绝不影响原工具执行。
  7. - 仅对「已存在」的源码类文件生效;新文件、二进制、敏感目录一律跳过。
  8. - 若 .bak 已存在且与当前文件内容一致,则跳过,避免无意义覆盖/抖动。
  9. - 多版本备份:保留最近 MAX_VERSIONS 个历史版本(.bak / .bak.1 / .bak.2),
  10. 每次备份前轮转旧版本,超出上限的自动删除。
  11. - 备份失败也静默放行(exit 0),不让备份逻辑阻断用户工作。
  12. """
  13. import sys
  14. import os
  15. import json
  16. import shutil
  17. import filecmp
  18. # 保留最近 N 个历史版本(.bak / .bak.1 / .bak.2)
  19. MAX_VERSIONS = 10
  20. # 仅备份源码/配置类文件,避免 .bak 污染图片等二进制
  21. BACKUP_EXT = {
  22. ".php", ".html", ".htm", ".js", ".css", ".json", ".py", ".txt",
  23. ".md", ".conf", ".xml", ".sql", ".inc", ".tpl", ".vue", ".ts",
  24. ".tsx", ".jsx", ".yml", ".yaml", ".ini", ".htaccess",
  25. }
  26. # 跳过这些目录(对齐 Rules §7.3 排查/忽略清单)
  27. EXCLUDE_DIRS = {
  28. "backup", "vendor", "runtime", "node_modules", "uploads",
  29. ".git", ".codebuddy", "ecachefiles",
  30. }
  31. def main():
  32. try:
  33. raw = sys.stdin.read()
  34. if not raw.strip():
  35. return 0
  36. data = json.loads(raw)
  37. except Exception:
  38. return 0
  39. tool_input = data.get("tool_input", {}) or {}
  40. path = (
  41. tool_input.get("filePath")
  42. or tool_input.get("file_path")
  43. or tool_input.get("path")
  44. or ""
  45. )
  46. if not path:
  47. return 0
  48. path = os.path.abspath(path)
  49. norm = path.replace("\\", "/").lower()
  50. # 不备份 .bak / .broken 自身
  51. if norm.endswith(".bak") or norm.endswith(".broken"):
  52. return 0
  53. # 跳过敏感/忽略目录
  54. if any(part in EXCLUDE_DIRS for part in norm.split("/")):
  55. return 0
  56. # 仅备份源码/配置类扩展名
  57. if os.path.splitext(norm)[1] not in BACKUP_EXT:
  58. return 0
  59. # 新文件无需回滚
  60. if not os.path.isfile(path):
  61. return 0
  62. bak = path + ".bak"
  63. try:
  64. # 内容一致则跳过(无需备份也无需轮转)
  65. if os.path.exists(bak) and filecmp.cmp(bak, path, shallow=False):
  66. return 0
  67. # 轮转旧备份:.bak.(N-1) → .bak.N,删除超出 MAX_VERSIONS 的
  68. # 从最旧开始处理,避免覆盖
  69. oldest = "%s.bak.%d" % (path, MAX_VERSIONS - 1)
  70. if os.path.exists(oldest):
  71. os.remove(oldest)
  72. for i in range(MAX_VERSIONS - 2, 0, -1):
  73. src = "%s.bak.%d" % (path, i)
  74. dst = "%s.bak.%d" % (path, i + 1)
  75. if os.path.exists(src):
  76. os.rename(src, dst)
  77. # .bak → .bak.1
  78. if os.path.exists(bak):
  79. os.rename(bak, "%s.bak.1" % path)
  80. # 备份当前文件
  81. shutil.copy2(path, bak)
  82. except Exception:
  83. return 0
  84. return 0
  85. if __name__ == "__main__":
  86. sys.exit(main())