line_ending.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 public 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. #
  21. r"""All line-ending related functions, from conversions to config processing.
  22. Line-ending normalization is a complex beast. Here is some notes and details
  23. about how it seems to work.
  24. The normalization is a two-fold process that happens at two moments:
  25. - When reading a file from the index and to the working directory. For example
  26. when doing a ``git clone`` or ``git checkout`` call. We call this process the
  27. read filter in this module.
  28. - When writing a file to the index from the working directory. For example
  29. when doing a ``git add`` call. We call this process the write filter in this
  30. module.
  31. Note that when checking status (getting unstaged changes), whether or not
  32. normalization is done on write depends on whether or not the file in the
  33. working dir has also been normalized on read:
  34. - For autocrlf=true all files are always normalized on both read and write.
  35. - For autocrlf=input files are only normalized on write if they are newly
  36. "added". Since files which are already committed are not normalized on
  37. checkout into the working tree, they are also left alone when staging
  38. modifications into the index.
  39. One thing to know is that Git does line-ending normalization only on text
  40. files. How does Git know that a file is text? We can either mark a file as a
  41. text file, a binary file or ask Git to automatically decides. Git has an
  42. heuristic to detect if a file is a text file or a binary file. It seems based
  43. on the percentage of non-printable characters in files.
  44. The code for this heuristic is here:
  45. https://git.kernel.org/pub/scm/git/git.git/tree/convert.c#n46
  46. Dulwich have an implementation with a slightly different heuristic, the
  47. `dulwich.patch.is_binary` function.
  48. The binary detection heuristic implementation is close to the one in JGit:
  49. https://github.com/eclipse/jgit/blob/f6873ffe522bbc3536969a3a3546bf9a819b92bf/org.eclipse.jgit/src/org/eclipse/jgit/diff/RawText.java#L300
  50. There is multiple variables that impact the normalization.
  51. First, a repository can contains a ``.gitattributes`` file (or more than one...)
  52. that can further customize the operation on some file patterns, for example:
  53. \*.txt text
  54. Force all ``.txt`` files to be treated as text files and to have their lines
  55. endings normalized.
  56. \*.jpg -text
  57. Force all ``.jpg`` files to be treated as binary files and to not have their
  58. lines endings converted.
  59. \*.vcproj text eol=crlf
  60. Force all ``.vcproj`` files to be treated as text files and to have their lines
  61. endings converted into ``CRLF`` in working directory no matter the native EOL of
  62. the platform.
  63. \*.sh text eol=lf
  64. Force all ``.sh`` files to be treated as text files and to have their lines
  65. endings converted into ``LF`` in working directory no matter the native EOL of
  66. the platform.
  67. If the ``eol`` attribute is not defined, Git uses the ``core.eol`` configuration
  68. value described later.
  69. \* text=auto
  70. Force all files to be scanned by the text file heuristic detection and to have
  71. their line endings normalized in case they are detected as text files.
  72. Git also have a obsolete attribute named ``crlf`` that can be translated to the
  73. corresponding text attribute value.
  74. Then there are some configuration option (that can be defined at the
  75. repository or user level):
  76. - core.autocrlf
  77. - core.eol
  78. ``core.autocrlf`` is taken into account for all files that doesn't have a ``text``
  79. attribute defined in ``.gitattributes``; it takes three possible values:
  80. - ``true``: This forces all files on the working directory to have CRLF
  81. line-endings in the working directory and convert line-endings to LF
  82. when writing to the index. When autocrlf is set to true, eol value is
  83. ignored.
  84. - ``input``: Quite similar to the ``true`` value but only force the write
  85. filter, ie line-ending of new files added to the index will get their
  86. line-endings converted to LF.
  87. - ``false`` (default): No normalization is done.
  88. ``core.eol`` is the top-level configuration to define the line-ending to use
  89. when applying the read_filer. It takes three possible values:
  90. - ``lf``: When normalization is done, force line-endings to be ``LF`` in the
  91. working directory.
  92. - ``crlf``: When normalization is done, force line-endings to be ``CRLF`` in
  93. the working directory.
  94. - ``native`` (default): When normalization is done, force line-endings to be
  95. the platform's native line ending.
  96. One thing to remember is when line-ending normalization is done on a file, Git
  97. always normalize line-ending to ``LF`` when writing to the index.
  98. There are sources that seems to indicate that Git won't do line-ending
  99. normalization when a file contains mixed line-endings. I think this logic
  100. might be in text / binary detection heuristic but couldn't find it yet.
  101. Sources:
  102. - https://git-scm.com/docs/git-config#git-config-coreeol
  103. - https://git-scm.com/docs/git-config#git-config-coreautocrlf
  104. - https://git-scm.com/docs/gitattributes#_checking_out_and_checking_in
  105. - https://adaptivepatchwork.com/2012/03/01/mind-the-end-of-your-line/
  106. """
  107. from .object_store import iter_tree_contents
  108. from .objects import Blob
  109. from .patch import is_binary
  110. CRLF = b"\r\n"
  111. LF = b"\n"
  112. def convert_crlf_to_lf(text_hunk):
  113. """Convert CRLF in text hunk into LF.
  114. Args:
  115. text_hunk: A bytes string representing a text hunk
  116. Returns: The text hunk with the same type, with CRLF replaced into LF
  117. """
  118. return text_hunk.replace(CRLF, LF)
  119. def convert_lf_to_crlf(text_hunk):
  120. """Convert LF in text hunk into CRLF.
  121. Args:
  122. text_hunk: A bytes string representing a text hunk
  123. Returns: The text hunk with the same type, with LF replaced into CRLF
  124. """
  125. # TODO find a more efficient way of doing it
  126. intermediary = text_hunk.replace(CRLF, LF)
  127. return intermediary.replace(LF, CRLF)
  128. def get_checkout_filter(core_eol, core_autocrlf, git_attributes):
  129. """Returns the correct checkout filter based on the passed arguments."""
  130. # TODO this function should process the git_attributes for the path and if
  131. # the text attribute is not defined, fallback on the
  132. # get_checkout_filter_autocrlf function with the autocrlf value
  133. return get_checkout_filter_autocrlf(core_autocrlf)
  134. def get_checkin_filter(core_eol, core_autocrlf, git_attributes):
  135. """Returns the correct checkin filter based on the passed arguments."""
  136. # TODO this function should process the git_attributes for the path and if
  137. # the text attribute is not defined, fallback on the
  138. # get_checkin_filter_autocrlf function with the autocrlf value
  139. return get_checkin_filter_autocrlf(core_autocrlf)
  140. def get_checkout_filter_autocrlf(core_autocrlf):
  141. """Returns the correct checkout filter base on autocrlf value.
  142. Args:
  143. core_autocrlf: The bytes configuration value of core.autocrlf.
  144. Valid values are: b'true', b'false' or b'input'.
  145. Returns: Either None if no filter has to be applied or a function
  146. accepting a single argument, a binary text hunk
  147. """
  148. if core_autocrlf == b"true":
  149. return convert_lf_to_crlf
  150. return None
  151. def get_checkin_filter_autocrlf(core_autocrlf):
  152. """Returns the correct checkin filter base on autocrlf value.
  153. Args:
  154. core_autocrlf: The bytes configuration value of core.autocrlf.
  155. Valid values are: b'true', b'false' or b'input'.
  156. Returns: Either None if no filter has to be applied or a function
  157. accepting a single argument, a binary text hunk
  158. """
  159. if core_autocrlf == b"true" or core_autocrlf == b"input":
  160. return convert_crlf_to_lf
  161. # Checking filter should never be `convert_lf_to_crlf`
  162. return None
  163. class BlobNormalizer:
  164. """An object to store computation result of which filter to apply based
  165. on configuration, gitattributes, path and operation (checkin or checkout).
  166. """
  167. def __init__(self, config_stack, gitattributes) -> None:
  168. self.config_stack = config_stack
  169. self.gitattributes = gitattributes
  170. # Compute which filters we needs based on parameters
  171. try:
  172. core_eol = config_stack.get("core", "eol")
  173. except KeyError:
  174. core_eol = "native"
  175. try:
  176. core_autocrlf = config_stack.get("core", "autocrlf").lower()
  177. except KeyError:
  178. core_autocrlf = False
  179. self.fallback_read_filter = get_checkout_filter(
  180. core_eol, core_autocrlf, self.gitattributes
  181. )
  182. self.fallback_write_filter = get_checkin_filter(
  183. core_eol, core_autocrlf, self.gitattributes
  184. )
  185. def checkin_normalize(self, blob, tree_path):
  186. """Normalize a blob during a checkin operation."""
  187. if self.fallback_write_filter is not None:
  188. return normalize_blob(
  189. blob, self.fallback_write_filter, binary_detection=True
  190. )
  191. return blob
  192. def checkout_normalize(self, blob, tree_path):
  193. """Normalize a blob during a checkout operation."""
  194. if self.fallback_read_filter is not None:
  195. return normalize_blob(
  196. blob, self.fallback_read_filter, binary_detection=True
  197. )
  198. return blob
  199. def normalize_blob(blob, conversion, binary_detection):
  200. """Takes a blob as input returns either the original blob if
  201. binary_detection is True and the blob content looks like binary, else
  202. return a new blob with converted data.
  203. """
  204. # Read the original blob
  205. data = blob.data
  206. # If we need to detect if a file is binary and the file is detected as
  207. # binary, do not apply the conversion function and return the original
  208. # chunked text
  209. if binary_detection is True:
  210. if is_binary(data):
  211. return blob
  212. # Now apply the conversion
  213. converted_data = conversion(data)
  214. new_blob = Blob()
  215. new_blob.data = converted_data
  216. return new_blob
  217. class TreeBlobNormalizer(BlobNormalizer):
  218. def __init__(self, config_stack, git_attributes, object_store, tree=None) -> None:
  219. super().__init__(config_stack, git_attributes)
  220. if tree:
  221. self.existing_paths = {
  222. name for name, _, _ in iter_tree_contents(object_store, tree)
  223. }
  224. else:
  225. self.existing_paths = set()
  226. def checkin_normalize(self, blob, tree_path):
  227. # Existing files should only be normalized on checkin if it was
  228. # previously normalized on checkout
  229. if (
  230. self.fallback_read_filter is not None
  231. or tree_path not in self.existing_paths
  232. ):
  233. return super().checkin_normalize(blob, tree_path)
  234. return blob