Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/line_ending.py: 35%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# line_ending.py -- Line ending conversion functions
2# Copyright (C) 2018-2018 Boris Feld <boris.feld@comet.ml>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
21r"""All line-ending related functions, from conversions to config processing.
23Line-ending normalization is a complex beast. Here is some notes and details
24about how it seems to work.
26The normalization is a two-fold process that happens at two moments:
28- When reading a file from the index and to the working directory. For example
29 when doing a ``git clone`` or ``git checkout`` call. This is called the
30 smudge filter (repository -> working tree).
31- When writing a file to the index from the working directory. For example
32 when doing a ``git add`` call. This is called the clean filter (working tree
33 -> repository).
35Note that when checking status (getting unstaged changes), whether or not
36normalization is done on write depends on whether or not the file in the
37working dir has also been normalized on read:
39- For autocrlf=true all files are always normalized on both read and write.
40- For autocrlf=input files are only normalized on write if they are newly
41 "added". Since files which are already committed are not normalized on
42 checkout into the working tree, they are also left alone when staging
43 modifications into the index.
45One thing to know is that Git does line-ending normalization only on text
46files. How does Git know that a file is text? We can either mark a file as a
47text file, a binary file or ask Git to automatically decides. Git has an
48heuristic to detect if a file is a text file or a binary file. It seems based
49on the percentage of non-printable characters in files.
51The code for this heuristic is here:
52https://git.kernel.org/pub/scm/git/git.git/tree/convert.c#n46
54Dulwich have an implementation with a slightly different heuristic, the
55`dulwich.patch.is_binary` function.
57The binary detection heuristic implementation is close to the one in JGit:
58https://github.com/eclipse/jgit/blob/f6873ffe522bbc3536969a3a3546bf9a819b92bf/org.eclipse.jgit/src/org/eclipse/jgit/diff/RawText.java#L300
60There is multiple variables that impact the normalization.
62First, a repository can contains a ``.gitattributes`` file (or more than one...)
63that can further customize the operation on some file patterns, for example:
65 \*.txt text
67Force all ``.txt`` files to be treated as text files and to have their lines
68endings normalized.
70 \*.jpg -text
72Force all ``.jpg`` files to be treated as binary files and to not have their
73lines endings converted.
75 \*.vcproj text eol=crlf
77Force all ``.vcproj`` files to be treated as text files and to have their lines
78endings converted into ``CRLF`` in working directory no matter the native EOL of
79the platform.
81 \*.sh text eol=lf
83Force all ``.sh`` files to be treated as text files and to have their lines
84endings converted into ``LF`` in working directory no matter the native EOL of
85the platform.
87If the ``eol`` attribute is not defined, Git uses the ``core.eol`` configuration
88value described later.
90 \* text=auto
92Force all files to be scanned by the text file heuristic detection and to have
93their line endings normalized in case they are detected as text files.
95Git also have a obsolete attribute named ``crlf`` that can be translated to the
96corresponding text attribute value.
98Then there are some configuration option (that can be defined at the
99repository or user level):
101- core.autocrlf
102- core.eol
104``core.autocrlf`` is taken into account for all files that doesn't have a ``text``
105attribute defined in ``.gitattributes``; it takes three possible values:
107 - ``true``: This forces all files on the working directory to have CRLF
108 line-endings in the working directory and convert line-endings to LF
109 when writing to the index. An explicit ``eol`` attribute still takes
110 precedence over it.
111 - ``input``: Quite similar to the ``true`` value but only applies the clean
112 filter, ie line-ending of new files added to the index will get their
113 line-endings converted to LF.
114 - ``false`` (default): No normalization is done.
116``core.eol`` is the top-level configuration to define the line-ending to use
117when applying the smudge filter. It takes three possible values:
119 - ``lf``: When normalization is done, force line-endings to be ``LF`` in the
120 working directory.
121 - ``crlf``: When normalization is done, force line-endings to be ``CRLF`` in
122 the working directory.
123 - ``native`` (default): When normalization is done, force line-endings to be
124 the platform's native line ending.
126One thing to remember is when line-ending normalization is done on a file, Git
127always normalize line-ending to ``LF`` when writing to the index.
129There are sources that seems to indicate that Git won't do line-ending
130normalization when a file contains mixed line-endings. I think this logic
131might be in text / binary detection heuristic but couldn't find it yet.
133Sources:
134- https://git-scm.com/docs/git-config#git-config-coreeol
135- https://git-scm.com/docs/git-config#git-config-coreautocrlf
136- https://git-scm.com/docs/gitattributes#_checking_out_and_checking_in
137- https://adaptivepatchwork.com/2012/03/01/mind-the-end-of-your-line/
138"""
140__all__ = [
141 "CRLF",
142 "LF",
143 "BlobNormalizer",
144 "CRLFAction",
145 "LineEndingFilter",
146 "TreeBlobNormalizer",
147 "check_safecrlf",
148 "convert_crlf_to_lf",
149 "convert_lf_to_crlf",
150 "get_clean_filter",
151 "get_clean_filter_autocrlf",
152 "get_smudge_filter",
153 "get_smudge_filter_autocrlf",
154 "line_ending_filter_for_action",
155 "normalize_blob",
156 "read_eol_config",
157 "resolve_crlf_action",
158]
160import logging
161import os
162from collections.abc import Callable, Mapping
163from enum import Enum
164from typing import TYPE_CHECKING, Any
166if TYPE_CHECKING:
167 from .config import Config
168 from .object_store import BaseObjectStore
170from .attrs import AttributeValue, GitAttributes, Pattern
171from .filters import FilterBlobNormalizer, FilterContext, FilterRegistry
172from .object_store import iter_tree_contents
173from .objects import Blob, ObjectID
174from .patch import is_binary
175from .wildmatch import MalformedPattern
177CRLF = b"\r\n"
178LF = b"\n"
180logger = logging.getLogger(__name__)
183def read_eol_config(config: "Config | None") -> tuple[str, bytes, bytes]:
184 """Read the line ending related settings from a configuration.
186 Returns: Tuple with the values of ``core.eol``, ``core.autocrlf`` and
187 ``core.safecrlf``
188 """
189 if config is None:
190 return "native", b"false", b"false"
192 try:
193 core_eol_raw = config.get("core", "eol")
194 except KeyError:
195 core_eol = "native"
196 else:
197 core_eol = (
198 core_eol_raw.decode("ascii")
199 if isinstance(core_eol_raw, bytes)
200 else str(core_eol_raw)
201 ).lower()
203 try:
204 autocrlf_raw = config.get("core", "autocrlf")
205 except KeyError:
206 autocrlf = b"false"
207 else:
208 autocrlf = (
209 autocrlf_raw.lower()
210 if isinstance(autocrlf_raw, bytes)
211 else str(autocrlf_raw).lower().encode("ascii")
212 )
214 try:
215 safecrlf_raw = config.get("core", "safecrlf")
216 except KeyError:
217 safecrlf = b"false"
218 else:
219 safecrlf = (
220 safecrlf_raw
221 if isinstance(safecrlf_raw, bytes)
222 else safecrlf_raw.encode("utf-8")
223 )
225 return core_eol, autocrlf, safecrlf
228class CRLFAction(Enum):
229 """The line ending conversion to apply to a path.
231 This mirrors git's ``enum convert_crlf_action`` in convert.c.
232 ``UNDEFINED``, ``TEXT`` and ``AUTO`` are intermediate states only;
233 :func:`resolve_crlf_action` always resolves them into one of the
234 others.
235 """
237 UNDEFINED = "undefined"
238 BINARY = "binary"
239 TEXT = "text"
240 TEXT_INPUT = "text-input"
241 TEXT_CRLF = "text-crlf"
242 AUTO = "auto"
243 AUTO_INPUT = "auto-input"
244 AUTO_CRLF = "auto-crlf"
247# Actions where git applies its text/binary heuristic rather than trusting the
248# attributes, and so refuses to touch files that already contain CR.
249_AUTO_ACTIONS = (CRLFAction.AUTO, CRLFAction.AUTO_INPUT, CRLFAction.AUTO_CRLF)
252def _crlf_action_from_attr(value: AttributeValue) -> CRLFAction:
253 """Translate a ``text`` (or obsolete ``crlf``) attribute into an action."""
254 if value is True:
255 return CRLFAction.TEXT
256 elif value is False:
257 return CRLFAction.BINARY
258 elif value == b"input":
259 return CRLFAction.TEXT_INPUT
260 elif value == b"auto":
261 return CRLFAction.AUTO
262 return CRLFAction.UNDEFINED
265def _text_eol_is_crlf(core_eol: str, autocrlf: bytes) -> bool:
266 """Decide the working tree line ending when only the config says so."""
267 if autocrlf == b"true":
268 return True
269 elif autocrlf == b"input":
270 return False
271 if core_eol == "crlf":
272 return True
273 if core_eol == "native":
274 return os.linesep == "\r\n"
275 return False
278def resolve_crlf_action(
279 attributes: Mapping[bytes, AttributeValue],
280 core_eol: str = "native",
281 autocrlf: bytes = b"false",
282) -> CRLFAction:
283 """Determine the line ending conversion for a path.
285 This follows ``convert_attrs`` in git's convert.c: the ``text`` attribute
286 (or the obsolete ``crlf`` one) selects whether conversion happens at all,
287 an explicit ``eol`` attribute then pins the working tree line ending, and
288 ``core.autocrlf``/``core.eol`` only apply where the attributes are silent.
290 Args:
291 attributes: Attributes matched for the path
292 core_eol: Value of ``core.eol``
293 autocrlf: Value of ``core.autocrlf``
295 Returns: The action to apply
296 """
297 action = _crlf_action_from_attr(attributes.get(b"text"))
298 if action == CRLFAction.UNDEFINED:
299 action = _crlf_action_from_attr(attributes.get(b"crlf"))
301 if action != CRLFAction.BINARY:
302 eol_attr = attributes.get(b"eol")
303 if action == CRLFAction.AUTO and eol_attr == b"lf":
304 action = CRLFAction.AUTO_INPUT
305 elif action == CRLFAction.AUTO and eol_attr == b"crlf":
306 action = CRLFAction.AUTO_CRLF
307 elif eol_attr == b"lf":
308 action = CRLFAction.TEXT_INPUT
309 elif eol_attr == b"crlf":
310 action = CRLFAction.TEXT_CRLF
312 if action in (CRLFAction.TEXT, CRLFAction.AUTO):
313 # Neither the attributes nor an "eol" attribute pinned the working
314 # tree line ending, so fall back to the configuration.
315 crlf = _text_eol_is_crlf(core_eol, autocrlf)
316 if action == CRLFAction.TEXT:
317 action = CRLFAction.TEXT_CRLF if crlf else CRLFAction.TEXT_INPUT
318 else:
319 action = CRLFAction.AUTO_CRLF if crlf else CRLFAction.AUTO_INPUT
320 if action == CRLFAction.UNDEFINED:
321 if autocrlf == b"true":
322 action = CRLFAction.AUTO_CRLF
323 elif autocrlf == b"input":
324 action = CRLFAction.AUTO_INPUT
325 else:
326 action = CRLFAction.BINARY
328 return action
331def line_ending_filter_for_action(
332 action: CRLFAction, safecrlf: bytes = b"false"
333) -> "LineEndingFilter | None":
334 """Build the filter implementing a resolved :class:`CRLFAction`.
336 Args:
337 action: Action as returned by :func:`resolve_crlf_action`
338 safecrlf: Value of ``core.safecrlf``
340 Returns: A filter, or None if the action converts nothing
341 """
342 if action == CRLFAction.BINARY:
343 return None
345 smudge: Callable[[bytes], bytes] | None = (
346 convert_lf_to_crlf
347 if action in (CRLFAction.TEXT_CRLF, CRLFAction.AUTO_CRLF)
348 else None
349 )
350 return LineEndingFilter(
351 clean_conversion=convert_crlf_to_lf,
352 smudge_conversion=smudge,
353 binary_detection=True,
354 safecrlf=safecrlf,
355 # Only the "auto" family leaves files with existing CRs alone; an
356 # explicit "text" attribute means the user has already decided.
357 require_lf_only=action in _AUTO_ACTIONS,
358 )
361class LineEndingFilter:
362 """Filter driver for line ending conversion.
364 Satisfies the :class:`dulwich.filters.FilterDriver` protocol structurally,
365 without inheriting from it, so that the line ending logic this module
366 exports can be imported from ``filters`` without a circular import.
367 """
369 def __init__(
370 self,
371 clean_conversion: Callable[[bytes], bytes] | None = None,
372 smudge_conversion: Callable[[bytes], bytes] | None = None,
373 binary_detection: bool = True,
374 safecrlf: bytes = b"false",
375 require_lf_only: bool = False,
376 ):
377 """Initialize LineEndingFilter.
379 Args:
380 clean_conversion: Conversion to apply on checkin
381 smudge_conversion: Conversion to apply on checkout
382 binary_detection: Skip files detected as binary
383 safecrlf: Value of ``core.safecrlf``
384 require_lf_only: Only smudge content whose line endings are all LF,
385 as git does for ``text=auto`` and ``core.autocrlf``
386 """
387 self.clean_conversion = clean_conversion
388 self.smudge_conversion = smudge_conversion
389 self.binary_detection = binary_detection
390 self.safecrlf = safecrlf
391 self.require_lf_only = require_lf_only
393 @classmethod
394 def from_config(
395 cls, config: "Config | None", for_text_attr: bool = False
396 ) -> "LineEndingFilter":
397 """Create a LineEndingFilter from git configuration.
399 Args:
400 config: Git configuration
401 for_text_attr: If True, always normalize on checkin (for text attribute)
403 Returns:
404 Configured LineEndingFilter instance
405 """
406 if config is None:
407 # Default filter
408 if for_text_attr:
409 # For text attribute: always normalize on checkin
410 return cls(
411 clean_conversion=convert_crlf_to_lf,
412 smudge_conversion=None,
413 binary_detection=True,
414 )
415 else:
416 # No config: no conversion
417 return cls()
419 core_eol, autocrlf, safecrlf = read_eol_config(config)
421 if for_text_attr:
422 # For text attribute: always normalize to LF on checkin
423 # Smudge behavior depends on core.eol and core.autocrlf
424 smudge_filter = get_smudge_filter(core_eol, autocrlf)
425 clean_filter: Callable[[bytes], bytes] | None = convert_crlf_to_lf
426 else:
427 # Normal autocrlf behavior
428 smudge_filter = get_smudge_filter(core_eol, autocrlf)
429 clean_filter = get_clean_filter(core_eol, autocrlf)
431 return cls(
432 clean_conversion=clean_filter,
433 smudge_conversion=smudge_filter,
434 binary_detection=True,
435 safecrlf=safecrlf,
436 )
438 def clean(self, data: bytes, path: bytes = b"") -> bytes:
439 """Apply line ending conversion for checkin (working tree -> repository)."""
440 if self.clean_conversion is None:
441 return data
443 # Skip binary files if detection is enabled
444 if self.binary_detection and is_binary(data):
445 return data
447 converted = self.clean_conversion(data)
449 # Check if conversion is safe
450 if self.safecrlf != b"false":
451 check_safecrlf(data, converted, self.safecrlf, path)
453 return converted
455 def smudge(self, data: bytes, path: bytes = b"") -> bytes:
456 """Apply line ending conversion for checkout (repository -> working tree)."""
457 if self.smudge_conversion is None:
458 return data
460 # Skip binary files if detection is enabled
461 if self.binary_detection and is_binary(data):
462 return data
464 # Git leaves content that already contains CR alone when the decision
465 # came from a heuristic rather than an explicit attribute.
466 if self.require_lf_only and b"\r" in data:
467 return data
469 converted = self.smudge_conversion(data)
471 # Check if conversion is safe
472 if self.safecrlf != b"false":
473 check_safecrlf(data, converted, self.safecrlf, path)
475 return converted
477 def cleanup(self) -> None:
478 """Clean up any resources held by this filter driver."""
479 # LineEndingFilter doesn't hold any resources that need cleanup
481 def reuse(self, config: "Config", filter_name: str) -> bool:
482 """Check if this filter driver should be reused with the given configuration."""
483 # LineEndingFilter is lightweight and should always be recreated
484 # to ensure it uses the latest configuration
485 return False
488def convert_crlf_to_lf(text_hunk: bytes) -> bytes:
489 """Convert CRLF in text hunk into LF.
491 Args:
492 text_hunk: A bytes string representing a text hunk
493 Returns: The text hunk with the same type, with CRLF replaced into LF
494 """
495 return text_hunk.replace(CRLF, LF)
498def convert_lf_to_crlf(text_hunk: bytes) -> bytes:
499 """Convert LF in text hunk into CRLF.
501 Args:
502 text_hunk: A bytes string representing a text hunk
503 Returns: The text hunk with the same type, with LF replaced into CRLF
504 """
505 # Single-pass conversion: split on LF and join with CRLF
506 # This avoids the double replacement issue
507 parts = text_hunk.split(LF)
508 # Remove any trailing CR to avoid CRCRLF
509 cleaned_parts = []
510 for i, part in enumerate(parts):
511 if i < len(parts) - 1 and part.endswith(b"\r"):
512 cleaned_parts.append(part[:-1])
513 else:
514 cleaned_parts.append(part)
515 return CRLF.join(cleaned_parts)
518def check_safecrlf(
519 original: bytes, converted: bytes, safecrlf: bytes, path: bytes = b""
520) -> None:
521 """Check if CRLF conversion is safe according to core.safecrlf setting.
523 Args:
524 original: Original content before conversion
525 converted: Content after conversion
526 safecrlf: Value of core.safecrlf config (b"true", b"warn", or b"false")
527 path: Path to the file being checked (for error messages)
529 Raises:
530 ValueError: If safecrlf is "true" and conversion would lose data
531 """
532 if safecrlf == b"false":
533 return
535 # Check if conversion is reversible
536 if safecrlf in (b"true", b"warn"):
537 # For CRLF->LF conversion, check if converting back would recover original
538 if CRLF in original and CRLF not in converted:
539 # This was a CRLF->LF conversion
540 recovered = convert_lf_to_crlf(converted)
541 if recovered != original:
542 msg = (
543 f"CRLF would be replaced by LF in {path.decode('utf-8', 'replace')}"
544 )
545 if safecrlf == b"true":
546 raise ValueError(msg)
547 else: # warn
548 logger.warning(msg)
550 # For LF->CRLF conversion, check if converting back would recover original
551 elif LF in original and CRLF in converted and CRLF not in original:
552 # This was a LF->CRLF conversion
553 recovered = convert_crlf_to_lf(converted)
554 if recovered != original:
555 msg = (
556 f"LF would be replaced by CRLF in {path.decode('utf-8', 'replace')}"
557 )
558 if safecrlf == b"true":
559 raise ValueError(msg)
560 else: # warn
561 logger.warning(msg)
564def get_smudge_filter(
565 core_eol: str, core_autocrlf: bytes
566) -> Callable[[bytes], bytes] | None:
567 """Returns the correct smudge filter based on the passed arguments."""
568 # Git attributes handling is done by the filter infrastructure
569 return get_smudge_filter_autocrlf(core_autocrlf)
572def get_clean_filter(
573 core_eol: str, core_autocrlf: bytes
574) -> Callable[[bytes], bytes] | None:
575 """Returns the correct clean filter based on the passed arguments."""
576 # Git attributes handling is done by the filter infrastructure
577 return get_clean_filter_autocrlf(core_autocrlf)
580def get_smudge_filter_autocrlf(
581 core_autocrlf: bytes,
582) -> Callable[[bytes], bytes] | None:
583 """Returns the correct smudge filter base on autocrlf value.
585 Args:
586 core_autocrlf: The bytes configuration value of core.autocrlf.
587 Valid values are: b'true', b'false' or b'input'.
588 Returns: Either None if no filter has to be applied or a function
589 accepting a single argument, a binary text hunk
590 """
591 if core_autocrlf == b"true":
592 return convert_lf_to_crlf
594 return None
597def get_clean_filter_autocrlf(
598 core_autocrlf: bytes,
599) -> Callable[[bytes], bytes] | None:
600 """Returns the correct clean filter base on autocrlf value.
602 Args:
603 core_autocrlf: The bytes configuration value of core.autocrlf.
604 Valid values are: b'true', b'false' or b'input'.
605 Returns: Either None if no filter has to be applied or a function
606 accepting a single argument, a binary text hunk
607 """
608 if core_autocrlf == b"true" or core_autocrlf == b"input":
609 return convert_crlf_to_lf
611 # Checking filter should never be `convert_lf_to_crlf`
612 return None
615class BlobNormalizer(FilterBlobNormalizer):
616 """An object to store computation result of which filter to apply based on configuration, gitattributes, path and operation (checkin or checkout).
618 This class maintains backward compatibility while using the filter infrastructure.
619 """
621 def __init__(
622 self,
623 config_stack: "Config",
624 gitattributes: Mapping[str, Any],
625 core_eol: str = "native",
626 autocrlf: bytes = b"false",
627 safecrlf: bytes = b"false",
628 ) -> None:
629 """Initialize FilteringBlobNormalizer."""
630 # Set up a filter registry with line ending filters
631 filter_registry = FilterRegistry(config_stack)
633 # Create line ending filter if needed
634 smudge_filter = get_smudge_filter(core_eol, autocrlf)
635 clean_filter = get_clean_filter(core_eol, autocrlf)
637 # Always register a text filter that can be used by gitattributes
638 # Even if autocrlf is false, gitattributes text=true should work
639 line_ending_filter = LineEndingFilter(
640 clean_conversion=clean_filter or convert_crlf_to_lf,
641 smudge_conversion=smudge_filter or convert_lf_to_crlf,
642 binary_detection=True,
643 safecrlf=safecrlf,
644 )
645 filter_registry.register_driver("text", line_ending_filter)
647 # Convert dict gitattributes to GitAttributes object for parent class
648 git_attrs_patterns = []
649 for pattern_str, attrs in gitattributes.items():
650 if isinstance(pattern_str, str):
651 pattern_bytes = pattern_str.encode("utf-8")
652 else:
653 pattern_bytes = pattern_str
654 try:
655 pattern = Pattern(pattern_bytes)
656 except MalformedPattern:
657 logger.warning("Ignoring malformed pattern %r", pattern_bytes)
658 continue
659 git_attrs_patterns.append((pattern, attrs))
661 git_attributes = GitAttributes(git_attrs_patterns)
663 # Create FilterContext for parent class
664 filter_context = FilterContext(filter_registry)
666 # Initialize parent class with gitattributes
667 # The filter infrastructure will handle gitattributes processing
668 super().__init__(config_stack, git_attributes, filter_context=filter_context)
670 # Store original filters for backward compatibility
671 self.fallback_read_filter = smudge_filter
672 self.fallback_write_filter = clean_filter
674 def checkin_normalize(self, blob: Blob, path: bytes) -> Blob:
675 """Normalize a blob during a checkin operation."""
676 # First try to get filter from gitattributes (handled by parent)
677 result = super().checkin_normalize(blob, path)
679 # Check if gitattributes explicitly disabled text conversion
680 attrs = self.gitattributes.match_path(path)
681 if b"text" in attrs and attrs[b"text"] is False:
682 # Explicitly marked as binary, no conversion
683 return blob
685 # If no filter was applied via gitattributes and we have a fallback filter
686 # (autocrlf is enabled), apply it to all files
687 if result is blob and self.fallback_write_filter is not None:
688 # Apply the clean filter with binary detection
689 # Get safecrlf from config
690 safecrlf = b"false"
691 config_stack = getattr(self.filter_registry, "config_stack", None)
692 if config_stack is not None:
693 safecrlf = config_stack.get(b"core", b"safecrlf", b"false")
694 if hasattr(safecrlf, "encode"):
695 safecrlf = safecrlf.encode("utf-8")
697 line_ending_filter = LineEndingFilter(
698 clean_conversion=self.fallback_write_filter,
699 smudge_conversion=None,
700 binary_detection=True,
701 safecrlf=safecrlf,
702 )
703 filtered_data = line_ending_filter.clean(blob.data, path)
704 if filtered_data != blob.data:
705 new_blob = Blob()
706 new_blob.data = filtered_data
707 return new_blob
709 return result
711 def checkout_normalize(self, blob: Blob, path: bytes) -> Blob:
712 """Normalize a blob during a checkout operation."""
713 # First try to get filter from gitattributes (handled by parent)
714 result = super().checkout_normalize(blob, path)
716 # Check if gitattributes explicitly disabled text conversion
717 attrs = self.gitattributes.match_path(path)
718 if b"text" in attrs and attrs[b"text"] is False:
719 # Explicitly marked as binary, no conversion
720 return blob
722 # If no filter was applied via gitattributes and we have a fallback filter
723 # (autocrlf is enabled), apply it to all files
724 if result is blob and self.fallback_read_filter is not None:
725 # Apply the smudge filter with binary detection
726 # Get safecrlf from config
727 safecrlf = b"false"
728 config_stack = getattr(self.filter_registry, "config_stack", None)
729 if config_stack is not None:
730 safecrlf = config_stack.get(b"core", b"safecrlf", b"false")
731 if hasattr(safecrlf, "encode"):
732 safecrlf = safecrlf.encode("utf-8")
734 line_ending_filter = LineEndingFilter(
735 clean_conversion=None,
736 smudge_conversion=self.fallback_read_filter,
737 binary_detection=True,
738 safecrlf=safecrlf,
739 )
740 filtered_data = line_ending_filter.smudge(blob.data, path)
741 if filtered_data != blob.data:
742 new_blob = Blob()
743 new_blob.data = filtered_data
744 return new_blob
746 return result
749def normalize_blob(
750 blob: Blob, conversion: Callable[[bytes], bytes], binary_detection: bool
751) -> Blob:
752 """Normalize blob by applying line ending conversion."""
753 # Read the original blob
754 data = blob.data
756 # If we need to detect if a file is binary and the file is detected as
757 # binary, do not apply the conversion function and return the original
758 # chunked text
759 if binary_detection is True:
760 if is_binary(data):
761 return blob
763 # Now apply the conversion
764 converted_data = conversion(data)
766 new_blob = Blob()
767 new_blob.data = converted_data
769 return new_blob
772class TreeBlobNormalizer(BlobNormalizer):
773 """Blob normalizer that tracks existing files in a tree."""
775 def __init__(
776 self,
777 config_stack: "Config",
778 git_attributes: Mapping[str, Any],
779 object_store: "BaseObjectStore",
780 tree: ObjectID | None = None,
781 core_eol: str = "native",
782 autocrlf: bytes = b"false",
783 safecrlf: bytes = b"false",
784 ) -> None:
785 """Initialize TreeBlobNormalizer."""
786 super().__init__(config_stack, git_attributes, core_eol, autocrlf, safecrlf)
787 if tree:
788 self.existing_paths = {
789 name for name, _, _ in iter_tree_contents(object_store, tree)
790 }
791 else:
792 self.existing_paths = set()
794 def checkin_normalize(self, blob: Blob, path: bytes) -> Blob:
795 """Normalize blob for checkin, considering existing tree state."""
796 # Existing files should only be normalized on checkin if:
797 # 1. They were previously normalized on checkout (autocrlf=true), OR
798 # 2. We have a write filter (autocrlf=true or autocrlf=input), OR
799 # 3. They are new files
800 if (
801 self.fallback_read_filter is not None
802 or self.fallback_write_filter is not None
803 or path not in self.existing_paths
804 ):
805 return super().checkin_normalize(blob, path)
806 return blob