reporesolve.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import os
  2. import re
  3. import shlex
  4. import subprocess
  5. import time
  6. from gitutil import GIT_CMD, _git_toplevel
  7. from session_state import with_locked_state
  8. RES_NONE = 0
  9. RES_CWD = 1
  10. RES_COMMAND = 2
  11. RES_SHA_SCAN = 3
  12. RES_TOUCHED_PATHS = 4
  13. RES_HINT = 5
  14. COMMIT_SUBCOMMANDS = {("git", "commit"), ("gt", "create"), ("gt", "modify")}
  15. PUSH_SUBCOMMANDS = {("git", "push"), ("gt", "submit")}
  16. SCAN_SKIP_DIRS = {
  17. "node_modules", ".venv", "venv", "__pycache__", ".tox", "dist",
  18. "build", "target", ".cache", ".git", "vendor", "site-packages",
  19. }
  20. SCAN_MAX_DEPTH = int(os.environ.get("SG_REPO_SCAN_MAX_DEPTH", "3"))
  21. SCAN_MAX_REPOS = int(os.environ.get("SG_REPO_SCAN_MAX_REPOS", "64"))
  22. SCAN_BUDGET_S = float(os.environ.get("SG_REPO_SCAN_BUDGET_S", "4"))
  23. _SEPARATORS = frozenset(";&|()\n")
  24. _SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
  25. _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
  26. _PREFIX_WORDS = frozenset(("env", "time", "exec", "command", "nohup"))
  27. def _abs(base, p):
  28. try:
  29. p = os.path.expanduser(p)
  30. if not os.path.isabs(p):
  31. p = os.path.join(base or os.getcwd(), p)
  32. return os.path.normpath(p)
  33. except (TypeError, ValueError, OSError):
  34. return None
  35. def _is_sep(tok):
  36. return bool(tok) and set(tok) <= _SEPARATORS
  37. def _tokenize(command):
  38. if os.sep == "\\":
  39. command = command.replace("\\", "\\\\")
  40. try:
  41. lex = shlex.shlex(command, posix=True, punctuation_chars=";&|()")
  42. lex.whitespace_split = True
  43. return list(lex)
  44. except ValueError:
  45. try:
  46. return command.replace("&&", " && ").replace(";", " ; ").split()
  47. except Exception:
  48. return []
  49. def dirs_from_command(command, cwd, subcommands=None):
  50. if not isinstance(command, str) or not command.strip():
  51. return []
  52. tokens = _tokenize(command)
  53. out = []
  54. cur = cwd or ""
  55. i = 0
  56. n = len(tokens)
  57. at_start = True
  58. while i < n:
  59. t = tokens[i]
  60. if _is_sep(t):
  61. at_start = True
  62. i += 1
  63. continue
  64. if at_start and (_ENV_ASSIGN_RE.match(t) or t in _PREFIX_WORDS):
  65. i += 1
  66. continue
  67. if at_start and t in ("cd", "pushd"):
  68. if i + 1 < n and not _is_sep(tokens[i + 1]) and tokens[i + 1] not in ("-",) \
  69. and not tokens[i + 1].startswith("-"):
  70. nxt = _abs(cur, tokens[i + 1])
  71. if nxt:
  72. cur = nxt
  73. i += 2
  74. else:
  75. i += 1
  76. at_start = False
  77. continue
  78. prog = os.path.basename(t) if t else t
  79. if at_start and prog in ("git", "gt"):
  80. j = i + 1
  81. cdir = cur
  82. gdir = None
  83. wtree = None
  84. if prog == "git":
  85. while j < n and not _is_sep(tokens[j]):
  86. a = tokens[j]
  87. if a == "-C" and j + 1 < n:
  88. cdir = _abs(cdir, tokens[j + 1]) or cdir
  89. j += 2
  90. continue
  91. if a == "-c" and j + 1 < n:
  92. j += 2
  93. continue
  94. if a.startswith("--git-dir="):
  95. gdir = _abs(cdir, a.split("=", 1)[1])
  96. j += 1
  97. continue
  98. if a == "--git-dir" and j + 1 < n:
  99. gdir = _abs(cdir, tokens[j + 1])
  100. j += 2
  101. continue
  102. if a.startswith("--work-tree="):
  103. wtree = _abs(cdir, a.split("=", 1)[1])
  104. j += 1
  105. continue
  106. if a == "--work-tree" and j + 1 < n:
  107. wtree = _abs(cdir, tokens[j + 1])
  108. j += 2
  109. continue
  110. if a.startswith("-"):
  111. j += 1
  112. continue
  113. break
  114. sub = tokens[j] if j < n and not _is_sep(tokens[j]) else None
  115. if subcommands is None or (prog, sub) in subcommands:
  116. cand = wtree
  117. if not cand and gdir:
  118. cand = os.path.dirname(gdir) if os.path.basename(gdir) == ".git" else gdir
  119. if not cand:
  120. cand = cdir
  121. if cand:
  122. out.append(cand)
  123. i = j + 1 if j < n else j
  124. at_start = False
  125. continue
  126. at_start = False
  127. i += 1
  128. return list(dict.fromkeys(d for d in out if d))
  129. def toplevel_from_command(command, cwd, subcommands=None, cwd_root=None):
  130. cwd_abs = _abs(None, cwd) if cwd else None
  131. for d in dirs_from_command(command, cwd, subcommands):
  132. if cwd_root and d == cwd_abs:
  133. return cwd_root
  134. try:
  135. if os.path.isdir(d):
  136. top = _git_toplevel(d)
  137. if top:
  138. return top
  139. except OSError:
  140. continue
  141. return None
  142. def scan_roots(cwd):
  143. roots = []
  144. for r in (cwd, os.environ.get("CLAUDE_PROJECT_DIR")):
  145. if r and os.path.isdir(r):
  146. a = os.path.abspath(r)
  147. if a not in roots:
  148. roots.append(a)
  149. return roots
  150. def iter_git_repos(roots, max_depth=None, max_repos=None, deadline=None):
  151. max_depth = SCAN_MAX_DEPTH if max_depth is None else max_depth
  152. max_repos = SCAN_MAX_REPOS if max_repos is None else max_repos
  153. seen = set()
  154. for root in roots or []:
  155. try:
  156. if not root or not os.path.isdir(root):
  157. continue
  158. root = os.path.abspath(root)
  159. except OSError:
  160. continue
  161. base_depth = root.rstrip(os.sep).count(os.sep)
  162. for dirpath, dirnames, filenames in os.walk(root):
  163. if deadline is not None and time.monotonic() > deadline:
  164. return
  165. if ".git" in dirnames or ".git" in filenames:
  166. try:
  167. key = os.path.realpath(dirpath)
  168. except OSError:
  169. key = dirpath
  170. if key not in seen:
  171. seen.add(key)
  172. yield dirpath
  173. if len(seen) >= max_repos:
  174. return
  175. depth = dirpath.rstrip(os.sep).count(os.sep) - base_depth
  176. if depth >= max_depth:
  177. dirnames[:] = []
  178. else:
  179. dirnames[:] = [
  180. d for d in dirnames
  181. if d not in SCAN_SKIP_DIRS and not d.startswith(".")
  182. ]
  183. def repo_containing_commit(sha, roots, budget_s=None):
  184. if not isinstance(sha, str) or not _SHA_RE.match(sha):
  185. return None
  186. budget_s = SCAN_BUDGET_S if budget_s is None else budget_s
  187. deadline = time.monotonic() + budget_s
  188. for repo in iter_git_repos(roots, deadline=deadline):
  189. try:
  190. r = subprocess.run(
  191. [*GIT_CMD, "cat-file", "-e", f"{sha}^{{commit}}"],
  192. cwd=repo, capture_output=True, timeout=3,
  193. )
  194. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  195. continue
  196. if r.returncode == 0:
  197. return _git_toplevel(repo) or repo
  198. return None
  199. def repos_from_paths(paths, cwd=None, limit=200):
  200. counts = {}
  201. order = []
  202. cache = {}
  203. for p in list(paths or [])[:limit]:
  204. if not isinstance(p, str) or not p:
  205. continue
  206. ap = p if os.path.isabs(p) else _abs(cwd, p)
  207. if not ap:
  208. continue
  209. d = os.path.dirname(ap)
  210. while d and not os.path.isdir(d):
  211. parent = os.path.dirname(d)
  212. if parent == d:
  213. break
  214. d = parent
  215. if not d:
  216. continue
  217. if d in cache:
  218. top = cache[d]
  219. else:
  220. try:
  221. top = _git_toplevel(d) if os.path.isdir(d) else None
  222. except OSError:
  223. top = None
  224. cache[d] = top
  225. if top:
  226. if top not in counts:
  227. order.append(top)
  228. counts[top] = counts.get(top, 0) + 1
  229. return sorted(order, key=lambda t: (-counts[t], order.index(t)))
  230. def save_repo_hint(session_id, repo_root):
  231. if not session_id or not repo_root:
  232. return
  233. def _save(state):
  234. state["repo_root_hint"] = repo_root
  235. try:
  236. with_locked_state(session_id, _save)
  237. except Exception:
  238. pass
  239. def load_repo_hint(session_id):
  240. if not session_id:
  241. return None
  242. try:
  243. hint = with_locked_state(session_id, lambda s: s.get("repo_root_hint"))
  244. except Exception:
  245. return None
  246. if isinstance(hint, str) and hint and os.path.isdir(hint):
  247. top = _git_toplevel(hint)
  248. if top:
  249. return top
  250. return None
  251. _UNSET = object()
  252. def resolve_repo_root(cwd, command=None, subcommands=None, sha=None,
  253. touched_paths=None, session_id=None, cwd_root=_UNSET):
  254. if cwd_root is _UNSET:
  255. cwd_root = _git_toplevel(cwd) if cwd else None
  256. if command:
  257. top = toplevel_from_command(command, cwd, subcommands, cwd_root)
  258. if top and top != cwd_root:
  259. return top, RES_COMMAND
  260. if cwd_root:
  261. return cwd_root, RES_CWD
  262. if touched_paths:
  263. tops = repos_from_paths(touched_paths, cwd)
  264. if tops:
  265. return tops[0], RES_TOUCHED_PATHS
  266. if sha:
  267. top = repo_containing_commit(sha, scan_roots(cwd))
  268. if top:
  269. return top, RES_SHA_SCAN
  270. hint = load_repo_hint(session_id)
  271. if hint:
  272. return hint, RES_HINT
  273. return None, RES_NONE