Skip to content

Commit e3819a0

Browse files
committed
Strip CR from CRLF input in msgmerge-female
Working trees produced by Git's autocrlf=true on Windows ship .po/.pot files with CRLF line endings. gettext msgmerge leaks the embedded \r into string fields, producing mixed-ending output and spurious fuzzy or duplicate entries from the polluted comparisons. Pre-canonicalize PO and POT to LF before invoking gettext.
1 parent 3357a92 commit e3819a0

3 files changed

Lines changed: 60 additions & 0 deletions

File tree

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Changelog
22

3+
### 1.5.3
4+
5+
- `msgmerge-female` now strips `\r` from CRLF line endings in PO and POT files before invoking gettext `msgmerge`. Working trees produced by Git's `autocrlf=true` on Windows would otherwise produce mixed-ending output and spurious fuzzy/duplicate entries.
6+
37
### 1.5.2
48

59
- Action: `poify` now always runs `msgmerge`, even when the POT file itself is unchanged. This lets the action clear stale PO/POT occurrence mismatches that can be reintroduced by translation-side PO updates.

msg2po/msgmerge.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,26 @@
2222
from msg2po.po_utils import normalize_po, po_content_snapshot
2323

2424

25+
def _strip_cr(path: str) -> None:
26+
"""Strip CRs from CRLF line endings before invoking gettext.
27+
28+
On Windows checkouts under core.autocrlf=true, .po/.pot files arrive with
29+
CRLF endings. gettext msgmerge then leaks the embedded \\r into string
30+
fields (msgids, occurrence filenames, ...), producing mixed-ending output
31+
and spurious fuzzy/duplicate entries from the polluted comparisons. The
32+
output writer itself emits only LF, so feeding pre-canonicalized input
33+
is sufficient to get clean output.
34+
"""
35+
with open(path, "rb") as f:
36+
data = f.read()
37+
if b"\r\n" in data:
38+
with open(path, "wb") as f:
39+
f.write(data.replace(b"\r\n", b"\n"))
40+
41+
2542
def merge(po_path: str, pot_path: str):
2643
logger.info(po_path)
44+
_strip_cr(po_path)
2745
exit_code = 0
2846
cmd = ["msgmerge", "--previous", "--no-wrap", "-U", "-q", "--backup=off", po_path, pot_path]
2947
res = subprocess.run(
@@ -67,6 +85,7 @@ def main():
6785

6886
# single file
6987
if (args.PO is not None) and (args.POT is not None):
88+
_strip_cr(args.POT)
7089
res = merge(args.PO, args.POT)
7190
if res != 0:
7291
logger.error(f"msgmerge failed for {args.PO}")
@@ -78,6 +97,10 @@ def main():
7897
po_files = find_files(po_dir, "po")
7998
pot_file = os.path.join(po_dir, CONFIG.src_lang + ".pot")
8099

100+
# Canonicalize POT once before the worker pool: each worker would
101+
# otherwise race to rewrite the same shared file.
102+
_strip_cr(pot_file)
103+
81104
logger.info(f"Merging PO files in {po_dir} with {pot_file}")
82105
pool = Pool()
83106
try:

tests/test_metadata_regressions.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,39 @@ def fake_run(*_args, **_kwargs):
6464
assert po[0].occurrences == [("game/one.msg", "100"), ("game/two.msg", "200")]
6565

6666

67+
class TestMsgmergeStripsCRLF:
68+
def test_merge_canonicalizes_crlf_input_to_lf(self, tmp_path, monkeypatch):
69+
"""gettext msgmerge on Windows leaks \\r from CRLF input into msgids
70+
and occurrence filenames; pre-canonicalize PO/POT before invoking it."""
71+
po_path = tmp_path / "it.po"
72+
pot_path = tmp_path / "en.pot"
73+
74+
po = polib.POFile()
75+
po.metadata = {"Content-Type": "text/plain; charset=UTF-8"}
76+
po.append(polib.POEntry(msgid="Hello", msgstr="ciao", occurrences=[("game/one.msg", "100")]))
77+
po.save(po_path)
78+
pot = polib.POFile()
79+
pot.metadata = {"Content-Type": "text/plain; charset=UTF-8"}
80+
pot.append(polib.POEntry(msgid="Hello", msgstr="", occurrences=[("game/one.msg", "100")]))
81+
pot.save(pot_path)
82+
83+
for p in (po_path, pot_path):
84+
data = p.read_bytes()
85+
p.write_bytes(data.replace(b"\n", b"\r\n"))
86+
assert b"\r\n" in p.read_bytes()
87+
88+
def fake_run(*_args, **_kwargs):
89+
return type("Result", (), {"stdout": "", "stderr": "", "returncode": 0})()
90+
91+
monkeypatch.setattr("msg2po.msgmerge.subprocess.run", fake_run)
92+
93+
merge(str(po_path), str(pot_path))
94+
95+
assert b"\r\n" not in po_path.read_bytes()
96+
# POT is canonicalized in main() outside merge() to avoid pool races,
97+
# so the subprocess-mocked single-file call here only strips the PO.
98+
99+
67100
class TestDir2MsgstrOccurrenceOnlyNormalization:
68101
def test_dir2msgstr_saves_dedup_when_only_occurrences_change(self, tmp_path, monkeypatch):
69102
po_path = tmp_path / "it.po"

0 commit comments

Comments
 (0)