Skip to content

Commit 3af1df0

Browse files
committed
Merge branch 'tk/p4-metadata-coding-strategies'
"git p4" updates. * tk/p4-metadata-coding-strategies: git-p4: improve encoding handling to support inconsistent encodings
2 parents e121c8c + f7b5ff6 commit 3af1df0

File tree

5 files changed

+572
-18
lines changed

5 files changed

+572
-18
lines changed

Documentation/git-p4.txt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,42 @@ git-p4.pathEncoding::
636636
Git expects paths encoded as UTF-8. Use this config to tell git-p4
637637
what encoding Perforce had used for the paths. This encoding is used
638638
to transcode the paths to UTF-8. As an example, Perforce on Windows
639-
often uses "cp1252" to encode path names.
639+
often uses "cp1252" to encode path names. If this option is passed
640+
into a p4 clone request, it is persisted in the resulting new git
641+
repo.
642+
643+
git-p4.metadataDecodingStrategy::
644+
Perforce keeps the encoding of a changelist descriptions and user
645+
full names as stored by the client on a given OS. The p4v client
646+
uses the OS-local encoding, and so different users can end up storing
647+
different changelist descriptions or user full names in different
648+
encodings, in the same depot.
649+
Git tolerates inconsistent/incorrect encodings in commit messages
650+
and author names, but expects them to be specified in utf-8.
651+
git-p4 can use three different decoding strategies in handling the
652+
encoding uncertainty in Perforce: 'passthrough' simply passes the
653+
original bytes through from Perforce to git, creating usable but
654+
incorrectly-encoded data when the Perforce data is encoded as
655+
anything other than utf-8. 'strict' expects the Perforce data to be
656+
encoded as utf-8, and fails to import when this is not true.
657+
'fallback' attempts to interpret the data as utf-8, and otherwise
658+
falls back to using a secondary encoding - by default the common
659+
windows encoding 'cp-1252' - with upper-range bytes escaped if
660+
decoding with the fallback encoding also fails.
661+
Under python2 the default strategy is 'passthrough' for historical
662+
reasons, and under python3 the default is 'fallback'.
663+
When 'strict' is selected and decoding fails, the error message will
664+
propose changing this config parameter as a workaround. If this
665+
option is passed into a p4 clone request, it is persisted into the
666+
resulting new git repo.
667+
668+
git-p4.metadataFallbackEncoding::
669+
Specify the fallback encoding to use when decoding Perforce author
670+
names and changelists descriptions using the 'fallback' strategy
671+
(see git-p4.metadataDecodingStrategy). The fallback encoding will
672+
only be used when decoding as utf-8 fails. This option defaults to
673+
cp1252, a common windows encoding. If this option is passed into a
674+
p4 clone request, it is persisted into the resulting new git repo.
640675

641676
git-p4.largeFileSystem::
642677
Specify the system that is used for large (binary) files. Please note

git-p4.py

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
# pylint: disable=wrong-import-position
3232
#
3333

34+
import struct
3435
import sys
3536
if sys.version_info.major < 3 and sys.version_info.minor < 7:
3637
sys.stderr.write("git-p4: requires Python 2.7 or later.\n")
@@ -71,6 +72,9 @@
7172
# The block size is reduced automatically if required
7273
defaultBlockSize = 1 << 20
7374

75+
defaultMetadataDecodingStrategy = 'passthrough' if sys.version_info.major == 2 else 'fallback'
76+
defaultFallbackMetadataEncoding = 'cp1252'
77+
7478
p4_access_checked = False
7579

7680
re_ko_keywords = re.compile(br'\$(Id|Header)(:[^$\n]+)?\$')
@@ -229,6 +233,70 @@ def encode_text_stream(s):
229233
return s.encode('utf_8') if isinstance(s, unicode) else s
230234

231235

