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,92 @@ 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` on the last ` b/`; prefixes are pinned by our git flags.
200+
201+ Correct for any spaces inside X or Y. Only a filename literally containing
202+ ` b/` defeats it; the rename/copy metadata and ---/+++ re-keying backstop
203+ even that pathological case.
204+ """
205+ if rest .startswith ('"' ):
206+ # Both paths quoted: "a/X" "b/Y" — split between the quoted strings.
207+ idx = rest .find ('" "' )
208+ if idx == - 1 :
209+ return None , None
210+ return _strip_git_prefix (rest [: idx + 1 ]), _strip_git_prefix (rest [idx + 1 :].strip ())
211+ idx = rest .rfind (" b/" )
212+ if idx < 2 :
213+ return None , None
214+ return _strip_git_prefix (rest [:idx ]), _strip_git_prefix (rest [idx + 1 :])
215+
216+
217+ def _unescape_c_quoting (text : str ) -> str :
218+ """Decode git's C-style path escapes (`\\ \\ `, `\\ "`, `\\ n`, `\\ t`, octal).
219+
220+ Only meaningful on paths git quoted; unquoted output (e.g. `-z --numstat`)
221+ is never munged and must pass through untouched. Octal runs decode as
222+ UTF-8 bytes because git escapes non-ASCII per byte, not per character.
223+ """
224+ out : list [str ] = []
225+ raw = bytearray ()
226+
227+ def flush_octal () -> None :
228+ if raw :
229+ out .append (bytes (raw ).decode ("utf-8" , errors = "surrogateescape" ))
230+ raw .clear ()
231+
232+ i , n = 0 , len (text )
233+ while i < n :
234+ ch = text [i ]
235+ if ch != "\\ " or i + 1 >= n :
236+ flush_octal ()
237+ out .append (ch )
238+ i += 1
239+ continue
240+ nxt = text [i + 1 ]
241+ if nxt in "01234567" :
242+ digits = text [i + 1 : i + 4 ]
243+ length = 0
244+ while length < 3 and length < len (digits ) and digits [length ] in "01234567" :
245+ length += 1
246+ raw .extend (int (digits [:length ], 8 ).to_bytes (1 , "big" ))
247+ i += 1 + length
248+ continue
249+ flush_octal ()
250+ if nxt == "n" :
251+ out .append ("\n " )
252+ elif nxt == "t" :
253+ out .append ("\t " )
254+ elif nxt == "\\ " :
255+ out .append ("\\ " )
256+ elif nxt == '"' :
257+ out .append ('"' )
258+ else :
259+ out .append (ch )
260+ out .append (nxt )
261+ i += 2
262+ flush_octal ()
263+ return "" .join (out )
264+
265+
266+ def _decode_quoted (path : str ) -> str :
267+ """Remove git's C-style quoting when present; bare paths pass through."""
187268 if len (path ) >= 2 and path .startswith ('"' ) and path .endswith ('"' ):
188- path = path [1 :- 1 ]
269+ return _unescape_c_quoting (path [1 :- 1 ])
270+ return path
271+
272+
273+ def _strip_git_prefix (path : str ) -> str :
274+ """Strip git's a//b/ prefixes, unescaping C-style quoting when present."""
275+ path = _decode_quoted (path )
189276 if path .startswith (("a/" , "b/" )):
190277 return path [2 :]
191278 return path
@@ -224,13 +311,9 @@ def flush() -> None:
224311 minus = _strip_git_prefix (rest [2 :mid ])
225312 current = _strip_git_prefix (rest [mid + 3 :])
226313 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
314+ minus , current = _split_header_fallback (rest )
230315 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
316+ minus , current = _split_header_fallback (rest )
234317 chunk = [line ]
235318 elif current is None and minus is None :
236319 continue
@@ -248,6 +331,7 @@ def flush() -> None:
248331 for prev in text .splitlines ():
249332 if prev .startswith ("--- " ):
250333 key = _strip_git_prefix (prev [4 :].strip ())
334+ break
251335 else :
252336 key = plus
253337 break
0 commit comments