为什么Git不支持原生的UTF-16编码?

13

Git支持多种不同的编码方案,包括UTF-7, UTF-8UTF-32,以及非UTF编码。

鉴于此,为什么它不支持UTF-16呢?

有很多问题询问如何使Git支持UTF-16,但我认为这还没有得到明确的问答。


1
你为什么说 Git 支持 UTF-7?(无论如何,我认为你混淆了“代码页”和“Unicode 编码方案”,但它们是完全不同的。) - torek
1
@torek,你说得对,我混淆了代码页编码方案。我已经修复了它。至于UTF-7,天真的回答是我的GUI给了我在UTF-7中进行差异比较的选项。我知道这可能只是我的差异工具的限制,但似乎有一个普遍的共识,即git与UTF-16不兼容。 - Zac Faragher
@MadPhysicist 嗯,首先,有这个 - Zac Faragher
根据维基百科的说法,UTF-16在Windows、Java和JavaScript等系统内部使用,并且通常用于Windows上的纯文本和文字处理数据文件。它很少用于Unix/Linux或macOS上的文件。也许这只是Windows/Unix之间的分歧?@MadPhysicist - Zac Faragher
1
根据https://dev59.com/ym025IYBdhLWcg3wQDhb#6134127的说法,utf-16会影响git正常检测二进制文件的能力。虽然并未解释utf-32在这种情况下的工作原理,但仍然似乎具有相关性。 - Mad Physicist
显示剩余7条评论
5个回答

21
我在我的(目前状况相当停滞的)书籍(请参见第3章,该章节比后面的章节更为完整)中花费了相当一部分篇幅来探讨字符编码问题,因为它是一个历史上的混乱。然而,值得在此提及的是,这个问题的前提之一——Git以某种方式支持UTF-7和UTF-32——是错误的:UTF-7根本就没有成为标准,可能根本不应该使用(所以自然地,旧版的Internet Explorer会使用它,这导致了链接到维基百科页面中提到的安全问题)。