236+
class MetadataDecodingException(Exception):
237+
def __init__(self, input_string):
238+
self.input_string = input_string
239+
240+
def __str__(self):
241+
return """Decoding perforce metadata failed!
242+
The failing string was:
243+
---
244+
{}
245+
---
246+
Consider setting the git-p4.metadataDecodingStrategy config option to
247+
'fallback', to allow metadata to be decoded using a fallback encoding,
248+
defaulting to cp1252.""".format(self.input_string)
249+
250+
251+
encoding_fallback_warning_issued = False
252+
encoding_escape_warning_issued = False
253+
def metadata_stream_to_writable_bytes(s):
254+
encodingStrategy = gitConfig('git-p4.metadataDecodingStrategy') or defaultMetadataDecodingStrategy
255+
fallbackEncoding = gitConfig('git-p4.metadataFallbackEncoding') or defaultFallbackMetadataEncoding
256+
if not isinstance(s, bytes):
257+
return s.encode('utf_8')
258+
if encodingStrategy == 'passthrough':
259+
return s
260+
try:
261+
s.decode('utf_8')
262+
return s
263+
except UnicodeDecodeError:
264+
if encodingStrategy == 'fallback' and fallbackEncoding:
265+
global encoding_fallback_warning_issued
266+
global encoding_escape_warning_issued
267+
try:
268+
if not encoding_fallback_warning_issued:
269+
print("\nCould not decode value as utf-8; using configured fallback encoding %s: %s" % (fallbackEncoding, s))
270+
print("\n(this warning is only displayed once during an import)")
271+
encoding_fallback_warning_issued = True
272+
return s.decode(fallbackEncoding).encode('utf_8')
273+
except Exception as exc:
274+
if not encoding_escape_warning_issued:
275+
print("\nCould not decode value with configured fallback encoding %s; escaping bytes over 127: %s" % (fallbackEncoding, s))
276+
print("\n(this warning is only displayed once during an import)")
277+
encoding_escape_warning_issued = True
278+
escaped_bytes = b''
279+
# bytes and strings work very differently in python2 vs python3...
280+
if str is bytes:
281+
for byte in s:
282+
byte_number = struct.unpack('>B', byte)[0]
283+
if byte_number > 127:
284+
escaped_bytes += b'%'
285+
escaped_bytes += hex(byte_number)[2:].upper()
286+
else:
287+
escaped_bytes += byte
288+
else:
289+
for byte_number in s:
290+
if byte_number > 127:
291+
escaped_bytes += b'%'
292+
escaped_bytes += hex(byte_number).upper().encode()[2:]
293+
else:
294+
escaped_bytes += bytes([byte_number])
295+
return escaped_bytes
296+
297+
raise MetadataDecodingException(s)
298+
299+
232300
def decode_path(path):
233301
"""Decode a given string (bytes or otherwise) using configured path
234302
encoding options.
@@ -786,11 +854,12 @@ def p4CmdList(cmd, stdin=None, stdin_mode='w+b', cb=None, skip_info=False,
786854
if bytes is not str:
787855
# Decode unmarshalled dict to use str keys and values, except for:
788856
# - `data` which may contain arbitrary binary data
789-
# - `depotFile[0-9]*`, `path`, or `clientFile` which may contain non-UTF8 encoded text
857+
# - `desc` or `FullName` which may contain non-UTF8 encoded text handled below, eagerly converted to bytes
858+
# - `depotFile[0-9]*`, `path`, or `clientFile` which may contain non-UTF8 encoded text, handled by decode_path()
790859
decoded_entry = {}
791860
for key, value in entry.items():
792861
key = key.decode()
793-
if isinstance(value, bytes) and not (key in ('data', 'path', 'clientFile') or key.startswith('depotFile')):
862+
if isinstance(value, bytes) and not (key in ('data', 'desc', 'FullName', 'path', 'clientFile') or key.startswith('depotFile')):
794863
value = value.decode()
795864
decoded_entry[key] = value
796865
# Parse out data if it's an error response
@@ -800,6 +869,10 @@ def p4CmdList(cmd, stdin=None, stdin_mode='w+b', cb=None, skip_info=False,
800869
if skip_info:
801870
if 'code' in entry and entry['code'] == 'info':
802871
continue
872+
if 'desc' in entry:
873+
entry['desc'] = metadata_stream_to_writable_bytes(entry['desc'])
874+
if 'FullName' in entry:
875+
entry['FullName'] = metadata_stream_to_writable_bytes(entry['FullName'])
803876
if cb is not None:
804877
cb(entry)
805878
else:
@@ -1603,7 +1676,13 @@ def getUserMapFromPerforceServer(self):
16031676
for output in p4CmdList(["users"]):
16041677
if "User" not in output:
16051678
continue
1606-
self.users[output["User"]] = output["FullName"] + " <" + output["Email"] + ">"
1679+
# "FullName" is bytes. "Email" on the other hand might be bytes
1680+
# or unicode string depending on whether we are running under
1681+
# python2 or python3. To support
1682+
# git-p4.metadataDecodingStrategy=fallback, self.users dict values
1683+
# are always bytes, ready to be written to git.
1684+
emailbytes = metadata_stream_to_writable_bytes(output["Email"])
1685+
self.users[output["User"]] = output["FullName"] + b" <" + emailbytes + b">"
16071686
self.emails[output["Email"]] = output["User"]
16081687

16091688
mapUserConfigRegex = re.compile(r"^\s*(\S+)\s*=\s*(.+)\s*<(\S+)>\s*$", re.VERBOSE)
@@ -1613,26 +1692,28 @@ def getUserMapFromPerforceServer(self):
16131692
user = mapUser[0][0]
16141693
fullname = mapUser[0][1]
16151694
email = mapUser[0][2]
1616-
self.users[user] = fullname + " <" + email + ">"
1695+
fulluser = fullname + " <" + email + ">"
1696+
self.users[user] = metadata_stream_to_writable_bytes(fulluser)
16171697
self.emails[email] = user
16181698

1619-
s = ''
1699+
s = b''
16201700
for (key, val) in self.users.items():
1621-
s += "%s\t%s\n" % (key.expandtabs(1), val.expandtabs(1))
1701+
keybytes = metadata_stream_to_writable_bytes(key)
1702+
s += b"%s\t%s\n" % (keybytes.expandtabs(1), val.expandtabs(1))
16221703

1623-
open(self.getUserCacheFilename(), 'w').write(s)
1704+
open(self.getUserCacheFilename(), 'wb').write(s)
16241705
self.userMapFromPerforceServer = True
16251706

16261707
def loadUserMapFromCache(self):
16271708
self.users = {}
16281709
self.userMapFromPerforceServer = False
16291710
try:
1630-
cache = open(self.getUserCacheFilename(), 'r')
1711+
cache = open(self.getUserCacheFilename(), 'rb')
16311712
lines = cache.readlines()
16321713
cache.close()
16331714
for line in lines:
1634-
entry = line.strip().split("\t")
1635-
self.users[entry[0]] = entry[1]
1715+
entry = line.strip().split(b"\t")
1716+
self.users[entry[0].decode('utf_8')] = entry[1]
16361717
except IOError:
16371718
self.getUserMapFromPerforceServer()
16381719

@@ -3229,7 +3310,8 @@ def make_email(self, userid):
32293310
if userid in self.users:
32303311
return self.users[userid]
32313312
else:
3232-
return "%s <a@b>" % userid
3313+
userid_bytes = metadata_stream_to_writable_bytes(userid)
3314+
return b"%s <a@b>" % userid_bytes
32333315

32343316
def streamTag(self, gitStream, labelName, labelDetails, commit, epoch):
32353317
"""Stream a p4 tag.
@@ -3253,9 +3335,10 @@ def streamTag(self, gitStream, labelName, labelDetails, commit, epoch):
32533335
email = self.make_email(owner)
32543336
else:
32553337
email = self.make_email(self.p4UserId())
3256-
tagger = "%s %s %s" % (email, epoch, self.tz)
32573338

