2121"""
2222from __future__ import annotations
2323
24+ import base64
2425import contextlib
2526import json
2627import os
2728import shutil
29+ import stat
2830import subprocess
2931import sys
3032import tempfile
@@ -266,40 +268,84 @@ def build_local_snapshot(
266268# ---------------------------------------------------------------------------
267269
268270
271+ GIT_TIMEOUT_SECONDS = 300
272+
273+
274+ def _git_auth_env (token : str ) -> dict [str , str ]:
275+ """Credentials for github.com passed through git's environment-config
276+ channel (``GIT_CONFIG_COUNT``/``KEY``/``VALUE``) instead of the clone URL,
277+ so the token never appears in argv, the process list, ``.git/config`` or
278+ error messages."""
279+ basic = base64 .b64encode (f"x-access-token:{ token } " .encode ()).decode ()
280+ return {
281+ "GIT_CONFIG_COUNT" : "1" ,
282+ "GIT_CONFIG_KEY_0" : "http.https://github.com/.extraheader" ,
283+ "GIT_CONFIG_VALUE_0" : f"AUTHORIZATION: basic { basic } " ,
284+ }
285+
286+
287+ def _redact (text : str , secrets : tuple [str , ...]) -> str :
288+ for secret in secrets :
289+ if secret :
290+ text = text .replace (secret , "***" )
291+ return text
292+
293+
269294def _run_git (
270295 args : list [str ],
271296 * ,
272297 cwd : Path | None = None ,
273298 env : dict [str , str ] | None = None ,
274299 check : bool = True ,
300+ redact : tuple [str , ...] = (),
275301) -> subprocess .CompletedProcess :
276302 """Thin wrapper around git subprocess for testability and uniform
277303 error handling. Uses ``GIT_TERMINAL_PROMPT=0`` to fail fast on auth
278- issues instead of hanging for input."""
304+ issues instead of hanging for input, and a timeout so a stalled
305+ network clone cannot block the job indefinitely. Any value in
306+ *redact* is masked in error messages."""
279307 full_env = {** os .environ , "GIT_TERMINAL_PROMPT" : "0" }
280308 if env :
281309 full_env .update (env )
282- result = subprocess .run (
283- ["git" , * args ],
284- cwd = str (cwd ) if cwd else None ,
285- env = full_env ,
286- capture_output = True ,
287- text = True ,
288- )
310+ try :
311+ result = subprocess .run (
312+ ["git" , * args ],
313+ cwd = str (cwd ) if cwd else None ,
314+ env = full_env ,
315+ capture_output = True ,
316+ text = True ,
317+ timeout = GIT_TIMEOUT_SECONDS ,
318+ )
319+ except subprocess .TimeoutExpired :
320+ raise RemoteSnapshotError (
321+ _redact (f"git { ' ' .join (args )} timed out after { GIT_TIMEOUT_SECONDS } s" , redact )
322+ ) from None
289323 if check and result .returncode != 0 :
290324 raise RemoteSnapshotError (
291- f"git { ' ' .join (args )} failed (rc={ result .returncode } ): "
292- f"{ result .stderr .strip () or result .stdout .strip ()} "
325+ _redact (
326+ f"git { ' ' .join (args )} failed (rc={ result .returncode } ): "
327+ f"{ result .stderr .strip () or result .stdout .strip ()} " ,
328+ redact ,
329+ )
293330 )
294331 return result
295332
296333
297- def _scrub_token_from_remote (repo_dir : Path , owner : str , slug : str ) -> None :
298- """After clone, replace the token-bearing remote URL with a clean one.
299- Defense-in-depth: prevents the token from leaking via subsequent ``git
300- remote -v`` calls or accidental ``.git/config`` exposure."""
301- clean_url = f"https://github.com/{ owner } /{ slug } .git"
302- _run_git (["remote" , "set-url" , "origin" , clean_url ], cwd = repo_dir , check = False )
334+ def _force_remove_readonly (func , path , _exc ) -> None :
335+ """rmtree error hook: git pack files are read-only on Windows, so clear
336+ the bit and retry once."""
337+ try :
338+ os .chmod (path , stat .S_IWRITE )
339+ func (path )
340+ except OSError :
341+ pass
342+
343+
344+ def _rmtree (path : Path ) -> None :
345+ if sys .version_info >= (3 , 12 ):
346+ shutil .rmtree (path , onexc = _force_remove_readonly )
347+ else : # pragma: no cover - older interpreters
348+ shutil .rmtree (path , onerror = _force_remove_readonly )
303349
304350
305351@contextlib .contextmanager
@@ -317,7 +363,7 @@ def _sparse_clone(
317363 token = gh_token .strip ()
318364 if not token :
319365 raise RemoteSnapshotError (f"gh_token is empty for { owner } /{ slug } " )
320- clone_url = f"https://x-access-token: { token } @ github.com/{ owner } /{ slug } .git"
366+ clone_url = f"https://github.com/{ owner } /{ slug } .git"
321367 clone_args = [
322368 "clone" ,
323369 "--filter=blob:none" ,
@@ -327,8 +373,7 @@ def _sparse_clone(
327373 if branch :
328374 clone_args += ["--branch" , branch ]
329375 clone_args += [clone_url , str (tmp )]
330- _run_git (clone_args )
331- _scrub_token_from_remote (tmp , owner , slug )
376+ _run_git (clone_args , env = _git_auth_env (token ), redact = (token ,))
332377 # `--no-cone` lets us mix file-level patterns (AGENTS.md, CLAUDE.md)
333378 # with directory patterns (skills, rules). Cone-mode is the default
334379 # since git 2.35 and rejects file patterns. The deprecation warning
@@ -340,7 +385,7 @@ def _sparse_clone(
340385 )
341386 yield tmp
342387 finally :
343- shutil . rmtree (tmp , ignore_errors = True )
388+ _rmtree (tmp )
344389
345390
346391def build_remote_snapshot (
0 commit comments