话虽如此,让我们首先将字符编码代码页区分开来。(也请参见下面类似脚注的部分。)这里的根本问题是计算机(至少是现代计算机)使用一系列8位字节,每个字节表示[0..255]范围内的一个整数。旧的系统有6、7、8甚至9位字节,但我认为把任何小于8位的东西都叫做“字节”是误导性的。(BBN's 的“C机器”有10位字节!)无论如何,如果一个字节表示一个字符符号,那么这就给我们提供了256种符号的上限。在那些不好的ASCII时代,这是足够的,因为ASCII只有128个符号,其中33个是非打印符号(控制代码0x000x1f,以及0x7f代表DEL或已删除的纸带上的打孔,在此以十六进制书写)。 当我们需要超过94个可打印符号加上空格(0x20)时,我们——我指的是全世界使用计算机的人们,而不是特定的我——说:“看这个,我们有128个未使用的编码,从0x80到0xff,让我们使用其中一些!”于是法国人用了一些ç和é等字符,以及像«和»这样的标点符号。捷克人需要一个带抑扬符的Z,ž。俄罗斯人需要很多,用于Cyrillic。希腊人需要很多,等等。结果是8位空间的上半部分分裂成许多不兼容的集合,人们称之为代码页

基本上,计算机存储一些八位字节值,比如十进制235(0xEB hex),由其他东西——另一个计算机程序,或最终是一个人盯着屏幕——来解释将该235解释为Cyrillic л字符或希腊λ字符等。如果我们正在使用代码页,则告诉我们“235”意味着什么:我们应该对其施加什么样的语义。
这里的问题在于我们能够支持的字符代码数量是有限的。如果我们想让西里尔字母L(л)与希腊字母L(lambda,λ)共存,我们不能同时使用CP-1251CP-1253,因此我们需要一种更好的方式来编码符号。一个明显的方法是停止使用单字节值来编码符号:如果我们使用双字节值,我们可以编码65536个值,包括0x00000xffff;减去一些控制码仍然有足够的空间容纳许多字母表。然而,我们很快就超过了这个限制,所以我们转向了Unicode,它有1114112个被称为代码点的位置,每个位置代表某种具有语义意义的符号。其中有超过10万个正在使用,包括表情符号如和。

将Unicode编码成字节或单词

这就是UTF-8、UTF-16、UTF-32、UCS-2和UCS-4的用处。它们都是将Unicode代码点(约100万个值之一)编码为字节流的方案。我将完全跳过UCS,只看UTF-8和UTF-16编码,因为它们目前最有趣。(另请参见什么是Unicode、UTF-8和UTF-16?
UTF-8编码很简单:任何十进制值小于128的代码点都被编码为一个包含该值的字节。这意味着普通ASCII文本字符仍然是普通ASCII文本字符。在0x0080(十进制128)到0x07ff(十进制2047)范围内的代码点编码为两个字节,其值都在128-255范围内,因此可以与单字节编码值区分开来。在0x0800到0xffff范围内的代码点以相同的128-255范围内的三个字节进行编码,其余有效值以四个字节进行编码。对于Git本身而言,关键在于没有编码值类似于ASCII NUL(0x00)或斜杠(0x2f)。 UTF-8编码允许Git“假装”文本字符串(尤其是文件名)是斜杠分隔的名称组件,其结尾或可以标记为ASCII NUL字节。这是Git在树对象中使用的编码方式,因此UTF-8编码的树对象无需调整即可完美适配。
UTF-16编码每个字符使用两个配对字节。这对Git和路径名有两个问题。首先,一对字节中的一个字节可能会意外地类似于“/”,并且所有ASCII值的字符必须编码为一对字节,其中一个字节是类似于ASCII NUL的“0x00”。因此,Git需要知道:“此路径名已经以UTF-16编码”,并且处理字节对。树对象中没有足够的空间存储此信息,因此Git需要新的对象类型。其次,每当我们将一个16位值分成两个独立的8位字节时,我们都会按某种顺序进行:我要么先给你更重要的字节,然后是不那么重要的字节;要么先给你不那么重要的字节,然后是更重要的字节。这第二个问题导致UTF-16具有字节顺序标记。UTF-8不需要字节顺序标记,就足够了,那么为什么不在树中使用它呢?所以Git使用了它。
这对于树来说很好,但我们还有提交、标签和数据块。
Git对这四种对象中的三种进行自己的解释。
  1. 提交包含哈希ID。
  2. 树包含路径名、文件模式和哈希ID。
  3. 标签包含哈希ID。

未在此列出的是{{blob}},在大多数情况下,Git不会对{{blob}}进行任何解释。

为了方便理解提交、树和标签,Git通常将三者限制为UTF-8。但是,Git允许在提交中的{{日志消息}}或标签中的{{标签文本}}中进行一些(大多数)未解释的内容。这些内容位于Git解释的头部之后,因此即使此时存在特别棘手或丑陋的内容,也是相当安全的。(由于PGP签名出现在标题下面,有一些微小的风险,因为它们会被解释。)对于提交而言,现代Git将在解释部分中包括一个编码标头行,然后Git可以尝试对提交消息正文进行解码,并将其重新编码为Git输出的字节使用的任何程序所使用的编码方式。1

对于注释型标签对象,同样的规则也适用。我不确定Git是否有代码来处理标签(提交代码可能可以重复使用,但标签通常具有PGP签名,并且强制UTF-8可能更明智)。由于树是{{内部}}对象,它们的编码基本上是不可见的——除了我在书中指出的问题,您不需要意识到这一点。

这会留下{{blobs}},它们是最重要的部分。


1这是计算机世界中反复出现的主题:一切都被重复编码和解码。考虑一下通过Wi-Fi或有线网络连接传输的内容:它已被编码成某种无线电波或类似物,然后某个硬件将其解码成位流,另一些硬件将其重新编码成字节流。硬件和/或软件会剥离头部,在某种方式下解释剩余的编码,适当地更改数据,并重新编码比特和字节,以供另一层硬件和软件处理。真奇妙,任何事情竟然都能完成。


Blob编码

Git喜欢声称它完全不关心存储在文件中的实际数据,即Git blob。这基本上是真的。或者说,半真。只要Git所做的只是存储您的数据,那就完全正确!Git只存储字节。这些字节的含义由您决定。

当您运行git diffgit merge时,故事就会破裂,因为差异算法和合并代码是面向的。行以换行符终止。 (如果您使用的是使用CRLF而不是换行符的系统,则CRLF对的第二个字符换行符,因此这里没有问题-尽管Git可以处理未终止的最终行,但这会在某些地方引起一些小问题。)如果文件以UTF-16编码,则许多字节往往似乎是ASCII NULs,因此Git将其视为二进制。

这是可以解决的:Git可以将UTF-16数据解码为UTF-8,将该数据通过所有现有的面向行的算法(现在将看到以换行符结尾的行),然后重新编码数据回到UTF-16。这里有一堆次要的技术问题;最大的问题是确定某个文件是UTF-16,如果是,那么是哪种字节顺序(UTF-16-LE还是UTF-16-BE?)。如果文件有一个字节顺序标记,那么就解决了字节顺序问题,并且UTF-16-ness可以像声明文件binary或text一样编码到.gitattributes中,所以这是可以解决的。它只是混乱不堪,没有人做过这项工作。
脚注式:代码页可以被认为是编码的(糟糕)形式。
我上面提到的是我们使用Unicode的方式,将21位的代码点值编码成一些八位字节的数量(在UTF-8中为1到4字节,在UTF-16中为2字节——这里有一个丑陋的小技巧,称为surrogates,将21位值压缩成16位容器,偶尔使用一对16位值,在这里)。这种编码技巧意味着我们可以表示所有合法的21位代码点值,尽管我们可能需要多个8位字节来这样做。
当我们使用代码页(CP-number)时,我们所做的事情是将256个值(适合一个8位字节)映射到21位代码点空间中。我们挑选出不超过256个这样的代码点子集,并说:“这些是我们允许的代码点。”我们将第一个编码为,比如说,0xa0,第二个编码为0xa1,以此类推。我们总是留出至少一些控制代码的空间——通常是在0x000x1f范围内的所有32个控制代码——通常我们还留下整个7位ASCII子集,就像Unicode本身一样(参见Unicode字符列表),这就是为什么我们最常从0xa0开始的原因。
当编写正确的Unicode支持库时,代码页只是使用这种形式的索引的转换表。困难的部分是为所有的代码页制作准确的表格,其中有很多。
代码页的好处是字符再次是每个一个字节。坏处是一旦选择了符号集,你就无法更改,当你说:我使用这个代码页之后,你就被锁定在这个小的Unicode子集中。如果切换到另一个代码页,你的八位字节值中的一些或全部表示不同的符号。

1
代码页的好处在于字符再次变成了每个字节一个字节,直到您考虑组合字符为止;) (https://en.wikipedia.org/wiki/Combining_character) - VonC
@VonC:组合字符在任何地方都会出现问题,包括Unicode代码点中。Git有特殊的OSX hack来处理OSX内核坚持使用NFD的事实(请参见https://serverfault.com/questions/397420/converting-utf-8-nfd-filenames-to-utf-8-nfc-in-either-rsync-or-afpd)。 - torek
有问题,但也很有趣:https://dev59.com/K2w15IYBdhLWcg3wfb3i#29947301,正如https://meta.stackexchange.com/a/315819/6309所示。 - VonC
我实际上在使用git时遇到了很多问题,运行"git stash"会将我所有的UTF-16 LE文件重写为UTF-16 BE文件。不知道是什么原因导致的这个问题...... - undefined

8
第一次在Git代码库中提到UTF-8是在d4a9ce7(2005年8月,v0.99.6)关于邮件框补丁的内容:

可选地,使用“-u”标志,将.info.msg的输出从其原始字符集[sic]转换为utf-8。这是为了鼓励人们在提交消息中使用utf8以实现互操作性。

此内容由Junio C Hamano / 濱野純 <junkio@cox.net>签署。
字符编码在commit 3a59e59(2017年7月,Git v2.6.0-rc0)中得到澄清。
"git 是编码无关的" 这种说法只适用于 blob 对象。例如,tree 和 commit 对象的 "非 NUL 字节" 要求排除了 UTF-16/32,而索引文件中 "/" 的特殊含义以及 commit 对象中的空格和换行符也排除了 EBCDIC 和其他非 ASCII 编码。Git 期望 bytes < 0x80 是纯 ASCII,因此与 ASCII 范围部分重叠的 CJK 编码也存在问题。例如,fmt_ident() 假定用户名的尾随 0x5C 是 ASCII '\',但是有超过 200 个 GBK 双字节代码以 0x5C 结尾。Linux 上的默认编码为 UTF-8,并在 Mac 和 Windows 版本中进行相应的路径转换,已经建立了 UTF-8 NFC 作为路径名的事实标准。"
请参考 "git, msysgit, accents, utf-8, the definitive answers" 了解最后一个补丁的更多信息。 Documentation/i18n.txt 的最新版本包括:
Git 在某种程度上是字符编码不可知的。
blob 对象的内容是未经解释的字节序列。核心层没有编码转换。
路径名以 UTF-8 标准规范 C 编码。这适用于:
树对象、索引文件、引用名称以及命令行参数、环境变量和配置文件(.git/config、gitignore、gitattributes 和 gitmodules)中的路径名。
你可以在提交0217569(2012年1月,Git v2.1.0-rc0)中看到UTF-8路径转换的示例,该提交增加了Win32 Unicode文件名支持。
更改 opendir / readdir 以使用Windows Unicode API并在UTF-8 / UTF-16之间进行转换。
关于命令行参数,请参阅提交3f04614(2011年1月,Git v2.1.0-rc0),它将启动时的命令行参数从UTF-16转换为UTF-8。
注意:在 Git 2.21(2019年2月)之前的代码和测试中,假设系统提供的 iconv() 在被要求将编码转换为 UTF-16(或 UTF-32)时,输出总是带有 BOM,但显然有些实现输出大端序而没有 BOM。
现在添加了一个编译时开关来帮助这些系统(例如 NonStop)向输出添加 BOM,以增加可移植性。

查看提交 79444c9(2019年2月12日)由brian m. carlson (bk2204)完成。
(由Junio C Hamano -- gitster --提交18f9fb6中合并,2019年2月13日)

utf8: 处理不为UTF-16写BOM的系统

当序列化UTF-16(和UTF-32)时,有三种可能的写入流的方式。可以以大端或小端格式使用BOM编写数据,也可以在大端格式下不使用BOM编写数据。
大多数系统的iconv实现选择以某种字节序的BOM编写它,因为这是最保险的,而且它对于Windows上的误解释很常见的UTF-16和小端序列化是有抵抗力的。 为了与Windows兼容并避免意外误用,Git始终希望使用BOM编写UTF-16,并会拒绝读取没有BOM的UTF-16。
然而,musl的iconv实现编写UTF-16时不使用BOM,依赖用户将其解释为大端。这导致t0028和相关功能失败,因为Git无法读取没有BOM的文件。
所以这里添加的“编译时开关”在Makefile中:
# Define ICONV_OMITS_BOM if your iconv implementation does not write a
# byte-order mark (BOM) when writing UTF-16 or UTF-32 and always writes in
# big-endian format.
#
ifdef ICONV_OMITS_BOM
    BASIC_CFLAGS += -DICONV_OMITS_BOM
endif

由于NonStop OS及其关联的NonStop SQL产品始终使用UTF-16BE(16位)编码Unicode(UCS2)字符集, 在该环境中您可以使用ICONV_OMITS_BOM


1
顺便提一下,在Rust或Python 3中编写代码并在二进制、8位代码页和Unicode样式字符串之间来回切换,真的会让人感受到编码问题的冲击。 :-) 其中一个有趣的事情是,如果Rust程序将其参数作为字符串访问,那么通过在argv中提供无效的UTF-8字节序列,您可以在启动时使程序崩溃。 - torek
这是一个很好的调查/总结,说明了Git如何逐渐远离“仅字节:编码无关”的口号! - Rusi
你能为“NonStop”添加一些上下文吗?这样就清楚它是什么了。一个(非裸露的)链接可能就足够了。维基百科列出了三种可能性(但它可能不是其中任何一种)。 - Peter Mortensen
@PeterMortensen 感谢您的编辑和提问。我已相应地编辑了答案,添加了“NonStop”的链接和参考资料,以及编译时的旋钮。 - VonC
Citrus iconv 在一些 BSD 上使用,它只转换传递给它的精确数据,不会删除或添加 BOM。我个人认为这样做是一个 bug。 - mirabilos

5

最近,Git开始支持像UTF-16这样的编码格式。请参阅gitattributes文档中的working-tree-encoding

如果您想在Windows机器上将.txt文件设置为UTF-16而没有BOM,则需将以下内容添加到您的gitattributes文件中:

*.txt text working-tree-encoding=UTF-16LE eol=CRLF

针对 jthill的评论:

毫无疑问,UTF-16是一团糟。但请考虑以下几点:

  • Java 使用 UTF16

  • 微软也使用 UTF16

    请注意这一行:UTF16… 是 Windows 操作系统上本地 Unicode 编码所使用的编码方式

  • JavaScript 在 UCS-2 和 UTF-16 之间使用了混乱的编码方式


仅补充一点:UEFI规范内部也仅支持UTF16LE字符串。 - Arrrow

1
短形式是添加对宽字符的支持会使一切变得更加困难。任何涉及到8位ISO代码页、UTF-8或任何其他MBCS的操作都可以轻松地扫描/跨越/复制字符串。但是,尝试添加对传输编码包含嵌入空值的字符串的支持,即使是最简单的操作也开始膨胀所有代码的复杂性。
我不知道UTF-16甚至有什么声称的优势,在实际使用时不会被缺点所抵消。你可以用相同简单的代码在ASCII、UTF-8、所有16个ISO/IEC-8859集合、所有EBCDIC以及可能还有十几个其他集合中识别字符串边界。只需稍微限制(基于ASCII,并添加几行多行终止符约定),您就可以获得基本的标记化,并且将转换为公共内部代码页基本上是免费的。
添加UTF-16支持会增加大量的工作量和复杂性,但所有这些工作都没有实际意义——在说出“哦,现在它可以处理UTF-16了!”之后,那些增加的冗余和努力有什么用处呢?没有。UTF-16能做的一切,UTF-8同样可以做得更好。

好观点。+1。这让我想起了最近的 https://github.com/git/git/commit/e92d6225361eba5ff34696122d1491dc7ace2a5a,用于管理往返编码转换。 - VonC
当然,如果由我们决定,UTF16不是一个好的设计选择。但某些相当重要的方面已经多次采用了它!因此,我们需要在可以避免时避免使用它;必须接受它时则应该与之共存。请查看我的下面回答。 - Rusi
@Rusi,这是一个有价值的观点。在企业环境中,薪资合理化动机往往是决定性的因素,而UTF-16无疑是一种极为成功的薪资合理化手段。但我认为,屈服于不断涌现的胡言乱语策略,用来推销这些东西是不明智的战略。将内部UTF-8与工作树中任何所需字符集转换为服务似乎是一个合理的妥协,我猜这是唯一可行的方法。 - jthill
@jthill:总的来说,我同意。但在这个特定的背景下,这是不公平的。当Unicode=BMP=UCS2时,MS/Java是早期采用者。Unicode联盟移动了多少球门柱并不是他们的错。以下是我对Unicode反复无常的总结:http://blog.languager.org/2015/03/whimsical-unicode.html - Rusi
@Rusi 我想我们大致同龄,这不是我在想象或重新构想,这是记忆。UTF-8比Unicode晚几年才开始流行,但可用的可扫描字节编码的存在或缺失并不影响相信可能会有最终的双字节编码的合理性。摩尔定律在那时已经三十岁了,而我已经从事专业编程超过一半的时间了。这只是时间问题。 - jthill
显示剩余2条评论

1
Git 2.20(2018年第四季度)将支持UTF-16环境变量,包括Git的支持...(Git 2.21中还修复了一个错误,请参见答案的第二部分)。

查看 提交fe21c6b, 提交665177e (2018年10月30日) 由 Johannes Schindelin (dscho) 完成。
协助者: Jeff Hostetler (jeffhostetler)
(由Junio C Hamano -- gitster --合并于提交0474cd1, 2018年11月13日)

mingw: 在运行时动态重新编码环境变量(UTF-16 <-> UTF-8

在 Windows 上,权威的环境编码是 UTF-16。
在 Git for Windows 中,我们将其转换为 UTF-8(因为 UTF-16 对于 Git 来说是一个“陌生”的概念,其源代码没有为此做好准备)。

以前,出于性能考虑,我们一开始就将整个环境转换为 UTF-8,并在 putenv()run_command() 时将其转换回来。

拥有一个私有副本的环境也有其自身的危险:当 Git 的源代码使用的库尝试修改环境时,它实际上并不起作用(在 Git for Windows 的情况下,libcurl,请参见 git-for-windows/git/compare/bcad1e6d58^...bcad1e6d58^2 了解问题的一瞥)。

因此,如果我们在 getenv()/putenv() 调用中切换到动态转换,则可以使我们的环境处理更加健壮。基于 Jeff Hostetler 在 MSVC 上的初始版本,此补丁使之成为可能。

令人惊讶的是,这对速度有一个积极的影响:在编写当前代码时,我们测试了性能,并且有如此多的getenv() 调用,以至于似乎最好一次性转换所有内容。然而,与此同时,Git 显然已经在 getenv() 调用方面进行了一些清理,因此测试套件生成的 Git 进程在其生命周期内平均只使用 40 次 getenv()/putenv() 调用。

说到整个测试套件:在当前代码中,重新编码所花费的总时间约为 32.4 秒(运行时间为 113 分钟),而此补丁引入的代码总共只需约 8.2 秒。
不算太多,但它证明了我们不必担心此补丁引入的性能影响。


在 Git 2.21(2019年第一季度)中,之前的路径引入了一个错误,影响了 GIT_EXTERNAL_DIFF 命令:从 getenv() 返回的字符串是非易失性的,这是不正确的,已经得到了纠正。

请参见 提交 6776a84(2019年1月11日),作者为 Kim Gybels (Jeff-G)
(由 Junio C Hamano -- gitster -- 合并于 提交 6a015ce,2019年1月29日)

这个bug在git-for-windows/git问题2007中被报告:
"无法在超过8个文件上使用difftool"
$ yes n | git -c difftool.prompt=yes difftool fe21c6b285df fe21c6b285df~100

Viewing (1/404): '.gitignore'
Launch 'bc3' [Y/n]?
Viewing (2/404): 'Documentation/.gitignore'
[...]
Viewing (8/404): 'Documentation/RelNotes/2.18.1.txt'
Launch 'bc3' [Y/n]?
Viewing (9/404): 'Documentation/RelNotes/2.19.0.txt'
Launch 'bc3' [Y/n]? error: cannot spawn ¦?: No such file or directory
fatal: external diff died, stopping at Documentation/RelNotes/2.19.1.txt

因此:

diff: 确保external_diff_cmd的正确生命周期

根据getenv(3)的注释:

getenv()的实现不需要是可重入的。
getenv()返回的字符串可能是静态分配的,并且可以通过后续调用getenv()putenv(3)setenv(3)unsetenv(3)进行修改。

由于getenv()返回的字符串允许在后续调用getenv()时更改,因此在从环境中缓存external_diff_cmd时,请务必复制。

这个问题在Git for Windows上变得明显,因为fe21c6bmingw:动态重新编码环境变量(UTF-16 <-> UTF-8))之后,compat/mingw.c中提供的getenv()实现已更改以保留一定数量的分配字符串并在后续调用中释放它们。


Git 2.24(2019年第四季度)修复了之前引入的一个黑客攻击。

请参见提交2049b8d提交97fff61(2019年9月30日),作者为Johannes Schindelin(dscho
(由Junio C Hamano -- gitster --提交772cad0中合并,2019年10月9日)

将稳定的sort函数git_sort()移入libgit.a

qsort()函数不能保证是稳定的,即它不能保证维护被告知视为相等的项的顺序。
相比之下,我们在compat/qsort.c中实现了一个归并排序算法的git_sort()函数是稳定的。

为了准备在Git的重命名检测中使用稳定的排序,将稳定的排序移入libgit.a,以便无条件地编译它,并将其重命名为git_stable_qsort()

注意:这也使得我们引入的hack过时了,该hack是在fe21c6bmingw:在飞行中重新编码环境变量(UTF-16 <-> UTF-8),2018-10-30,Git v2.20.0-rc0)中直接包含compat/qsort.ccompat/mingw.c中使用稳定的排序。


请参阅 https://stackoverflow.com/a/54973615/6309 了解 .gitattributes 中 UTF-16LE-BOM 的支持。 - VonC

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接