3258-
gitStream.write("tagger %s\n" % tagger)
3339+
gitStream.write("tagger ")
3340+
gitStream.write(email)
3341+
gitStream.write(" %s %s\n" % (epoch, self.tz))
32593342

32603343
print("labelDetails=", labelDetails)
32613344
if 'Description' in labelDetails:
@@ -3351,12 +3434,12 @@ def commit(self, details, files, branch, parent="", allow_empty=False):
33513434
self.gitStream.write("commit %s\n" % branch)
33523435
self.gitStream.write("mark :%s\n" % details["change"])
33533436
self.committedChanges.add(int(details["change"]))
3354-
committer = ""
33553437
if author not in self.users:
33563438
self.getUserMapFromPerforceServer()
3357-
committer = "%s %s %s" % (self.make_email(author), epoch, self.tz)
33583439

3359-
self.gitStream.write("committer %s\n" % committer)
3440+
self.gitStream.write("committer ")
3441+
self.gitStream.write(self.make_email(author))
3442+
self.gitStream.write(" %s %s\n" % (epoch, self.tz))
33603443

33613444
self.gitStream.write("data <<EOT\n")
33623445
self.gitStream.write(details["desc"])
@@ -4257,6 +4340,14 @@ def run(self, args):
42574340
if self.useClientSpec_from_options:
42584341
system(["git", "config", "--bool", "git-p4.useclientspec", "true"])
42594342

4343+
# persist any git-p4 encoding-handling config options passed in for clone:
4344+
if gitConfig('git-p4.metadataDecodingStrategy'):
4345+
system(["git", "config", "git-p4.metadataDecodingStrategy", gitConfig('git-p4.metadataDecodingStrategy')])
4346+
if gitConfig('git-p4.metadataFallbackEncoding'):
4347+
system(["git", "config", "git-p4.metadataFallbackEncoding", gitConfig('git-p4.metadataFallbackEncoding')])
4348+
if gitConfig('git-p4.pathEncoding'):
4349+
system(["git", "config", "git-p4.pathEncoding", gitConfig('git-p4.pathEncoding')])
4350+
42604351
return True
42614352

42624353

t/lib-git-p4.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,11 @@ start_p4d () {
142142

143143
p4_add_user () {
144144
name=$1 &&
145+
fullname="${2:-Dr. $1}"
145146
p4 user -f -i <<-EOF
146147
User: $name
147148
Email: $name@example.com
148-
FullName: Dr. $name
149+
FullName: $fullname
149150
EOF
150151
}
151152

0 commit comments

Comments
 (0)