22
33from __future__ import annotations
44
5+ import os
56import subprocess
67from collections .abc import Iterable
78from dataclasses import dataclass
@@ -72,11 +73,15 @@ def run(self, args: list[str], cwd: Path) -> str:
7273 text = True ,
7374 timeout = self ._timeout ,
7475 check = False ,
76+ # Deterministic English errors regardless of host locale.
77+ env = {** os .environ , "LC_ALL" : "C" },
7578 )
7679 except FileNotFoundError as exc :
7780 raise GitCommandError ("git binary not found on PATH." ) from exc
7881 except subprocess .TimeoutExpired as exc :
7982 raise GitCommandError (f"git { ' ' .join (args )} timed out." ) from exc
83+ except UnicodeDecodeError as exc :
84+ raise GitCommandError (f"git { ' ' .join (args )} output was not valid UTF-8." ) from exc
8085 if proc .returncode != 0 :
8186 err = proc .stderr .strip ()
8287 if "not a git repository" in err :
@@ -85,17 +90,25 @@ def run(self, args: list[str], cwd: Path) -> str:
8590 return proc .stdout
8691
8792
93+ def _dir_match (posix : str , dirname : str ) -> bool :
94+ """True when dirname is the path itself or any parent directory in it."""
95+ return posix == dirname or posix .startswith (dirname + "/" ) or f"/{ dirname } /" in f"/{ posix } "
96+
97+
8898def matches_any (path : str , patterns : Iterable [str ]) -> bool :
89- """Gitignore-flavored match: dir prefixes, bare basenames, else fnmatch."""
99+ """Gitignore-flavored match: dir prefixes, bare basenames, else fnmatch.
100+
101+ Directory patterns (`dist/`, `dist/*`) match at any depth, so monorepo
102+ `packages/ui/dist/` output is filtered just like root-level `dist/`.
103+ """
90104 posix = PurePosixPath (path ).as_posix ()
91105 name = PurePosixPath (path ).name
92106 for pat in patterns :
93107 if pat .endswith ("/*" ):
94- prefix = pat [:- 1 ]
95- if posix == prefix [:- 1 ] or posix .startswith (prefix ):
108+ if _dir_match (posix , pat [:- 2 ]):
96109 return True
97110 elif pat .endswith ("/" ):
98- if posix == pat [:- 1 ] or posix . startswith ( pat ):
111+ if _dir_match ( posix , pat [:- 1 ]):
99112 return True
100113 elif "/" not in pat :
101114 if fnmatchcase (name , pat ):
@@ -174,18 +187,91 @@ def _parse_name_status(output: str) -> dict[str, tuple[FileStatus, str | None]]:
174187 if len (parts ) != 3 :
175188 raise GitCommandError (f"cannot parse rename/copy row: { line !r} ." )
176189 _ , old , new = parts
177- statuses [new ] = (status , old )
190+ statuses [_decode_quoted ( new ) ] = (status , _decode_quoted ( old ) )
178191 else :
179192 if len (parts ) != 2 :
180193 raise GitCommandError (f"cannot parse status row: { line !r} ." )
181- statuses [parts [1 ]] = (status , None )
194+ statuses [_decode_quoted ( parts [1 ]) ] = (status , None )
182195 return statuses
183196
184197
185- def _strip_git_prefix (path : str ) -> str :
186- """Strip git's a//b/ prefixes and optional C-style quoting."""
198+ def _split_header_fallback (rest : str ) -> tuple [str | None , str | None ]:
199+ """Split `a/X b/Y` across quoted/unquoted combinations on either side.
200+
201+ Our git flags pin the prefixes, so Y always begins `b/` or `"b/`, and
202+ rfind lets the true boundary win over interior lookalikes. Rename/copy
203+ metadata and ---/+++ re-keying backstop anything stranger still.
204+ """
205+ if rest .endswith ('"' ):
206+ idx = rest .rfind (' "b/' )
207+ if idx == - 1 :
208+ return None , None
209+ return _strip_git_prefix (rest [:idx ]), _strip_git_prefix (rest [idx + 1 :])
210+ idx = rest .rfind (" b/" )
211+ if idx < 2 :
212+ return None , None
213+ return _strip_git_prefix (rest [:idx ]), _strip_git_prefix (rest [idx + 1 :])
214+
215+
216+ def _unescape_c_quoting (text : str ) -> str :
217+ """Decode git's C-style path escapes (`\\ \\ `, `\\ "`, `\\ n`, `\\ t`, octal).
218+
219+ Only meaningful on paths git quoted; unquoted output (e.g. `-z --numstat`)
220+ is never munged and must pass through untouched. Octal runs decode as
221+ UTF-8 bytes because git escapes non-ASCII per byte, not per character.
222+ """
223+ out : list [str ] = []
224+ raw = bytearray ()
225+
226+ def flush_octal () -> None :
227+ if raw :
228+ out .append (bytes (raw ).decode ("utf-8" , errors = "surrogateescape" ))
229+ raw .clear ()
230+
231+ i , n = 0 , len (text )
232+ while i < n :
233+ ch = text [i ]
234+ if ch != "\\ " or i + 1 >= n :
235+ flush_octal ()
236+ out .append (ch )
237+ i += 1
238+ continue
239+ nxt = text [i + 1 ]
240+ if nxt in "01234567" :
241+ digits = text [i + 1 : i + 4 ]
242+ length = 0
243+ while length < 3 and length < len (digits ) and digits [length ] in "01234567" :
244+ length += 1
245+ raw .extend (int (digits [:length ], 8 ).to_bytes (1 , "big" ))
246+ i += 1 + length
247+ continue
248+ flush_octal ()
249+ if nxt == "n" :
250+ out .append ("\n " )
251+ elif nxt == "t" :
252+ out .append ("\t " )
253+ elif nxt == "\\ " :
254+ out .append ("\\ " )
255+ elif nxt == '"' :
256+ out .append ('"' )
257+ else :
258+ out .append (ch )
259+ out .append (nxt )
260+ i += 2
261+ flush_octal ()
262+ return "" .join (out )
263+
264+
265+ def _decode_quoted (path : str ) -> str :
266+ """Remove git's C-style quoting when present; bare paths pass through."""
187267 if len (path ) >= 2 and path .startswith ('"' ) and path .endswith ('"' ):
188- path = path [1 :- 1 ]
268+ return _unescape_c_quoting (path [1 :- 1 ])
269+ return path
270+
271+
272+ def _strip_git_prefix (path : str ) -> str :
273+ """Strip git's a//b/ prefixes, unescaping C-style quoting when present."""
274+ path = _decode_quoted (path )
189275 if path .startswith (("a/" , "b/" )):
190276 return path [2 :]
191277 return path
@@ -202,11 +288,13 @@ def _parse_patch(output: str) -> dict[str, str]:
202288 def flush () -> None :
203289 nonlocal current , minus
204290 if current is not None or minus is not None :
291+ # Rename/copy lines carry bare paths (never a//b/ prefixed), so
292+ # decode quoting only; stripping would eat real a//b/ directories.
205293 for cl in chunk :
206294 if cl .startswith (("rename to " , "copy to " )):
207- current = _strip_git_prefix (cl .split (" " , 2 )[2 ].rstrip ("\n " ))
295+ current = _decode_quoted (cl .split (" " , 2 )[2 ].rstrip ("\n " ))
208296 elif cl .startswith (("rename from " , "copy from " )):
209- minus = _strip_git_prefix (cl .split (" " , 2 )[2 ].rstrip ("\n " ))
297+ minus = _decode_quoted (cl .split (" " , 2 )[2 ].rstrip ("\n " ))
210298 key = current if current is not None and current != "/dev/null" else minus
211299 if key is not None :
212300 if key not in chunks :
@@ -224,13 +312,9 @@ def flush() -> None:
224312 minus = _strip_git_prefix (rest [2 :mid ])
225313 current = _strip_git_prefix (rest [mid + 3 :])
226314 else :
227- halves = rest .rsplit (" " , 1 )
228- minus = _strip_git_prefix (halves [0 ].strip ()) if len (halves ) == 2 else None
229- current = _strip_git_prefix (halves [1 ].strip ()) if len (halves ) == 2 else None
315+ minus , current = _split_header_fallback (rest )
230316 else :
231- halves = rest .rsplit (" " , 1 )
232- minus = _strip_git_prefix (halves [0 ].strip ()) if len (halves ) == 2 else None
233- current = _strip_git_prefix (halves [1 ].strip ()) if len (halves ) == 2 else None
317+ minus , current = _split_header_fallback (rest )
234318 chunk = [line ]
235319 elif current is None and minus is None :
236320 continue
@@ -248,6 +332,7 @@ def flush() -> None:
248332 for prev in text .splitlines ():
249333 if prev .startswith ("--- " ):
250334 key = _strip_git_prefix (prev [4 :].strip ())
335+ break
251336 else :
252337 key = plus
253338 break
0 commit comments