mirror of https://github.com/git/git.git
4592 lines
168 KiB
Python
Executable File
4592 lines
168 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# git-p4.py -- A tool for bidirectional operation between a Perforce depot and git.
|
|
#
|
|
# Author: Simon Hausmann <simon@lst.de>
|
|
# Copyright: 2007 Simon Hausmann <simon@lst.de>
|
|
# 2007 Trolltech ASA
|
|
# License: MIT <http://www.opensource.org/licenses/mit-license.php>
|
|
#
|
|
# pylint: disable=bad-whitespace
|
|
# pylint: disable=broad-except
|
|
# pylint: disable=consider-iterating-dictionary
|
|
# pylint: disable=disable
|
|
# pylint: disable=fixme
|
|
# pylint: disable=invalid-name
|
|
# pylint: disable=line-too-long
|
|
# pylint: disable=missing-docstring
|
|
# pylint: disable=no-self-use
|
|
# pylint: disable=superfluous-parens
|
|
# pylint: disable=too-few-public-methods
|
|
# pylint: disable=too-many-arguments
|
|
# pylint: disable=too-many-branches
|
|
# pylint: disable=too-many-instance-attributes
|
|
# pylint: disable=too-many-lines
|
|
# pylint: disable=too-many-locals
|
|
# pylint: disable=too-many-nested-blocks
|
|
# pylint: disable=too-many-statements
|
|
# pylint: disable=ungrouped-imports
|
|
# pylint: disable=unused-import
|
|
# pylint: disable=wrong-import-order
|
|
# pylint: disable=wrong-import-position
|
|
#
|
|
|
|
import struct
|
|
import sys
|
|
if sys.version_info.major < 3 and sys.version_info.minor < 7:
|
|
sys.stderr.write("git-p4: requires Python 2.7 or later.\n")
|
|
sys.exit(1)
|
|
|
|
import ctypes
|
|
import errno
|
|
import functools
|
|
import glob
|
|
import marshal
|
|
import optparse
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
import zlib
|
|
|
|
# On python2.7 where raw_input() and input() are both availble,
|
|
# we want raw_input's semantics, but aliased to input for python3
|
|
# compatibility
|
|
# support basestring in python3
|
|
try:
|
|
if raw_input and input:
|
|
input = raw_input
|
|
except:
|
|
pass
|
|
|
|
verbose = False
|
|
|
|
# Only labels/tags matching this will be imported/exported
|
|
defaultLabelRegexp = r'[a-zA-Z0-9_\-.]+$'
|
|
|
|
# The block size is reduced automatically if required
|
|
defaultBlockSize = 1 << 20
|
|
|
|
defaultMetadataDecodingStrategy = 'passthrough' if sys.version_info.major == 2 else 'fallback'
|
|
defaultFallbackMetadataEncoding = 'cp1252'
|
|
|
|
p4_access_checked = False
|
|
|
|
re_ko_keywords = re.compile(br'\$(Id|Header)(:[^$\n]+)?\$')
|
|
re_k_keywords = re.compile(br'\$(Id|Header|Author|Date|DateTime|Change|File|Revision)(:[^$\n]+)?\$')
|
|
|
|
|
|
def format_size_human_readable(num):
|
|
"""Returns a number of units (typically bytes) formatted as a
|
|
human-readable string.
|
|
"""
|
|
if num < 1024:
|
|
return '{:d} B'.format(num)
|
|
for unit in ["Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
|
|
num /= 1024.0
|
|
if num < 1024.0:
|
|
return "{:3.1f} {}B".format(num, unit)
|
|
return "{:.1f} YiB".format(num)
|
|
|
|
|
|
def p4_build_cmd(cmd):
|
|
"""Build a suitable p4 command line.
|
|
|
|
This consolidates building and returning a p4 command line into one
|
|
location. It means that hooking into the environment, or other
|
|
configuration can be done more easily.
|
|
"""
|
|
real_cmd = ["p4"]
|
|
|
|
user = gitConfig("git-p4.user")
|
|
if len(user) > 0:
|
|
real_cmd += ["-u", user]
|
|
|
|
password = gitConfig("git-p4.password")
|
|
if len(password) > 0:
|
|
real_cmd += ["-P", password]
|
|
|
|
port = gitConfig("git-p4.port")
|
|
if len(port) > 0:
|
|
real_cmd += ["-p", port]
|
|
|
|
host = gitConfig("git-p4.host")
|
|
if len(host) > 0:
|
|
real_cmd += ["-H", host]
|
|
|
|
client = gitConfig("git-p4.client")
|
|
if len(client) > 0:
|
|
real_cmd += ["-c", client]
|
|
|
|
retries = gitConfigInt("git-p4.retries")
|
|
if retries is None:
|
|
# Perform 3 retries by default
|
|
retries = 3
|
|
if retries > 0:
|
|
# Provide a way to not pass this option by setting git-p4.retries to 0
|
|
real_cmd += ["-r", str(retries)]
|
|
|
|
real_cmd += cmd
|
|
|
|
# now check that we can actually talk to the server
|
|
global p4_access_checked
|
|
if not p4_access_checked:
|
|
p4_access_checked = True # suppress access checks in p4_check_access itself
|
|
p4_check_access()
|
|
|
|
return real_cmd
|
|
|
|
|
|
def git_dir(path):
|
|
"""Return TRUE if the given path is a git directory (/path/to/dir/.git).
|
|
This won't automatically add ".git" to a directory.
|
|
"""
|
|
d = read_pipe(["git", "--git-dir", path, "rev-parse", "--git-dir"], True).strip()
|
|
if not d or len(d) == 0:
|
|
return None
|
|
else:
|
|
return d
|
|
|
|
|
|
def chdir(path, is_client_path=False):
|
|
"""Do chdir to the given path, and set the PWD environment variable for use
|
|
by P4. It does not look at getcwd() output. Since we're not using the
|
|
shell, it is necessary to set the PWD environment variable explicitly.
|
|
|
|
Normally, expand the path to force it to be absolute. This addresses
|
|
the use of relative path names inside P4 settings, e.g.
|
|
P4CONFIG=.p4config. P4 does not simply open the filename as given; it
|
|
looks for .p4config using PWD.
|
|
|
|
If is_client_path, the path was handed to us directly by p4, and may be
|
|
a symbolic link. Do not call os.getcwd() in this case, because it will
|
|
cause p4 to think that PWD is not inside the client path.
|
|
"""
|
|
|
|
os.chdir(path)
|
|
if not is_client_path:
|
|
path = os.getcwd()
|
|
os.environ['PWD'] = path
|
|
|
|
|
|
def calcDiskFree():
|
|
"""Return free space in bytes on the disk of the given dirname."""
|
|
if platform.system() == 'Windows':
|
|
free_bytes = ctypes.c_ulonglong(0)
|
|
ctypes.windll.kernel32.GetDiskFreeSpaceExW(ctypes.c_wchar_p(os.getcwd()), None, None, ctypes.pointer(free_bytes))
|
|
return free_bytes.value
|
|
else:
|
|
st = os.statvfs(os.getcwd())
|
|
return st.f_bavail * st.f_frsize
|
|
|
|
|
|
def die(msg):
|
|
"""Terminate execution. Make sure that any running child processes have
|
|
been wait()ed for before calling this.
|
|
"""
|
|
if verbose:
|
|
raise Exception(msg)
|
|
else:
|
|
sys.stderr.write(msg + "\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def prompt(prompt_text):
|
|
"""Prompt the user to choose one of the choices.
|
|
|
|
Choices are identified in the prompt_text by square brackets around a
|
|
single letter option.
|
|
"""
|
|
choices = set(m.group(1) for m in re.finditer(r"\[(.)\]", prompt_text))
|
|
while True:
|
|
sys.stderr.flush()
|
|
sys.stdout.write(prompt_text)
|
|
sys.stdout.flush()
|
|
response = sys.stdin.readline().strip().lower()
|
|
if not response:
|
|
continue
|
|
response = response[0]
|
|
if response in choices:
|
|
return response
|
|
|
|
|
|
# We need different encoding/decoding strategies for text data being passed
|
|
# around in pipes depending on python version
|
|
if bytes is not str:
|
|
# For python3, always encode and decode as appropriate
|
|
def decode_text_stream(s):
|
|
return s.decode() if isinstance(s, bytes) else s
|
|
|
|
def encode_text_stream(s):
|
|
return s.encode() if isinstance(s, str) else s
|
|
else:
|
|
# For python2.7, pass read strings as-is, but also allow writing unicode
|
|
def decode_text_stream(s):
|
|
return s
|
|
|
|
def encode_text_stream(s):
|
|
return s.encode('utf_8') if isinstance(s, unicode) else s
|
|
|
|
|
|
class MetadataDecodingException(Exception):
|
|
def __init__(self, input_string):
|
|
self.input_string = input_string
|
|
|
|
def __str__(self):
|
|
return """Decoding perforce metadata failed!
|
|
The failing string was:
|
|
---
|
|
{}
|
|
---
|
|
Consider setting the git-p4.metadataDecodingStrategy config option to
|
|
'fallback', to allow metadata to be decoded using a fallback encoding,
|
|
defaulting to cp1252.""".format(self.input_string)
|
|
|
|
|
|
encoding_fallback_warning_issued = False
|
|
encoding_escape_warning_issued = False
|
|
def metadata_stream_to_writable_bytes(s):
|
|
encodingStrategy = gitConfig('git-p4.metadataDecodingStrategy') or defaultMetadataDecodingStrategy
|
|
fallbackEncoding = gitConfig('git-p4.metadataFallbackEncoding') or defaultFallbackMetadataEncoding
|
|
if not isinstance(s, bytes):
|
|
return s.encode('utf_8')
|
|
if encodingStrategy == 'passthrough':
|
|
return s
|
|
try:
|
|
s.decode('utf_8')
|
|
return s
|
|
except UnicodeDecodeError:
|
|
if encodingStrategy == 'fallback' and fallbackEncoding:
|
|
global encoding_fallback_warning_issued
|
|
global encoding_escape_warning_issued
|
|
try:
|
|
if not encoding_fallback_warning_issued:
|
|
print("\nCould not decode value as utf-8; using configured fallback encoding %s: %s" % (fallbackEncoding, s))
|
|
print("\n(this warning is only displayed once during an import)")
|
|
encoding_fallback_warning_issued = True
|
|
return s.decode(fallbackEncoding).encode('utf_8')
|
|
except Exception as exc:
|
|
if not encoding_escape_warning_issued:
|
|
print("\nCould not decode value with configured fallback encoding %s; escaping bytes over 127: %s" % (fallbackEncoding, s))
|
|
print("\n(this warning is only displayed once during an import)")
|
|
encoding_escape_warning_issued = True
|
|
escaped_bytes = b''
|
|
# bytes and strings work very differently in python2 vs python3...
|
|
if str is bytes:
|
|
for byte in s:
|
|
byte_number = struct.unpack('>B', byte)[0]
|
|
if byte_number > 127:
|
|
escaped_bytes += b'%'
|
|
escaped_bytes += hex(byte_number)[2:].upper()
|
|
else:
|
|
escaped_bytes += byte
|
|
else:
|
|
for byte_number in s:
|
|
if byte_number > 127:
|
|
escaped_bytes += b'%'
|
|
escaped_bytes += hex(byte_number).upper().encode()[2:]
|
|
else:
|
|
escaped_bytes += bytes([byte_number])
|
|
return escaped_bytes
|
|
|
|
raise MetadataDecodingException(s)
|
|
|
|
|
|
def decode_path(path):
|
|
"""Decode a given string (bytes or otherwise) using configured path
|
|
encoding options.
|
|
"""
|
|
|
|
encoding = gitConfig('git-p4.pathEncoding') or 'utf_8'
|
|
if bytes is not str:
|
|
return path.decode(encoding, errors='replace') if isinstance(path, bytes) else path
|
|
else:
|
|
try:
|
|
path.decode('ascii')
|
|
except:
|
|
path = path.decode(encoding, errors='replace')
|
|
if verbose:
|
|
print('Path with non-ASCII characters detected. Used {} to decode: {}'.format(encoding, path))
|
|
return path
|
|
|
|
|
|
def run_git_hook(cmd, param=[]):
|
|
"""Execute a hook if the hook exists."""
|
|
args = ['git', 'hook', 'run', '--ignore-missing', cmd]
|
|
if param:
|
|
args.append("--")
|
|
for p in param:
|
|
args.append(p)
|
|
return subprocess.call(args) == 0
|
|
|
|
|
|
def write_pipe(c, stdin, *k, **kw):
|
|
if verbose:
|
|
sys.stderr.write('Writing pipe: {}\n'.format(' '.join(c)))
|
|
|
|
p = subprocess.Popen(c, stdin=subprocess.PIPE, *k, **kw)
|
|
pipe = p.stdin
|
|
val = pipe.write(stdin)
|
|
pipe.close()
|
|
if p.wait():
|
|
die('Command failed: {}'.format(' '.join(c)))
|
|
|
|
return val
|
|
|
|
|
|
def p4_write_pipe(c, stdin, *k, **kw):
|
|
real_cmd = p4_build_cmd(c)
|
|
if bytes is not str and isinstance(stdin, str):
|
|
stdin = encode_text_stream(stdin)
|
|
return write_pipe(real_cmd, stdin, *k, **kw)
|
|
|
|
|
|
def read_pipe_full(c, *k, **kw):
|
|
"""Read output from command. Returns a tuple of the return status, stdout
|
|
text and stderr text.
|
|
"""
|
|
if verbose:
|
|
sys.stderr.write('Reading pipe: {}\n'.format(' '.join(c)))
|
|
|
|
p = subprocess.Popen(
|
|
c, stdout=subprocess.PIPE, stderr=subprocess.PIPE, *k, **kw)
|
|
out, err = p.communicate()
|
|
return (p.returncode, out, decode_text_stream(err))
|
|
|
|
|
|
def read_pipe(c, ignore_error=False, raw=False, *k, **kw):
|
|
"""Read output from command. Returns the output text on success. On
|
|
failure, terminates execution, unless ignore_error is True, when it
|
|
returns an empty string.
|
|
|
|
If raw is True, do not attempt to decode output text.
|
|
"""
|
|
retcode, out, err = read_pipe_full(c, *k, **kw)
|
|
if retcode != 0:
|
|
if ignore_error:
|
|
out = ""
|
|
else:
|
|
die('Command failed: {}\nError: {}'.format(' '.join(c), err))
|
|
if not raw:
|
|
out = decode_text_stream(out)
|
|
return out
|
|
|
|
|
|
def read_pipe_text(c, *k, **kw):
|
|
"""Read output from a command with trailing whitespace stripped. On error,
|
|
returns None.
|
|
"""
|
|
retcode, out, err = read_pipe_full(c, *k, **kw)
|
|
if retcode != 0:
|
|
return None
|
|
else:
|
|
return decode_text_stream(out).rstrip()
|
|
|
|
|
|
def p4_read_pipe(c, ignore_error=False, raw=False, *k, **kw):
|
|
real_cmd = p4_build_cmd(c)
|
|
return read_pipe(real_cmd, ignore_error, raw=raw, *k, **kw)
|
|
|
|
|
|
def read_pipe_lines(c, raw=False, *k, **kw):
|
|
if verbose:
|
|
sys.stderr.write('Reading pipe: {}\n'.format(' '.join(c)))
|
|
|
|
p = subprocess.Popen(c, stdout=subprocess.PIPE, *k, **kw)
|
|
pipe = p.stdout
|
|
lines = pipe.readlines()
|
|
if not raw:
|
|
lines = [decode_text_stream(line) for line in lines]
|
|
if pipe.close() or p.wait():
|
|
die('Command failed: {}'.format(' '.join(c)))
|
|
return lines
|
|
|
|
|
|
def p4_read_pipe_lines(c, *k, **kw):
|
|
"""Specifically invoke p4 on the command supplied."""
|
|
real_cmd = p4_build_cmd(c)
|
|
return read_pipe_lines(real_cmd, *k, **kw)
|
|
|
|
|
|
def p4_has_command(cmd):
|
|
"""Ask p4 for help on this command. If it returns an error, the command
|
|
does not exist in this version of p4.
|
|
"""
|
|
real_cmd = p4_build_cmd(["help", cmd])
|
|
p = subprocess.Popen(real_cmd, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
p.communicate()
|
|
return p.returncode == 0
|
|
|
|
|
|
def p4_has_move_command():
|
|
"""See if the move command exists, that it supports -k, and that it has not
|
|
been administratively disabled. The arguments must be correct, but the
|
|
filenames do not have to exist. Use ones with wildcards so even if they
|
|
exist, it will fail.
|
|
"""
|
|
|
|
if not p4_has_command("move"):
|
|
return False
|
|
cmd = p4_build_cmd(["move", "-k", "@from", "@to"])
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
out, err = p.communicate()
|
|
err = decode_text_stream(err)
|
|
# return code will be 1 in either case
|
|
if err.find("Invalid option") >= 0:
|
|
return False
|
|
if err.find("disabled") >= 0:
|
|
return False
|
|
# assume it failed because @... was invalid changelist
|
|
return True
|
|
|
|
|
|
def system(cmd, ignore_error=False, *k, **kw):
|
|
if verbose:
|
|
sys.stderr.write("executing {}\n".format(
|
|
' '.join(cmd) if isinstance(cmd, list) else cmd))
|
|
retcode = subprocess.call(cmd, *k, **kw)
|
|
if retcode and not ignore_error:
|
|
raise subprocess.CalledProcessError(retcode, cmd)
|
|
|
|
return retcode
|
|
|
|
|
|
def p4_system(cmd, *k, **kw):
|
|
"""Specifically invoke p4 as the system command."""
|
|
real_cmd = p4_build_cmd(cmd)
|
|
retcode = subprocess.call(real_cmd, *k, **kw)
|
|
if retcode:
|
|
raise subprocess.CalledProcessError(retcode, real_cmd)
|
|
|
|
|
|
def die_bad_access(s):
|
|
die("failure accessing depot: {0}".format(s.rstrip()))
|
|
|
|
|
|
def p4_check_access(min_expiration=1):
|
|
"""Check if we can access Perforce - account still logged in."""
|
|
|
|
results = p4CmdList(["login", "-s"])
|
|
|
|
if len(results) == 0:
|
|
# should never get here: always get either some results, or a p4ExitCode
|
|
assert("could not parse response from perforce")
|
|
|
|
result = results[0]
|
|
|
|
if 'p4ExitCode' in result:
|
|
# p4 returned non-zero status, e.g. P4PORT invalid, or p4 not in path
|
|
die_bad_access("could not run p4")
|
|
|
|
code = result.get("code")
|
|
if not code:
|
|
# we get here if we couldn't connect and there was nothing to unmarshal
|
|
die_bad_access("could not connect")
|
|
|
|
elif code == "stat":
|
|
expiry = result.get("TicketExpiration")
|
|
if expiry:
|
|
expiry = int(expiry)
|
|
if expiry > min_expiration:
|
|
# ok to carry on
|
|
return
|
|
else:
|
|
die_bad_access("perforce ticket expires in {0} seconds".format(expiry))
|
|
|
|
else:
|
|
# account without a timeout - all ok
|
|
return
|
|
|
|
elif code == "error":
|
|
data = result.get("data")
|
|
if data:
|
|
die_bad_access("p4 error: {0}".format(data))
|
|
else:
|
|
die_bad_access("unknown error")
|
|
elif code == "info":
|
|
return
|
|
else:
|
|
die_bad_access("unknown error code {0}".format(code))
|
|
|
|
|
|
_p4_version_string = None
|
|
|
|
|
|
def p4_version_string():
|
|
"""Read the version string, showing just the last line, which hopefully is
|
|
the interesting version bit.
|
|
|
|
$ p4 -V
|
|
Perforce - The Fast Software Configuration Management System.
|
|
Copyright 1995-2011 Perforce Software. All rights reserved.
|
|
Rev. P4/NTX86/2011.1/393975 (2011/12/16).
|
|
"""
|
|
global _p4_version_string
|
|
if not _p4_version_string:
|
|
a = p4_read_pipe_lines(["-V"])
|
|
_p4_version_string = a[-1].rstrip()
|
|
return _p4_version_string
|
|
|
|
|
|
def p4_integrate(src, dest):
|
|
p4_system(["integrate", "-Dt", wildcard_encode(src), wildcard_encode(dest)])
|
|
|
|
|
|
def p4_sync(f, *options):
|
|
p4_system(["sync"] + list(options) + [wildcard_encode(f)])
|
|
|
|
|
|
def p4_add(f):
|
|
"""Forcibly add file names with wildcards."""
|
|
if wildcard_present(f):
|
|
p4_system(["add", "-f", f])
|
|
else:
|
|
p4_system(["add", f])
|
|
|
|
|
|
def p4_delete(f):
|
|
p4_system(["delete", wildcard_encode(f)])
|
|
|
|
|
|
def p4_edit(f, *options):
|
|
p4_system(["edit"] + list(options) + [wildcard_encode(f)])
|
|
|
|
|
|
def p4_revert(f):
|
|
p4_system(["revert", wildcard_encode(f)])
|
|
|
|
|
|
def p4_reopen(type, f):
|
|
p4_system(["reopen", "-t", type, wildcard_encode(f)])
|
|
|
|
|
|
def p4_reopen_in_change(changelist, files):
|
|
cmd = ["reopen", "-c", str(changelist)] + files
|
|
p4_system(cmd)
|
|
|
|
|
|
def p4_move(src, dest):
|
|
p4_system(["move", "-k", wildcard_encode(src), wildcard_encode(dest)])
|
|
|
|
|
|
def p4_last_change():
|
|
results = p4CmdList(["changes", "-m", "1"], skip_info=True)
|
|
return int(results[0]['change'])
|
|
|
|
|
|
def p4_describe(change, shelved=False):
|
|
"""Make sure it returns a valid result by checking for the presence of
|
|
field "time".
|
|
|
|
Return a dict of the results.
|
|
"""
|
|
|
|
cmd = ["describe", "-s"]
|
|
if shelved:
|
|
cmd += ["-S"]
|
|
cmd += [str(change)]
|
|
|
|
ds = p4CmdList(cmd, skip_info=True)
|
|
if len(ds) != 1:
|
|
die("p4 describe -s %d did not return 1 result: %s" % (change, str(ds)))
|
|
|
|
d = ds[0]
|
|
|
|
if "p4ExitCode" in d:
|
|
die("p4 describe -s %d exited with %d: %s" % (change, d["p4ExitCode"],
|
|
str(d)))
|
|
if "code" in d:
|
|
if d["code"] == "error":
|
|
die("p4 describe -s %d returned error code: %s" % (change, str(d)))
|
|
|
|
if "time" not in d:
|
|
die("p4 describe -s %d returned no \"time\": %s" % (change, str(d)))
|
|
|
|
return d
|
|
|
|
|
|
def split_p4_type(p4type):
|
|
"""Canonicalize the p4 type and return a tuple of the base type, plus any
|
|
modifiers. See "p4 help filetypes" for a list and explanation.
|
|
"""
|
|
|
|
p4_filetypes_historical = {
|
|
"ctempobj": "binary+Sw",
|
|
"ctext": "text+C",
|
|
"cxtext": "text+Cx",
|
|
"ktext": "text+k",
|
|
"kxtext": "text+kx",
|
|
"ltext": "text+F",
|
|
"tempobj": "binary+FSw",
|
|
"ubinary": "binary+F",
|
|
"uresource": "resource+F",
|
|
"uxbinary": "binary+Fx",
|
|
"xbinary": "binary+x",
|
|
"xltext": "text+Fx",
|
|
"xtempobj": "binary+Swx",
|
|
"xtext": "text+x",
|
|
"xunicode": "unicode+x",
|
|
"xutf16": "utf16+x",
|
|
}
|
|
if p4type in p4_filetypes_historical:
|
|
p4type = p4_filetypes_historical[p4type]
|
|
mods = ""
|
|
s = p4type.split("+")
|
|
base = s[0]
|
|
mods = ""
|
|
if len(s) > 1:
|
|
mods = s[1]
|
|
return (base, mods)
|
|
|
|
|
|
def p4_type(f):
|
|
"""Return the raw p4 type of a file (text, text+ko, etc)."""
|
|
|
|
results = p4CmdList(["fstat", "-T", "headType", wildcard_encode(f)])
|
|
return results[0]['headType']
|
|
|
|
|
|
def p4_keywords_regexp_for_type(base, type_mods):
|
|
"""Given a type base and modifier, return a regexp matching the keywords
|
|
that can be expanded in the file.
|
|
"""
|
|
|
|
if base in ("text", "unicode", "binary"):
|
|
if "ko" in type_mods:
|
|
return re_ko_keywords
|
|
elif "k" in type_mods:
|
|
return re_k_keywords
|
|
else:
|
|
return None
|
|
else:
|
|
return None
|
|
|
|
|
|
def p4_keywords_regexp_for_file(file):
|
|
"""Given a file, return a regexp matching the possible RCS keywords that
|
|
will be expanded, or None for files with kw expansion turned off.
|
|
"""
|
|
|
|
if not os.path.exists(file):
|
|
return None
|
|
else:
|
|
type_base, type_mods = split_p4_type(p4_type(file))
|
|
return p4_keywords_regexp_for_type(type_base, type_mods)
|
|
|
|
|
|
def setP4ExecBit(file, mode):
|
|
"""Reopens an already open file and changes the execute bit to match the
|
|
execute bit setting in the passed in mode.
|
|
"""
|
|
|
|
p4Type = "+x"
|
|
|
|
if not isModeExec(mode):
|
|
p4Type = getP4OpenedType(file)
|
|
p4Type = re.sub('^([cku]?)x(.*)', '\\1\\2', p4Type)
|
|
p4Type = re.sub('(.*?\+.*?)x(.*?)', '\\1\\2', p4Type)
|
|
if p4Type[-1] == "+":
|
|
p4Type = p4Type[0:-1]
|
|
|
|
p4_reopen(p4Type, file)
|
|
|
|
|
|
def getP4OpenedType(file):
|
|
"""Returns the perforce file type for the given file."""
|
|
|
|
result = p4_read_pipe(["opened", wildcard_encode(file)])
|
|
match = re.match(".*\((.+)\)( \*exclusive\*)?\r?$", result)
|
|
if match:
|
|
return match.group(1)
|
|
else:
|
|
die("Could not determine file type for %s (result: '%s')" % (file, result))
|
|
|
|
|
|
def getP4Labels(depotPaths):
|
|
"""Return the set of all p4 labels."""
|
|
|
|
labels = set()
|
|
if not isinstance(depotPaths, list):
|
|
depotPaths = [depotPaths]
|
|
|
|
for l in p4CmdList(["labels"] + ["%s..." % p for p in depotPaths]):
|
|
label = l['label']
|
|
labels.add(label)
|
|
|
|
return labels
|
|
|
|
|
|
def getGitTags():
|
|
"""Return the set of all git tags."""
|
|
|
|
gitTags = set()
|
|
for line in read_pipe_lines(["git", "tag"]):
|
|
tag = line.strip()
|
|
gitTags.add(tag)
|
|
return gitTags
|
|
|
|
|
|
_diff_tree_pattern = None
|
|
|
|
|
|
def parseDiffTreeEntry(entry):
|
|
"""Parses a single diff tree entry into its component elements.
|
|
|
|
See git-diff-tree(1) manpage for details about the format of the diff
|
|
output. This method returns a dictionary with the following elements:
|
|
|
|
src_mode - The mode of the source file
|
|
dst_mode - The mode of the destination file
|
|
src_sha1 - The sha1 for the source file
|
|
dst_sha1 - The sha1 fr the destination file
|
|
status - The one letter status of the diff (i.e. 'A', 'M', 'D', etc)
|
|
status_score - The score for the status (applicable for 'C' and 'R'
|
|
statuses). This is None if there is no score.
|
|
src - The path for the source file.
|
|
dst - The path for the destination file. This is only present for
|
|
copy or renames. If it is not present, this is None.
|
|
|
|
If the pattern is not matched, None is returned.
|
|
"""
|
|
|
|
global _diff_tree_pattern
|
|
if not _diff_tree_pattern:
|
|
_diff_tree_pattern = re.compile(':(\d+) (\d+) (\w+) (\w+) ([A-Z])(\d+)?\t(.*?)((\t(.*))|$)')
|
|
|
|
match = _diff_tree_pattern.match(entry)
|
|
if match:
|
|
return {
|
|
'src_mode': match.group(1),
|
|
'dst_mode': match.group(2),
|
|
'src_sha1': match.group(3),
|
|
'dst_sha1': match.group(4),
|
|
'status': match.group(5),
|
|
'status_score': match.group(6),
|
|
'src': match.group(7),
|
|
'dst': match.group(10)
|
|
}
|
|
return None
|
|
|
|
|
|
def isModeExec(mode):
|
|
"""Returns True if the given git mode represents an executable file,
|
|
otherwise False.
|
|
"""
|
|
return mode[-3:] == "755"
|
|
|
|
|
|
class P4Exception(Exception):
|
|
"""Base class for exceptions from the p4 client."""
|
|
|
|
def __init__(self, exit_code):
|
|
self.p4ExitCode = exit_code
|
|
|
|
|
|
class P4ServerException(P4Exception):
|
|
"""Base class for exceptions where we get some kind of marshalled up result
|
|
from the server.
|
|
"""
|
|
|
|
def __init__(self, exit_code, p4_result):
|
|
super(P4ServerException, self).__init__(exit_code)
|
|
self.p4_result = p4_result
|
|
self.code = p4_result[0]['code']
|
|
self.data = p4_result[0]['data']
|
|
|
|
|
|
class P4RequestSizeException(P4ServerException):
|
|
"""One of the maxresults or maxscanrows errors."""
|
|
|
|
def __init__(self, exit_code, p4_result, limit):
|
|
super(P4RequestSizeException, self).__init__(exit_code, p4_result)
|
|
self.limit = limit
|
|
|
|
|
|
class P4CommandException(P4Exception):
|
|
"""Something went wrong calling p4 which means we have to give up."""
|
|
|
|
def __init__(self, msg):
|
|
self.msg = msg
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
def isModeExecChanged(src_mode, dst_mode):
|
|
return isModeExec(src_mode) != isModeExec(dst_mode)
|
|
|
|
|
|
def p4CmdList(cmd, stdin=None, stdin_mode='w+b', cb=None, skip_info=False,
|
|
errors_as_exceptions=False, *k, **kw):
|
|
|
|
cmd = p4_build_cmd(["-G"] + cmd)
|
|
if verbose:
|
|
sys.stderr.write("Opening pipe: {}\n".format(' '.join(cmd)))
|
|
|
|
# Use a temporary file to avoid deadlocks without
|
|
# subprocess.communicate(), which would put another copy
|
|
# of stdout into memory.
|
|
stdin_file = None
|
|
if stdin is not None:
|
|
stdin_file = tempfile.TemporaryFile(prefix='p4-stdin', mode=stdin_mode)
|
|
if not isinstance(stdin, list):
|
|
stdin_file.write(stdin)
|
|
else:
|
|
for i in stdin:
|
|
stdin_file.write(encode_text_stream(i))
|
|
stdin_file.write(b'\n')
|
|
stdin_file.flush()
|
|
stdin_file.seek(0)
|
|
|
|
p4 = subprocess.Popen(
|
|
cmd, stdin=stdin_file, stdout=subprocess.PIPE, *k, **kw)
|
|
|
|
result = []
|
|
try:
|
|
while True:
|
|
entry = marshal.load(p4.stdout)
|
|
if bytes is not str:
|
|
# Decode unmarshalled dict to use str keys and values, except for:
|
|
# - `data` which may contain arbitrary binary data
|
|
# - `desc` or `FullName` which may contain non-UTF8 encoded text handled below, eagerly converted to bytes
|
|
# - `depotFile[0-9]*`, `path`, or `clientFile` which may contain non-UTF8 encoded text, handled by decode_path()
|
|
decoded_entry = {}
|
|
for key, value in entry.items():
|
|
key = key.decode()
|
|
if isinstance(value, bytes) and not (key in ('data', 'desc', 'FullName', 'path', 'clientFile') or key.startswith('depotFile')):
|
|
value = value.decode()
|
|
decoded_entry[key] = value
|
|
# Parse out data if it's an error response
|
|
if decoded_entry.get('code') == 'error' and 'data' in decoded_entry:
|
|
decoded_entry['data'] = decoded_entry['data'].decode()
|
|
entry = decoded_entry
|
|
if skip_info:
|
|
if 'code' in entry and entry['code'] == 'info':
|
|
continue
|
|
if 'desc' in entry:
|
|
entry['desc'] = metadata_stream_to_writable_bytes(entry['desc'])
|
|
if 'FullName' in entry:
|
|
entry['FullName'] = metadata_stream_to_writable_bytes(entry['FullName'])
|
|
if cb is not None:
|
|
cb(entry)
|
|
else:
|
|
result.append(entry)
|
|
except EOFError:
|
|
pass
|
|
exitCode = p4.wait()
|
|
if exitCode != 0:
|
|
if errors_as_exceptions:
|
|
if len(result) > 0:
|
|
data = result[0].get('data')
|
|
if data:
|
|
m = re.search('Too many rows scanned \(over (\d+)\)', data)
|
|
if not m:
|
|
m = re.search('Request too large \(over (\d+)\)', data)
|
|
|
|
if m:
|
|
limit = int(m.group(1))
|
|
raise P4RequestSizeException(exitCode, result, limit)
|
|
|
|
raise P4ServerException(exitCode, result)
|
|
else:
|
|
raise P4Exception(exitCode)
|
|
else:
|
|
entry = {}
|
|
entry["p4ExitCode"] = exitCode
|
|
result.append(entry)
|
|
|
|
return result
|
|
|
|
|
|
def p4Cmd(cmd, *k, **kw):
|
|
list = p4CmdList(cmd, *k, **kw)
|
|
result = {}
|
|
for entry in list:
|
|
result.update(entry)
|
|
return result
|
|
|
|
|
|
def p4Where(depotPath):
|
|
if not depotPath.endswith("/"):
|
|
depotPath += "/"
|
|
depotPathLong = depotPath + "..."
|
|
outputList = p4CmdList(["where", depotPathLong])
|
|
output = None
|
|
for entry in outputList:
|
|
if "depotFile" in entry:
|
|
# Search for the base client side depot path, as long as it starts with the branch's P4 path.
|
|
# The base path always ends with "/...".
|
|
entry_path = decode_path(entry['depotFile'])
|
|
if entry_path.find(depotPath) == 0 and entry_path[-4:] == "/...":
|
|
output = entry
|
|
break
|
|
elif "data" in entry:
|
|
data = entry.get("data")
|
|
space = data.find(" ")
|
|
if data[:space] == depotPath:
|
|
output = entry
|
|
break
|
|
if output is None:
|
|
return ""
|
|
if output["code"] == "error":
|
|
return ""
|
|
clientPath = ""
|
|
if "path" in output:
|
|
clientPath = decode_path(output['path'])
|
|
elif "data" in output:
|
|
data = output.get("data")
|
|
lastSpace = data.rfind(b" ")
|
|
clientPath = decode_path(data[lastSpace + 1:])
|
|
|
|
if clientPath.endswith("..."):
|
|
clientPath = clientPath[:-3]
|
|
return clientPath
|
|
|
|
|
|
def currentGitBranch():
|
|
return read_pipe_text(["git", "symbolic-ref", "--short", "-q", "HEAD"])
|
|
|
|
|
|
def isValidGitDir(path):
|
|
return git_dir(path) is not None
|
|
|
|
|
|
def parseRevision(ref):
|
|
return read_pipe(["git", "rev-parse", ref]).strip()
|
|
|
|
|
|
def branchExists(ref):
|
|
rev = read_pipe(["git", "rev-parse", "-q", "--verify", ref],
|
|
ignore_error=True)
|
|
return len(rev) > 0
|
|
|
|
|
|
def extractLogMessageFromGitCommit(commit):
|
|
logMessage = ""
|
|
|
|
# fixme: title is first line of commit, not 1st paragraph.
|
|
foundTitle = False
|
|
for log in read_pipe_lines(["git", "cat-file", "commit", commit]):
|
|
if not foundTitle:
|
|
if len(log) == 1:
|
|
foundTitle = True
|
|
continue
|
|
|
|
logMessage += log
|
|
return logMessage
|
|
|
|
|
|
def extractSettingsGitLog(log):
|
|
values = {}
|
|
for line in log.split("\n"):
|
|
line = line.strip()
|
|
m = re.search(r"^ *\[git-p4: (.*)\]$", line)
|
|
if not m:
|
|
continue
|
|
|
|
assignments = m.group(1).split(':')
|
|
for a in assignments:
|
|
vals = a.split('=')
|
|
key = vals[0].strip()
|
|
val = ('='.join(vals[1:])).strip()
|
|
if val.endswith('\"') and val.startswith('"'):
|
|
val = val[1:-1]
|
|
|
|
values[key] = val
|
|
|
|
paths = values.get("depot-paths")
|
|
if not paths:
|
|
paths = values.get("depot-path")
|
|
if paths:
|
|
values['depot-paths'] = paths.split(',')
|
|
return values
|
|
|
|
|
|
def gitBranchExists(branch):
|
|
proc = subprocess.Popen(["git", "rev-parse", branch],
|
|
stderr=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
return proc.wait() == 0
|
|
|
|
|
|
def gitUpdateRef(ref, newvalue):
|
|
subprocess.check_call(["git", "update-ref", ref, newvalue])
|
|
|
|
|
|
def gitDeleteRef(ref):
|
|
subprocess.check_call(["git", "update-ref", "-d", ref])
|
|
|
|
|
|
_gitConfig = {}
|
|
|
|
|
|
def gitConfig(key, typeSpecifier=None):
|
|
if key not in _gitConfig:
|
|
cmd = ["git", "config"]
|
|
if typeSpecifier:
|
|
cmd += [typeSpecifier]
|
|
cmd += [key]
|
|
s = read_pipe(cmd, ignore_error=True)
|
|
_gitConfig[key] = s.strip()
|
|
return _gitConfig[key]
|
|
|
|
|
|
def gitConfigBool(key):
|
|
"""Return a bool, using git config --bool. It is True only if the
|
|
variable is set to true, and False if set to false or not present
|
|
in the config.
|
|
"""
|
|
|
|
if key not in _gitConfig:
|
|
_gitConfig[key] = gitConfig(key, '--bool') == "true"
|
|
return _gitConfig[key]
|
|
|
|
|
|
def gitConfigInt(key):
|
|
if key not in _gitConfig:
|
|
cmd = ["git", "config", "--int", key]
|
|
s = read_pipe(cmd, ignore_error=True)
|
|
v = s.strip()
|
|
try:
|
|
_gitConfig[key] = int(gitConfig(key, '--int'))
|
|
except ValueError:
|
|
_gitConfig[key] = None
|
|
return _gitConfig[key]
|
|
|
|
|
|
def gitConfigList(key):
|
|
if key not in _gitConfig:
|
|
s = read_pipe(["git", "config", "--get-all", key], ignore_error=True)
|
|
_gitConfig[key] = s.strip().splitlines()
|
|
if _gitConfig[key] == ['']:
|
|
_gitConfig[key] = []
|
|
return _gitConfig[key]
|
|
|
|
def fullP4Ref(incomingRef, importIntoRemotes=True):
|
|
"""Standardize a given provided p4 ref value to a full git ref:
|
|
refs/foo/bar/branch -> use it exactly
|
|
p4/branch -> prepend refs/remotes/ or refs/heads/
|
|
branch -> prepend refs/remotes/p4/ or refs/heads/p4/"""
|
|
if incomingRef.startswith("refs/"):
|
|
return incomingRef
|
|
if importIntoRemotes:
|
|
prepend = "refs/remotes/"
|
|
else:
|
|
prepend = "refs/heads/"
|
|
if not incomingRef.startswith("p4/"):
|
|
prepend += "p4/"
|
|
return prepend + incomingRef
|
|
|
|
def shortP4Ref(incomingRef, importIntoRemotes=True):
|
|
"""Standardize to a "short ref" if possible:
|
|
refs/foo/bar/branch -> ignore
|
|
refs/remotes/p4/branch or refs/heads/p4/branch -> shorten
|
|
p4/branch -> shorten"""
|
|
if importIntoRemotes:
|
|
longprefix = "refs/remotes/p4/"
|
|
else:
|
|
longprefix = "refs/heads/p4/"
|
|
if incomingRef.startswith(longprefix):
|
|
return incomingRef[len(longprefix):]
|
|
if incomingRef.startswith("p4/"):
|
|
return incomingRef[3:]
|
|
return incomingRef
|
|
|
|
def p4BranchesInGit(branchesAreInRemotes=True):
|
|
"""Find all the branches whose names start with "p4/", looking
|
|
in remotes or heads as specified by the argument. Return
|
|
a dictionary of { branch: revision } for each one found.
|
|
The branch names are the short names, without any
|
|
"p4/" prefix.
|
|
"""
|
|
|
|
branches = {}
|
|
|
|
cmdline = ["git", "rev-parse", "--symbolic"]
|
|
if branchesAreInRemotes:
|
|
cmdline.append("--remotes")
|
|
else:
|
|
cmdline.append("--branches")
|
|
|
|
for line in read_pipe_lines(cmdline):
|
|
line = line.strip()
|
|
|
|
# only import to p4/
|
|
if not line.startswith('p4/'):
|
|
continue
|
|
# special symbolic ref to p4/master
|
|
if line == "p4/HEAD":
|
|
continue
|
|
|
|
# strip off p4/ prefix
|
|
branch = line[len("p4/"):]
|
|
|
|
branches[branch] = parseRevision(line)
|
|
|
|
return branches
|
|
|
|
|
|
def branch_exists(branch):
|
|
"""Make sure that the given ref name really exists."""
|
|
|
|
cmd = ["git", "rev-parse", "--symbolic", "--verify", branch]
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
out, _ = p.communicate()
|
|
out = decode_text_stream(out)
|
|
if p.returncode:
|
|
return False
|
|
# expect exactly one line of output: the branch name
|
|
return out.rstrip() == branch
|
|
|
|
|
|
def findUpstreamBranchPoint(head="HEAD"):
|
|
branches = p4BranchesInGit()
|
|
# map from depot-path to branch name
|
|
branchByDepotPath = {}
|
|
for branch in branches.keys():
|
|
tip = branches[branch]
|
|
log = extractLogMessageFromGitCommit(tip)
|
|
settings = extractSettingsGitLog(log)
|
|
if "depot-paths" in settings:
|
|
git_branch = "remotes/p4/" + branch
|
|
paths = ",".join(settings["depot-paths"])
|
|
branchByDepotPath[paths] = git_branch
|
|
if "change" in settings:
|
|
paths = paths + ";" + settings["change"]
|
|
branchByDepotPath[paths] = git_branch
|
|
|
|
settings = None
|
|
parent = 0
|
|
while parent < 65535:
|
|
commit = head + "~%s" % parent
|
|
log = extractLogMessageFromGitCommit(commit)
|
|
settings = extractSettingsGitLog(log)
|
|
if "depot-paths" in settings:
|
|
paths = ",".join(settings["depot-paths"])
|
|
if "change" in settings:
|
|
expaths = paths + ";" + settings["change"]
|
|
if expaths in branchByDepotPath:
|
|
return [branchByDepotPath[expaths], settings]
|
|
if paths in branchByDepotPath:
|
|
return [branchByDepotPath[paths], settings]
|
|
|
|
parent = parent + 1
|
|
|
|
return ["", settings]
|
|
|
|
|
|
def createOrUpdateBranchesFromOrigin(localRefPrefix="refs/remotes/p4/", silent=True):
|
|
if not silent:
|
|
print("Creating/updating branch(es) in %s based on origin branch(es)"
|
|
% localRefPrefix)
|
|
|
|
originPrefix = "origin/p4/"
|
|
|
|
for line in read_pipe_lines(["git", "rev-parse", "--symbolic", "--remotes"]):
|
|
line = line.strip()
|
|
if (not line.startswith(originPrefix)) or line.endswith("HEAD"):
|
|
continue
|
|
|
|
headName = line[len(originPrefix):]
|
|
remoteHead = localRefPrefix + headName
|
|
originHead = line
|
|
|
|
original = extractSettingsGitLog(extractLogMessageFromGitCommit(originHead))
|
|
if 'depot-paths' not in original or 'change' not in original:
|
|
continue
|
|
|
|
update = False
|
|
if not gitBranchExists(remoteHead):
|
|
if verbose:
|
|
print("creating %s" % remoteHead)
|
|
update = True
|
|
else:
|
|
settings = extractSettingsGitLog(extractLogMessageFromGitCommit(remoteHead))
|
|
if 'change' in settings:
|
|
if settings['depot-paths'] == original['depot-paths']:
|
|
originP4Change = int(original['change'])
|
|
p4Change = int(settings['change'])
|
|
if originP4Change > p4Change:
|
|
print("%s (%s) is newer than %s (%s). "
|
|
"Updating p4 branch from origin."
|
|
% (originHead, originP4Change,
|
|
remoteHead, p4Change))
|
|
update = True
|
|
else:
|
|
print("Ignoring: %s was imported from %s while "
|
|
"%s was imported from %s"
|
|
% (originHead, ','.join(original['depot-paths']),
|
|
remoteHead, ','.join(settings['depot-paths'])))
|
|
|
|
if update:
|
|
system(["git", "update-ref", remoteHead, originHead])
|
|
|
|
|
|
def originP4BranchesExist():
|
|
return gitBranchExists("origin") or gitBranchExists("origin/p4") or gitBranchExists("origin/p4/master")
|
|
|
|
|
|
def p4ParseNumericChangeRange(parts):
|
|
changeStart = int(parts[0][1:])
|
|
if parts[1] == '#head':
|
|
changeEnd = p4_last_change()
|
|
else:
|
|
changeEnd = int(parts[1])
|
|
|
|
return (changeStart, changeEnd)
|
|
|
|
|
|
def chooseBlockSize(blockSize):
|
|
if blockSize:
|
|
return blockSize
|
|
else:
|
|
return defaultBlockSize
|
|
|
|
|
|
def p4ChangesForPaths(depotPaths, changeRange, requestedBlockSize):
|
|
assert depotPaths
|
|
|
|
# Parse the change range into start and end. Try to find integer
|
|
# revision ranges as these can be broken up into blocks to avoid
|
|
# hitting server-side limits (maxrows, maxscanresults). But if
|
|
# that doesn't work, fall back to using the raw revision specifier
|
|
# strings, without using block mode.
|
|
|
|
if changeRange is None or changeRange == '':
|
|
changeStart = 1
|
|
changeEnd = p4_last_change()
|
|
block_size = chooseBlockSize(requestedBlockSize)
|
|
else:
|
|
parts = changeRange.split(',')
|
|
assert len(parts) == 2
|
|
try:
|
|
changeStart, changeEnd = p4ParseNumericChangeRange(parts)
|
|
block_size = chooseBlockSize(requestedBlockSize)
|
|
except ValueError:
|
|
changeStart = parts[0][1:]
|
|
changeEnd = parts[1]
|
|
if requestedBlockSize:
|
|
die("cannot use --changes-block-size with non-numeric revisions")
|
|
block_size = None
|
|
|
|
changes = set()
|
|
|
|
# Retrieve changes a block at a time, to prevent running
|
|
# into a MaxResults/MaxScanRows error from the server. If
|
|
# we _do_ hit one of those errors, turn down the block size
|
|
|
|
while True:
|
|
cmd = ['changes']
|
|
|
|
if block_size:
|
|
end = min(changeEnd, changeStart + block_size)
|
|
revisionRange = "%d,%d" % (changeStart, end)
|
|
else:
|
|
revisionRange = "%s,%s" % (changeStart, changeEnd)
|
|
|
|
for p in depotPaths:
|
|
cmd += ["%s...@%s" % (p, revisionRange)]
|
|
|
|
# fetch the changes
|
|
try:
|
|
result = p4CmdList(cmd, errors_as_exceptions=True)
|
|
except P4RequestSizeException as e:
|
|
if not block_size:
|
|
block_size = e.limit
|
|
elif block_size > e.limit:
|
|
block_size = e.limit
|
|
else:
|
|
block_size = max(2, block_size // 2)
|
|
|
|
if verbose:
|
|
print("block size error, retrying with block size {0}".format(block_size))
|
|
continue
|
|
except P4Exception as e:
|
|
die('Error retrieving changes description ({0})'.format(e.p4ExitCode))
|
|
|
|
# Insert changes in chronological order
|
|
for entry in reversed(result):
|
|
if 'change' not in entry:
|
|
continue
|
|
changes.add(int(entry['change']))
|
|
|
|
if not block_size:
|
|
break
|
|
|
|
if end >= changeEnd:
|
|
break
|
|
|
|
changeStart = end + 1
|
|
|
|
changes = sorted(changes)
|
|
return changes
|
|
|
|
|
|
def p4PathStartsWith(path, prefix):
|
|
"""This method tries to remedy a potential mixed-case issue:
|
|
|
|
If UserA adds //depot/DirA/file1
|
|
and UserB adds //depot/dira/file2
|
|
|
|
we may or may not have a problem. If you have core.ignorecase=true,
|
|
we treat DirA and dira as the same directory.
|
|
"""
|
|
if gitConfigBool("core.ignorecase"):
|
|
return path.lower().startswith(prefix.lower())
|
|
return path.startswith(prefix)
|
|
|
|
|
|
def getClientSpec():
|
|
"""Look at the p4 client spec, create a View() object that contains
|
|
all the mappings, and return it.
|
|
"""
|
|
|
|
specList = p4CmdList(["client", "-o"])
|
|
if len(specList) != 1:
|
|
die('Output from "client -o" is %d lines, expecting 1' %
|
|
len(specList))
|
|
|
|
# dictionary of all client parameters
|
|
entry = specList[0]
|
|
|
|
# the //client/ name
|
|
client_name = entry["Client"]
|
|
|
|
# just the keys that start with "View"
|
|
view_keys = [k for k in entry.keys() if k.startswith("View")]
|
|
|
|
# hold this new View
|
|
view = View(client_name)
|
|
|
|
# append the lines, in order, to the view
|
|
for view_num in range(len(view_keys)):
|
|
k = "View%d" % view_num
|
|
if k not in view_keys:
|
|
die("Expected view key %s missing" % k)
|
|
view.append(entry[k])
|
|
|
|
return view
|
|
|
|
|
|
def getClientRoot():
|
|
"""Grab the client directory."""
|
|
|
|
output = p4CmdList(["client", "-o"])
|
|
if len(output) != 1:
|
|
die('Output from "client -o" is %d lines, expecting 1' % len(output))
|
|
|
|
entry = output[0]
|
|
if "Root" not in entry:
|
|
die('Client has no "Root"')
|
|
|
|
return entry["Root"]
|
|
|
|
|
|
def wildcard_decode(path):
|
|
"""Decode P4 wildcards into %xx encoding
|
|
|
|
P4 wildcards are not allowed in filenames. P4 complains if you simply
|
|
add them, but you can force it with "-f", in which case it translates
|
|
them into %xx encoding internally.
|
|
"""
|
|
|
|
# Search for and fix just these four characters. Do % last so
|
|
# that fixing it does not inadvertently create new %-escapes.
|
|
# Cannot have * in a filename in windows; untested as to
|
|
# what p4 would do in such a case.
|
|
if not platform.system() == "Windows":
|
|
path = path.replace("%2A", "*")
|
|
path = path.replace("%23", "#") \
|
|
.replace("%40", "@") \
|
|
.replace("%25", "%")
|
|
return path
|
|
|
|
|
|
def wildcard_encode(path):
|
|
"""Encode %xx coded wildcards into P4 coding."""
|
|
|
|
# do % first to avoid double-encoding the %s introduced here
|
|
path = path.replace("%", "%25") \
|
|
.replace("*", "%2A") \
|
|
.replace("#", "%23") \
|
|
.replace("@", "%40")
|
|
return path
|
|
|
|
|
|
def wildcard_present(path):
|
|
m = re.search("[*#@%]", path)
|
|
return m is not None
|
|
|
|
|
|
class LargeFileSystem(object):
|
|
"""Base class for large file system support."""
|
|
|
|
def __init__(self, writeToGitStream):
|
|
self.largeFiles = set()
|
|
self.writeToGitStream = writeToGitStream
|
|
|
|
def generatePointer(self, cloneDestination, contentFile):
|
|
"""Return the content of a pointer file that is stored in Git instead
|
|
of the actual content.
|
|
"""
|
|
assert False, "Method 'generatePointer' required in " + self.__class__.__name__
|
|
|
|
def pushFile(self, localLargeFile):
|
|
"""Push the actual content which is not stored in the Git repository to
|
|
a server.
|
|
"""
|
|
assert False, "Method 'pushFile' required in " + self.__class__.__name__
|
|
|
|
def hasLargeFileExtension(self, relPath):
|
|
return functools.reduce(
|
|
lambda a, b: a or b,
|
|
[relPath.endswith('.' + e) for e in gitConfigList('git-p4.largeFileExtensions')],
|
|
False
|
|
)
|
|
|
|
def generateTempFile(self, contents):
|
|
contentFile = tempfile.NamedTemporaryFile(prefix='git-p4-large-file', delete=False)
|
|
for d in contents:
|
|
contentFile.write(d)
|
|
contentFile.close()
|
|
return contentFile.name
|
|
|
|
def exceedsLargeFileThreshold(self, relPath, contents):
|
|
if gitConfigInt('git-p4.largeFileThreshold'):
|
|
contentsSize = sum(len(d) for d in contents)
|
|
if contentsSize > gitConfigInt('git-p4.largeFileThreshold'):
|
|
return True
|
|
if gitConfigInt('git-p4.largeFileCompressedThreshold'):
|
|
contentsSize = sum(len(d) for d in contents)
|
|
if contentsSize <= gitConfigInt('git-p4.largeFileCompressedThreshold'):
|
|
return False
|
|
contentTempFile = self.generateTempFile(contents)
|
|
compressedContentFile = tempfile.NamedTemporaryFile(prefix='git-p4-large-file', delete=True)
|
|
with zipfile.ZipFile(compressedContentFile, mode='w') as zf:
|
|
zf.write(contentTempFile, compress_type=zipfile.ZIP_DEFLATED)
|
|
compressedContentsSize = zf.infolist()[0].compress_size
|
|
os.remove(contentTempFile)
|
|
if compressedContentsSize > gitConfigInt('git-p4.largeFileCompressedThreshold'):
|
|
return True
|
|
return False
|
|
|
|
def addLargeFile(self, relPath):
|
|
self.largeFiles.add(relPath)
|
|
|
|
def removeLargeFile(self, relPath):
|
|
self.largeFiles.remove(relPath)
|
|
|
|
def isLargeFile(self, relPath):
|
|
return relPath in self.largeFiles
|
|
|
|
def processContent(self, git_mode, relPath, contents):
|
|
"""Processes the content of git fast import. This method decides if a
|
|
file is stored in the large file system and handles all necessary
|
|
steps.
|
|
"""
|
|
if self.exceedsLargeFileThreshold(relPath, contents) or self.hasLargeFileExtension(relPath):
|
|
contentTempFile = self.generateTempFile(contents)
|
|
pointer_git_mode, contents, localLargeFile = self.generatePointer(contentTempFile)
|
|
if pointer_git_mode:
|
|
git_mode = pointer_git_mode
|
|
if localLargeFile:
|
|
# Move temp file to final location in large file system
|
|
largeFileDir = os.path.dirname(localLargeFile)
|
|
if not os.path.isdir(largeFileDir):
|
|
os.makedirs(largeFileDir)
|
|
shutil.move(contentTempFile, localLargeFile)
|
|
self.addLargeFile(relPath)
|
|
if gitConfigBool('git-p4.largeFilePush'):
|
|
self.pushFile(localLargeFile)
|
|
if verbose:
|
|
sys.stderr.write("%s moved to large file system (%s)\n" % (relPath, localLargeFile))
|
|
return (git_mode, contents)
|
|
|
|
|
|
class MockLFS(LargeFileSystem):
|
|
"""Mock large file system for testing."""
|
|
|
|
def generatePointer(self, contentFile):
|
|
"""The pointer content is the original content prefixed with "pointer-".
|
|
The local filename of the large file storage is derived from the
|
|
file content.
|
|
"""
|
|
with open(contentFile, 'r') as f:
|
|
content = next(f)
|
|
gitMode = '100644'
|
|
pointerContents = 'pointer-' + content
|
|
localLargeFile = os.path.join(os.getcwd(), '.git', 'mock-storage', 'local', content[:-1])
|
|
return (gitMode, pointerContents, localLargeFile)
|
|
|
|
def pushFile(self, localLargeFile):
|
|
"""The remote filename of the large file storage is the same as the
|
|
local one but in a different directory.
|
|
"""
|
|
remotePath = os.path.join(os.path.dirname(localLargeFile), '..', 'remote')
|
|
if not os.path.exists(remotePath):
|
|
os.makedirs(remotePath)
|
|
shutil.copyfile(localLargeFile, os.path.join(remotePath, os.path.basename(localLargeFile)))
|
|
|
|
|
|
class GitLFS(LargeFileSystem):
|
|
"""Git LFS as backend for the git-p4 large file system.
|
|
See https://git-lfs.github.com/ for details.
|
|
"""
|
|
|
|
def __init__(self, *args):
|
|
LargeFileSystem.__init__(self, *args)
|
|
self.baseGitAttributes = []
|
|
|
|
def generatePointer(self, contentFile):
|
|
"""Generate a Git LFS pointer for the content. Return LFS Pointer file
|
|
mode and content which is stored in the Git repository instead of
|
|
the actual content. Return also the new location of the actual
|
|
content.
|
|
"""
|
|
if os.path.getsize(contentFile) == 0:
|
|
return (None, '', None)
|
|
|
|
pointerProcess = subprocess.Popen(
|
|
['git', 'lfs', 'pointer', '--file=' + contentFile],
|
|
stdout=subprocess.PIPE
|
|
)
|
|
pointerFile = decode_text_stream(pointerProcess.stdout.read())
|
|
if pointerProcess.wait():
|
|
os.remove(contentFile)
|
|
die('git-lfs pointer command failed. Did you install the extension?')
|
|
|
|
# Git LFS removed the preamble in the output of the 'pointer' command
|
|
# starting from version 1.2.0. Check for the preamble here to support
|
|
# earlier versions.
|
|
# c.f. https://github.com/github/git-lfs/commit/da2935d9a739592bc775c98d8ef4df9c72ea3b43
|
|
if pointerFile.startswith('Git LFS pointer for'):
|
|
pointerFile = re.sub(r'Git LFS pointer for.*\n\n', '', pointerFile)
|
|
|
|
oid = re.search(r'^oid \w+:(\w+)', pointerFile, re.MULTILINE).group(1)
|
|
# if someone use external lfs.storage ( not in local repo git )
|
|
lfs_path = gitConfig('lfs.storage')
|
|
if not lfs_path:
|
|
lfs_path = 'lfs'
|
|
if not os.path.isabs(lfs_path):
|
|
lfs_path = os.path.join(os.getcwd(), '.git', lfs_path)
|
|
localLargeFile = os.path.join(
|
|
lfs_path,
|
|
'objects', oid[:2], oid[2:4],
|
|
oid,
|
|
)
|
|
# LFS Spec states that pointer files should not have the executable bit set.
|
|
gitMode = '100644'
|
|
return (gitMode, pointerFile, localLargeFile)
|
|
|
|
def pushFile(self, localLargeFile):
|
|
uploadProcess = subprocess.Popen(
|
|
['git', 'lfs', 'push', '--object-id', 'origin', os.path.basename(localLargeFile)]
|
|
)
|
|
if uploadProcess.wait():
|
|
die('git-lfs push command failed. Did you define a remote?')
|
|
|
|
def generateGitAttributes(self):
|
|
return (
|
|
self.baseGitAttributes +
|
|
[
|
|
'\n',
|
|
'#\n',
|
|
'# Git LFS (see https://git-lfs.github.com/)\n',
|
|
'#\n',
|
|
] +
|
|
['*.' + f.replace(' ', '[[:space:]]') + ' filter=lfs diff=lfs merge=lfs -text\n'
|
|
for f in sorted(gitConfigList('git-p4.largeFileExtensions'))
|
|
] +
|
|
['/' + f.replace(' ', '[[:space:]]') + ' filter=lfs diff=lfs merge=lfs -text\n'
|
|
for f in sorted(self.largeFiles) if not self.hasLargeFileExtension(f)
|
|
]
|
|
)
|
|
|
|
def addLargeFile(self, relPath):
|
|
LargeFileSystem.addLargeFile(self, relPath)
|
|
self.writeToGitStream('100644', '.gitattributes', self.generateGitAttributes())
|
|
|
|
def removeLargeFile(self, relPath):
|
|
LargeFileSystem.removeLargeFile(self, relPath)
|
|
self.writeToGitStream('100644', '.gitattributes', self.generateGitAttributes())
|
|
|
|
def processContent(self, git_mode, relPath, contents):
|
|
if relPath == '.gitattributes':
|
|
self.baseGitAttributes = contents
|
|
return (git_mode, self.generateGitAttributes())
|
|
else:
|
|
return LargeFileSystem.processContent(self, git_mode, relPath, contents)
|
|
|
|
|
|
class Command:
|
|
delete_actions = ("delete", "move/delete", "purge")
|
|
add_actions = ("add", "branch", "move/add")
|
|
|
|
def __init__(self):
|
|
self.usage = "usage: %prog [options]"
|
|
self.needsGit = True
|
|
self.verbose = False
|
|
|
|
# This is required for the "append" update_shelve action
|
|
def ensure_value(self, attr, value):
|
|
if not hasattr(self, attr) or getattr(self, attr) is None:
|
|
setattr(self, attr, value)
|
|
return getattr(self, attr)
|
|
|
|
|
|
class P4UserMap:
|
|
def __init__(self):
|
|
self.userMapFromPerforceServer = False
|
|
self.myP4UserId = None
|
|
|
|
def p4UserId(self):
|
|
if self.myP4UserId:
|
|
return self.myP4UserId
|
|
|
|
results = p4CmdList(["user", "-o"])
|
|
for r in results:
|
|
if 'User' in r:
|
|
self.myP4UserId = r['User']
|
|
return r['User']
|
|
die("Could not find your p4 user id")
|
|
|
|
def p4UserIsMe(self, p4User):
|
|
"""Return True if the given p4 user is actually me."""
|
|
me = self.p4UserId()
|
|
if not p4User or p4User != me:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def getUserCacheFilename(self):
|
|
home = os.environ.get("HOME", os.environ.get("USERPROFILE"))
|
|
return home + "/.gitp4-usercache.txt"
|
|
|
|
def getUserMapFromPerforceServer(self):
|
|
if self.userMapFromPerforceServer:
|
|
return
|
|
self.users = {}
|
|
self.emails = {}
|
|
|
|
for output in p4CmdList(["users"]):
|
|
if "User" not in output:
|
|
continue
|
|
# "FullName" is bytes. "Email" on the other hand might be bytes
|
|
# or unicode string depending on whether we are running under
|
|
# python2 or python3. To support
|
|
# git-p4.metadataDecodingStrategy=fallback, self.users dict values
|
|
# are always bytes, ready to be written to git.
|
|
emailbytes = metadata_stream_to_writable_bytes(output["Email"])
|
|
self.users[output["User"]] = output["FullName"] + b" <" + emailbytes + b">"
|
|
self.emails[output["Email"]] = output["User"]
|
|
|
|
mapUserConfigRegex = re.compile(r"^\s*(\S+)\s*=\s*(.+)\s*<(\S+)>\s*$", re.VERBOSE)
|
|
for mapUserConfig in gitConfigList("git-p4.mapUser"):
|
|
mapUser = mapUserConfigRegex.findall(mapUserConfig)
|
|
if mapUser and len(mapUser[0]) == 3:
|
|
user = mapUser[0][0]
|
|
fullname = mapUser[0][1]
|
|
email = mapUser[0][2]
|
|
fulluser = fullname + " <" + email + ">"
|
|
self.users[user] = metadata_stream_to_writable_bytes(fulluser)
|
|
self.emails[email] = user
|
|
|
|
s = b''
|
|
for (key, val) in self.users.items():
|
|
keybytes = metadata_stream_to_writable_bytes(key)
|
|
s += b"%s\t%s\n" % (keybytes.expandtabs(1), val.expandtabs(1))
|
|
|
|
open(self.getUserCacheFilename(), 'wb').write(s)
|
|
self.userMapFromPerforceServer = True
|
|
|
|
def loadUserMapFromCache(self):
|
|
self.users = {}
|
|
self.userMapFromPerforceServer = False
|
|
try:
|
|
cache = open(self.getUserCacheFilename(), 'rb')
|
|
lines = cache.readlines()
|
|
cache.close()
|
|
for line in lines:
|
|
entry = line.strip().split(b"\t")
|
|
self.users[entry[0].decode('utf_8')] = entry[1]
|
|
except IOError:
|
|
self.getUserMapFromPerforceServer()
|
|
|
|
|
|
class P4Submit(Command, P4UserMap):
|
|
|
|
conflict_behavior_choices = ("ask", "skip", "quit")
|
|
|
|
def __init__(self):
|
|
Command.__init__(self)
|
|
P4UserMap.__init__(self)
|
|
self.options = [
|
|
optparse.make_option("--origin", dest="origin"),
|
|
optparse.make_option("-M", dest="detectRenames", action="store_true"),
|
|
# preserve the user, requires relevant p4 permissions
|
|
optparse.make_option("--preserve-user", dest="preserveUser", action="store_true"),
|
|
optparse.make_option("--export-labels", dest="exportLabels", action="store_true"),
|
|
optparse.make_option("--dry-run", "-n", dest="dry_run", action="store_true"),
|
|
optparse.make_option("--prepare-p4-only", dest="prepare_p4_only", action="store_true"),
|
|
optparse.make_option("--conflict", dest="conflict_behavior",
|
|
choices=self.conflict_behavior_choices),
|
|
optparse.make_option("--branch", dest="branch"),
|
|
optparse.make_option("--shelve", dest="shelve", action="store_true",
|
|
help="Shelve instead of submit. Shelved files are reverted, "
|
|
"restoring the workspace to the state before the shelve"),
|
|
optparse.make_option("--update-shelve", dest="update_shelve", action="append", type="int",
|
|
metavar="CHANGELIST",
|
|
help="update an existing shelved changelist, implies --shelve, "
|
|
"repeat in-order for multiple shelved changelists"),
|
|
optparse.make_option("--commit", dest="commit", metavar="COMMIT",
|
|
help="submit only the specified commit(s), one commit or xxx..xxx"),
|
|
optparse.make_option("--disable-rebase", dest="disable_rebase", action="store_true",
|
|
help="Disable rebase after submit is completed. Can be useful if you "
|
|
"work from a local git branch that is not master"),
|
|
optparse.make_option("--disable-p4sync", dest="disable_p4sync", action="store_true",
|
|
help="Skip Perforce sync of p4/master after submit or shelve"),
|
|
optparse.make_option("--no-verify", dest="no_verify", action="store_true",
|
|
help="Bypass p4-pre-submit and p4-changelist hooks"),
|
|
]
|
|
self.description = """Submit changes from git to the perforce depot.\n
|
|
The `p4-pre-submit` hook is executed if it exists and is executable. It
|
|
can be bypassed with the `--no-verify` command line option. The hook takes
|
|
no parameters and nothing from standard input. Exiting with a non-zero status
|
|
from this script prevents `git-p4 submit` from launching.
|
|
|
|
One usage scenario is to run unit tests in the hook.
|
|
|
|
The `p4-prepare-changelist` hook is executed right after preparing the default
|
|
changelist message and before the editor is started. It takes one parameter,
|
|
the name of the file that contains the changelist text. Exiting with a non-zero
|
|
status from the script will abort the process.
|
|
|
|
The purpose of the hook is to edit the message file in place, and it is not
|
|
supressed by the `--no-verify` option. This hook is called even if
|
|
`--prepare-p4-only` is set.
|
|
|
|
The `p4-changelist` hook is executed after the changelist message has been
|
|
edited by the user. It can be bypassed with the `--no-verify` option. It
|
|
takes a single parameter, the name of the file that holds the proposed
|
|
changelist text. Exiting with a non-zero status causes the command to abort.
|
|
|
|
The hook is allowed to edit the changelist file and can be used to normalize
|
|
the text into some project standard format. It can also be used to refuse the
|
|
Submit after inspect the message file.
|
|
|
|
The `p4-post-changelist` hook is invoked after the submit has successfully
|
|
occurred in P4. It takes no parameters and is meant primarily for notification
|
|
and cannot affect the outcome of the git p4 submit action.
|
|
"""
|
|
|
|
self.usage += " [name of git branch to submit into perforce depot]"
|
|
self.origin = ""
|
|
self.detectRenames = False
|
|
self.preserveUser = gitConfigBool("git-p4.preserveUser")
|
|
self.dry_run = False
|
|
self.shelve = False
|
|
self.update_shelve = list()
|
|
self.commit = ""
|
|
self.disable_rebase = gitConfigBool("git-p4.disableRebase")
|
|
self.disable_p4sync = gitConfigBool("git-p4.disableP4Sync")
|
|
self.prepare_p4_only = False
|
|
self.conflict_behavior = None
|
|
self.isWindows = (platform.system() == "Windows")
|
|
self.exportLabels = False
|
|
self.p4HasMoveCommand = p4_has_move_command()
|
|
self.branch = None
|
|
self.no_verify = False
|
|
|
|
if gitConfig('git-p4.largeFileSystem'):
|
|
die("Large file system not supported for git-p4 submit command. Please remove it from config.")
|
|
|
|
def check(self):
|
|
if len(p4CmdList(["opened", "..."])) > 0:
|
|
die("You have files opened with perforce! Close them before starting the sync.")
|
|
|
|
def separate_jobs_from_description(self, message):
|
|
"""Extract and return a possible Jobs field in the commit message. It
|
|
goes into a separate section in the p4 change specification.
|
|
|
|
A jobs line starts with "Jobs:" and looks like a new field in a
|
|
form. Values are white-space separated on the same line or on
|
|
following lines that start with a tab.
|
|
|
|
This does not parse and extract the full git commit message like a
|
|
p4 form. It just sees the Jobs: line as a marker to pass everything
|
|
from then on directly into the p4 form, but outside the description
|
|
section.
|
|
|
|
Return a tuple (stripped log message, jobs string).
|
|
"""
|
|
|
|
m = re.search(r'^Jobs:', message, re.MULTILINE)
|
|
if m is None:
|
|
return (message, None)
|
|
|
|
jobtext = message[m.start():]
|
|
stripped_message = message[:m.start()].rstrip()
|
|
return (stripped_message, jobtext)
|
|
|
|
def prepareLogMessage(self, template, message, jobs):
|
|
"""Edits the template returned from "p4 change -o" to insert the
|
|
message in the Description field, and the jobs text in the Jobs
|
|
field.
|
|
"""
|
|
result = ""
|
|
|
|
inDescriptionSection = False
|
|
|
|
for line in template.split("\n"):
|
|
if line.startswith("#"):
|
|
result += line + "\n"
|
|
continue
|
|
|
|
if inDescriptionSection:
|
|
if line.startswith("Files:") or line.startswith("Jobs:"):
|
|
inDescriptionSection = False
|
|
# insert Jobs section
|
|
if jobs:
|
|
result += jobs + "\n"
|
|
else:
|
|
continue
|
|
else:
|
|
if line.startswith("Description:"):
|
|
inDescriptionSection = True
|
|
line += "\n"
|
|
for messageLine in message.split("\n"):
|
|
line += "\t" + messageLine + "\n"
|
|
|
|
result += line + "\n"
|
|
|
|
return result
|
|
|
|
def patchRCSKeywords(self, file, regexp):
|
|
"""Attempt to zap the RCS keywords in a p4 controlled file matching the
|
|
given regex.
|
|
"""
|
|
handle, outFileName = tempfile.mkstemp(dir='.')
|
|
try:
|
|
with os.fdopen(handle, "wb") as outFile, open(file, "rb") as inFile:
|
|
for line in inFile.readlines():
|
|
outFile.write(regexp.sub(br'$\1$', line))
|
|
# Forcibly overwrite the original file
|
|
os.unlink(file)
|
|
shutil.move(outFileName, file)
|
|
except:
|
|
# cleanup our temporary file
|
|
os.unlink(outFileName)
|
|
print("Failed to strip RCS keywords in %s" % file)
|
|
raise
|
|
|
|
print("Patched up RCS keywords in %s" % file)
|
|
|
|
def p4UserForCommit(self, id):
|
|
"""Return the tuple (perforce user,git email) for a given git commit
|
|
id.
|
|
"""
|
|
self.getUserMapFromPerforceServer()
|
|
gitEmail = read_pipe(["git", "log", "--max-count=1",
|
|
"--format=%ae", id])
|
|
gitEmail = gitEmail.strip()
|
|
if gitEmail not in self.emails:
|
|
return (None, gitEmail)
|
|
else:
|
|
return (self.emails[gitEmail], gitEmail)
|
|
|
|
def checkValidP4Users(self, commits):
|
|
"""Check if any git authors cannot be mapped to p4 users."""
|
|
for id in commits:
|
|
user, email = self.p4UserForCommit(id)
|
|
if not user:
|
|
msg = "Cannot find p4 user for email %s in commit %s." % (email, id)
|
|
if gitConfigBool("git-p4.allowMissingP4Users"):
|
|
print("%s" % msg)
|
|
else:
|
|
die("Error: %s\nSet git-p4.allowMissingP4Users to true to allow this." % msg)
|
|
|
|
def lastP4Changelist(self):
|
|
"""Get back the last changelist number submitted in this client spec.
|
|
|
|
This then gets used to patch up the username in the change. If the
|
|
same client spec is being used by multiple processes then this might
|
|
go wrong.
|
|
"""
|
|
results = p4CmdList(["client", "-o"]) # find the current client
|
|
client = None
|
|
for r in results:
|
|
if 'Client' in r:
|
|
client = r['Client']
|
|
break
|
|
if not client:
|
|
die("could not get client spec")
|
|
results = p4CmdList(["changes", "-c", client, "-m", "1"])
|
|
for r in results:
|
|
if 'change' in r:
|
|
return r['change']
|
|
die("Could not get changelist number for last submit - cannot patch up user details")
|
|
|
|
def modifyChangelistUser(self, changelist, newUser):
|
|
"""Fixup the user field of a changelist after it has been submitted."""
|
|
changes = p4CmdList(["change", "-o", changelist])
|
|
if len(changes) != 1:
|
|
die("Bad output from p4 change modifying %s to user %s" %
|
|
(changelist, newUser))
|
|
|
|
c = changes[0]
|
|
if c['User'] == newUser:
|
|
# Nothing to do
|
|
return
|
|
c['User'] = newUser
|
|
# p4 does not understand format version 3 and above
|
|
input = marshal.dumps(c, 2)
|
|
|
|
result = p4CmdList(["change", "-f", "-i"], stdin=input)
|
|
for r in result:
|
|
if 'code' in r:
|
|
if r['code'] == 'error':
|
|
die("Could not modify user field of changelist %s to %s:%s" % (changelist, newUser, r['data']))
|
|
if 'data' in r:
|
|
print("Updated user field for changelist %s to %s" % (changelist, newUser))
|
|
return
|
|
die("Could not modify user field of changelist %s to %s" % (changelist, newUser))
|
|
|
|
def canChangeChangelists(self):
|
|
"""Check to see if we have p4 admin or super-user permissions, either
|
|
of which are required to modify changelists.
|
|
"""
|
|
results = p4CmdList(["protects", self.depotPath])
|
|
for r in results:
|
|
if 'perm' in r:
|
|
if r['perm'] == 'admin':
|
|
return 1
|
|
if r['perm'] == 'super':
|
|
return 1
|
|
return 0
|
|
|
|
def prepareSubmitTemplate(self, changelist=None):
|
|
"""Run "p4 change -o" to grab a change specification template.
|
|
|
|
This does not use "p4 -G", as it is nice to keep the submission
|
|
template in original order, since a human might edit it.
|
|
|
|
Remove lines in the Files section that show changes to files
|
|
outside the depot path we're committing into.
|
|
"""
|
|
|
|
upstream, settings = findUpstreamBranchPoint()
|
|
|
|
template = """\
|
|
# A Perforce Change Specification.
|
|
#
|
|
# Change: The change number. 'new' on a new changelist.
|
|
# Date: The date this specification was last modified.
|
|
# Client: The client on which the changelist was created. Read-only.
|
|
# User: The user who created the changelist.
|
|
# Status: Either 'pending' or 'submitted'. Read-only.
|
|
# Type: Either 'public' or 'restricted'. Default is 'public'.
|
|
# Description: Comments about the changelist. Required.
|
|
# Jobs: What opened jobs are to be closed by this changelist.
|
|
# You may delete jobs from this list. (New changelists only.)
|
|
# Files: What opened files from the default changelist are to be added
|
|
# to this changelist. You may delete files from this list.
|
|
# (New changelists only.)
|
|
"""
|
|
files_list = []
|
|
inFilesSection = False
|
|
change_entry = None
|
|
args = ['change', '-o']
|
|
if changelist:
|
|
args.append(str(changelist))
|
|
for entry in p4CmdList(args):
|
|
if 'code' not in entry:
|
|
continue
|
|
if entry['code'] == 'stat':
|
|
change_entry = entry
|
|
break
|
|
if not change_entry:
|
|
die('Failed to decode output of p4 change -o')
|
|
for key, value in change_entry.items():
|
|
if key.startswith('File'):
|
|
if 'depot-paths' in settings:
|
|
if not [p for p in settings['depot-paths']
|
|
if p4PathStartsWith(value, p)]:
|
|
continue
|
|
else:
|
|
if not p4PathStartsWith(value, self.depotPath):
|
|
continue
|
|
files_list.append(value)
|
|
continue
|
|
# Output in the order expected by prepareLogMessage
|
|
for key in ['Change', 'Client', 'User', 'Status', 'Description', 'Jobs']:
|
|
if key not in change_entry:
|
|
continue
|
|
template += '\n'
|
|
template += key + ':'
|
|
if key == 'Description':
|
|
template += '\n'
|
|
for field_line in change_entry[key].splitlines():
|
|
template += '\t'+field_line+'\n'
|
|
if len(files_list) > 0:
|
|
template += '\n'
|
|
template += 'Files:\n'
|
|
for path in files_list:
|
|
template += '\t'+path+'\n'
|
|
return template
|
|
|
|
def edit_template(self, template_file):
|
|
"""Invoke the editor to let the user change the submission message.
|
|
|
|
Return true if okay to continue with the submit.
|
|
"""
|
|
|
|
# if configured to skip the editing part, just submit
|
|
if gitConfigBool("git-p4.skipSubmitEdit"):
|
|
return True
|
|
|
|
# look at the modification time, to check later if the user saved
|
|
# the file
|
|
mtime = os.stat(template_file).st_mtime
|
|
|
|
# invoke the editor
|
|
if "P4EDITOR" in os.environ and (os.environ.get("P4EDITOR") != ""):
|
|
editor = os.environ.get("P4EDITOR")
|
|
else:
|
|
editor = read_pipe(["git", "var", "GIT_EDITOR"]).strip()
|
|
system(["sh", "-c", ('%s "$@"' % editor), editor, template_file])
|
|
|
|
# If the file was not saved, prompt to see if this patch should
|
|
# be skipped. But skip this verification step if configured so.
|
|
if gitConfigBool("git-p4.skipSubmitEditCheck"):
|
|
return True
|
|
|
|
# modification time updated means user saved the file
|
|
if os.stat(template_file).st_mtime > mtime:
|
|
return True
|
|
|
|
response = prompt("Submit template unchanged. Submit anyway? [y]es, [n]o (skip this patch) ")
|
|
if response == 'y':
|
|
return True
|
|
if response == 'n':
|
|
return False
|
|
|
|
def get_diff_description(self, editedFiles, filesToAdd, symlinks):
|
|
# diff
|
|
if "P4DIFF" in os.environ:
|
|
del(os.environ["P4DIFF"])
|
|
diff = ""
|
|
for editedFile in editedFiles:
|
|
diff += p4_read_pipe(['diff', '-du',
|
|
wildcard_encode(editedFile)])
|
|
|
|
# new file diff
|
|
newdiff = ""
|
|
for newFile in filesToAdd:
|
|
newdiff += "==== new file ====\n"
|
|
newdiff += "--- /dev/null\n"
|
|
newdiff += "+++ %s\n" % newFile
|
|
|
|
is_link = os.path.islink(newFile)
|
|
expect_link = newFile in symlinks
|
|
|
|
if is_link and expect_link:
|
|
newdiff += "+%s\n" % os.readlink(newFile)
|
|
else:
|
|
f = open(newFile, "r")
|
|
try:
|
|
for line in f.readlines():
|
|
newdiff += "+" + line
|
|
except UnicodeDecodeError:
|
|
# Found non-text data and skip, since diff description
|
|
# should only include text
|
|
pass
|
|
f.close()
|
|
|
|
return (diff + newdiff).replace('\r\n', '\n')
|
|
|
|
def applyCommit(self, id):
|
|
"""Apply one commit, return True if it succeeded."""
|
|
|
|
print("Applying", read_pipe(["git", "show", "-s",
|
|
"--format=format:%h %s", id]))
|
|
|
|
p4User, gitEmail = self.p4UserForCommit(id)
|
|
|
|
diff = read_pipe_lines(
|
|
["git", "diff-tree", "-r"] + self.diffOpts + ["{}^".format(id), id])
|
|
filesToAdd = set()
|
|
filesToChangeType = set()
|
|
filesToDelete = set()
|
|
editedFiles = set()
|
|
pureRenameCopy = set()
|
|
symlinks = set()
|
|
filesToChangeExecBit = {}
|
|
all_files = list()
|
|
|
|
for line in diff:
|
|
diff = parseDiffTreeEntry(line)
|
|
modifier = diff['status']
|
|
path = diff['src']
|
|
all_files.append(path)
|
|
|
|
if modifier == "M":
|
|
p4_edit(path)
|
|
if isModeExecChanged(diff['src_mode'], diff['dst_mode']):
|
|
filesToChangeExecBit[path] = diff['dst_mode']
|
|
editedFiles.add(path)
|
|
elif modifier == "A":
|
|
filesToAdd.add(path)
|
|
filesToChangeExecBit[path] = diff['dst_mode']
|
|
if path in filesToDelete:
|
|
filesToDelete.remove(path)
|
|
|
|
dst_mode = int(diff['dst_mode'], 8)
|
|
if dst_mode == 0o120000:
|
|
symlinks.add(path)
|
|
|
|
elif modifier == "D":
|
|
filesToDelete.add(path)
|
|
if path in filesToAdd:
|
|
filesToAdd.remove(path)
|
|
elif modifier == "C":
|
|
src, dest = diff['src'], diff['dst']
|
|
all_files.append(dest)
|
|
p4_integrate(src, dest)
|
|
pureRenameCopy.add(dest)
|
|
if diff['src_sha1'] != diff['dst_sha1']:
|
|
p4_edit(dest)
|
|
pureRenameCopy.discard(dest)
|
|
if isModeExecChanged(diff['src_mode'], diff['dst_mode']):
|
|
p4_edit(dest)
|
|
pureRenameCopy.discard(dest)
|
|
filesToChangeExecBit[dest] = diff['dst_mode']
|
|
if self.isWindows:
|
|
# turn off read-only attribute
|
|
os.chmod(dest, stat.S_IWRITE)
|
|
os.unlink(dest)
|
|
editedFiles.add(dest)
|
|
elif modifier == "R":
|
|
src, dest = diff['src'], diff['dst']
|
|
all_files.append(dest)
|
|
if self.p4HasMoveCommand:
|
|
p4_edit(src) # src must be open before move
|
|
p4_move(src, dest) # opens for (move/delete, move/add)
|
|
else:
|
|
p4_integrate(src, dest)
|
|
if diff['src_sha1'] != diff['dst_sha1']:
|
|
p4_edit(dest)
|
|
else:
|
|
pureRenameCopy.add(dest)
|
|
if isModeExecChanged(diff['src_mode'], diff['dst_mode']):
|
|
if not self.p4HasMoveCommand:
|
|
p4_edit(dest) # with move: already open, writable
|
|
filesToChangeExecBit[dest] = diff['dst_mode']
|
|
if not self.p4HasMoveCommand:
|
|
if self.isWindows:
|
|
os.chmod(dest, stat.S_IWRITE)
|
|
os.unlink(dest)
|
|
filesToDelete.add(src)
|
|
editedFiles.add(dest)
|
|
elif modifier == "T":
|
|
filesToChangeType.add(path)
|
|
else:
|
|
die("unknown modifier %s for %s" % (modifier, path))
|
|
|
|
diffcmd = "git diff-tree --full-index -p \"%s\"" % (id)
|
|
patchcmd = diffcmd + " | git apply "
|
|
tryPatchCmd = patchcmd + "--check -"
|
|
applyPatchCmd = patchcmd + "--check --apply -"
|
|
patch_succeeded = True
|
|
|
|
if verbose:
|
|
print("TryPatch: %s" % tryPatchCmd)
|
|
|
|
if os.system(tryPatchCmd) != 0:
|
|
fixed_rcs_keywords = False
|
|
patch_succeeded = False
|
|
print("Unfortunately applying the change failed!")
|
|
|
|
# Patch failed, maybe it's just RCS keyword woes. Look through
|
|
# the patch to see if that's possible.
|
|
if gitConfigBool("git-p4.attemptRCSCleanup"):
|
|
file = None
|
|
kwfiles = {}
|
|
for file in editedFiles | filesToDelete:
|
|
# did this file's delta contain RCS keywords?
|
|
regexp = p4_keywords_regexp_for_file(file)
|
|
if regexp:
|
|
# this file is a possibility...look for RCS keywords.
|
|
for line in read_pipe_lines(
|
|
["git", "diff", "%s^..%s" % (id, id), file],
|
|
raw=True):
|
|
if regexp.search(line):
|
|
if verbose:
|
|
print("got keyword match on %s in %s in %s" % (regex.pattern, line, file))
|
|
kwfiles[file] = regexp
|
|
break
|
|
|
|
for file, regexp in kwfiles.items():
|
|
if verbose:
|
|
print("zapping %s with %s" % (line, regexp.pattern))
|
|
# File is being deleted, so not open in p4. Must
|
|
# disable the read-only bit on windows.
|
|
if self.isWindows and file not in editedFiles:
|
|
os.chmod(file, stat.S_IWRITE)
|
|
self.patchRCSKeywords(file, kwfiles[file])
|
|
fixed_rcs_keywords = True
|
|
|
|
if fixed_rcs_keywords:
|
|
print("Retrying the patch with RCS keywords cleaned up")
|
|
if os.system(tryPatchCmd) == 0:
|
|
patch_succeeded = True
|
|
print("Patch succeesed this time with RCS keywords cleaned")
|
|
|
|
if not patch_succeeded:
|
|
for f in editedFiles:
|
|
p4_revert(f)
|
|
return False
|
|
|
|
#
|
|
# Apply the patch for real, and do add/delete/+x handling.
|
|
#
|
|
system(applyPatchCmd, shell=True)
|
|
|
|
for f in filesToChangeType:
|
|
p4_edit(f, "-t", "auto")
|
|
for f in filesToAdd:
|
|
p4_add(f)
|
|
for f in filesToDelete:
|
|
p4_revert(f)
|
|
p4_delete(f)
|
|
|
|
# Set/clear executable bits
|
|
for f in filesToChangeExecBit.keys():
|
|
mode = filesToChangeExecBit[f]
|
|
setP4ExecBit(f, mode)
|
|
|
|
update_shelve = 0
|
|
if len(self.update_shelve) > 0:
|
|
update_shelve = self.update_shelve.pop(0)
|
|
p4_reopen_in_change(update_shelve, all_files)
|
|
|
|
#
|
|
# Build p4 change description, starting with the contents
|
|
# of the git commit message.
|
|
#
|
|
logMessage = extractLogMessageFromGitCommit(id)
|
|
logMessage = logMessage.strip()
|
|
logMessage, jobs = self.separate_jobs_from_description(logMessage)
|
|
|
|
template = self.prepareSubmitTemplate(update_shelve)
|
|
submitTemplate = self.prepareLogMessage(template, logMessage, jobs)
|
|
|
|
if self.preserveUser:
|
|
submitTemplate += "\n######## Actual user %s, modified after commit\n" % p4User
|
|
|
|
if self.checkAuthorship and not self.p4UserIsMe(p4User):
|
|
submitTemplate += "######## git author %s does not match your p4 account.\n" % gitEmail
|
|
submitTemplate += "######## Use option --preserve-user to modify authorship.\n"
|
|
submitTemplate += "######## Variable git-p4.skipUserNameCheck hides this message.\n"
|
|
|
|
separatorLine = "######## everything below this line is just the diff #######\n"
|
|
if not self.prepare_p4_only:
|
|
submitTemplate += separatorLine
|
|
submitTemplate += self.get_diff_description(editedFiles, filesToAdd, symlinks)
|
|
|
|
handle, fileName = tempfile.mkstemp()
|
|
tmpFile = os.fdopen(handle, "w+b")
|
|
if self.isWindows:
|
|
submitTemplate = submitTemplate.replace("\n", "\r\n")
|
|
tmpFile.write(encode_text_stream(submitTemplate))
|
|
tmpFile.close()
|
|
|
|
submitted = False
|
|
|
|
try:
|
|
# Allow the hook to edit the changelist text before presenting it
|
|
# to the user.
|
|
if not run_git_hook("p4-prepare-changelist", [fileName]):
|
|
return False
|
|
|
|
if self.prepare_p4_only:
|
|
#
|
|
# Leave the p4 tree prepared, and the submit template around
|
|
# and let the user decide what to do next
|
|
#
|
|
submitted = True
|
|
print("")
|
|
print("P4 workspace prepared for submission.")
|
|
print("To submit or revert, go to client workspace")
|
|
print(" " + self.clientPath)
|
|
print("")
|
|
print("To submit, use \"p4 submit\" to write a new description,")
|
|
print("or \"p4 submit -i <%s\" to use the one prepared by"
|
|
" \"git p4\"." % fileName)
|
|
print("You can delete the file \"%s\" when finished." % fileName)
|
|
|
|
if self.preserveUser and p4User and not self.p4UserIsMe(p4User):
|
|
print("To preserve change ownership by user %s, you must\n"
|
|
"do \"p4 change -f <change>\" after submitting and\n"
|
|
"edit the User field.")
|
|
if pureRenameCopy:
|
|
print("After submitting, renamed files must be re-synced.")
|
|
print("Invoke \"p4 sync -f\" on each of these files:")
|
|
for f in pureRenameCopy:
|
|
print(" " + f)
|
|
|
|
print("")
|
|
print("To revert the changes, use \"p4 revert ...\", and delete")
|
|
print("the submit template file \"%s\"" % fileName)
|
|
if filesToAdd:
|
|
print("Since the commit adds new files, they must be deleted:")
|
|
for f in filesToAdd:
|
|
print(" " + f)
|
|
print("")
|
|
sys.stdout.flush()
|
|
return True
|
|
|
|
if self.edit_template(fileName):
|
|
if not self.no_verify:
|
|
if not run_git_hook("p4-changelist", [fileName]):
|
|
print("The p4-changelist hook failed.")
|
|
sys.stdout.flush()
|
|
return False
|
|
|
|
# read the edited message and submit
|
|
tmpFile = open(fileName, "rb")
|
|
message = decode_text_stream(tmpFile.read())
|
|
tmpFile.close()
|
|
if self.isWindows:
|
|
message = message.replace("\r\n", "\n")
|
|
if message.find(separatorLine) != -1:
|
|
submitTemplate = message[:message.index(separatorLine)]
|
|
else:
|
|
submitTemplate = message
|
|
|
|
if len(submitTemplate.strip()) == 0:
|
|
print("Changelist is empty, aborting this changelist.")
|
|
sys.stdout.flush()
|
|
return False
|
|
|
|
if update_shelve:
|
|
p4_write_pipe(['shelve', '-r', '-i'], submitTemplate)
|
|
elif self.shelve:
|
|
p4_write_pipe(['shelve', '-i'], submitTemplate)
|
|
else:
|
|
p4_write_pipe(['submit', '-i'], submitTemplate)
|
|
# The rename/copy happened by applying a patch that created a
|
|
# new file. This leaves it writable, which confuses p4.
|
|
for f in pureRenameCopy:
|
|
p4_sync(f, "-f")
|
|
|
|
if self.preserveUser:
|
|
if p4User:
|
|
# Get last changelist number. Cannot easily get it from
|
|
# the submit command output as the output is
|
|
# unmarshalled.
|
|
changelist = self.lastP4Changelist()
|
|
self.modifyChangelistUser(changelist, p4User)
|
|
|
|
submitted = True
|
|
|
|
run_git_hook("p4-post-changelist")
|
|
finally:
|
|
# Revert changes if we skip this patch
|
|
if not submitted or self.shelve:
|
|
if self.shelve:
|
|
print("Reverting shelved files.")
|
|
else:
|
|
print("Submission cancelled, undoing p4 changes.")
|
|
sys.stdout.flush()
|
|
for f in editedFiles | filesToDelete:
|
|
p4_revert(f)
|
|
for f in filesToAdd:
|
|
p4_revert(f)
|
|
os.remove(f)
|
|
|
|
if not self.prepare_p4_only:
|
|
os.remove(fileName)
|
|
return submitted
|
|
|
|
def exportGitTags(self, gitTags):
|
|
"""Export git tags as p4 labels. Create a p4 label and then tag with
|
|
that.
|
|
"""
|
|
|
|
validLabelRegexp = gitConfig("git-p4.labelExportRegexp")
|
|
if len(validLabelRegexp) == 0:
|
|
validLabelRegexp = defaultLabelRegexp
|
|
m = re.compile(validLabelRegexp)
|
|
|
|
for name in gitTags:
|
|
|
|
if not m.match(name):
|
|
if verbose:
|
|
print("tag %s does not match regexp %s" % (name, validLabelRegexp))
|
|
continue
|
|
|
|
# Get the p4 commit this corresponds to
|
|
logMessage = extractLogMessageFromGitCommit(name)
|
|
values = extractSettingsGitLog(logMessage)
|
|
|
|
if 'change' not in values:
|
|
# a tag pointing to something not sent to p4; ignore
|
|
if verbose:
|
|
print("git tag %s does not give a p4 commit" % name)
|
|
continue
|
|
else:
|
|
changelist = values['change']
|
|
|
|
# Get the tag details.
|
|
inHeader = True
|
|
isAnnotated = False
|
|
body = []
|
|
for l in read_pipe_lines(["git", "cat-file", "-p", name]):
|
|
l = l.strip()
|
|
if inHeader:
|
|
if re.match(r'tag\s+', l):
|
|
isAnnotated = True
|
|
elif re.match(r'\s*$', l):
|
|
inHeader = False
|
|
continue
|
|
else:
|
|
body.append(l)
|
|
|
|
if not isAnnotated:
|
|
body = ["lightweight tag imported by git p4\n"]
|
|
|
|
# Create the label - use the same view as the client spec we are using
|
|
clientSpec = getClientSpec()
|
|
|
|
labelTemplate = "Label: %s\n" % name
|
|
labelTemplate += "Description:\n"
|
|
for b in body:
|
|
labelTemplate += "\t" + b + "\n"
|
|
labelTemplate += "View:\n"
|
|
for depot_side in clientSpec.mappings:
|
|
labelTemplate += "\t%s\n" % depot_side
|
|
|
|
if self.dry_run:
|
|
print("Would create p4 label %s for tag" % name)
|
|
elif self.prepare_p4_only:
|
|
print("Not creating p4 label %s for tag due to option"
|
|
" --prepare-p4-only" % name)
|
|
else:
|
|
p4_write_pipe(["label", "-i"], labelTemplate)
|
|
|
|
# Use the label
|
|
p4_system(["tag", "-l", name] +
|
|
["%s@%s" % (depot_side, changelist) for depot_side in clientSpec.mappings])
|
|
|
|
if verbose:
|
|
print("created p4 label for tag %s" % name)
|
|
|
|
def run(self, args):
|
|
if len(args) == 0:
|
|
self.master = currentGitBranch()
|
|
elif len(args) == 1:
|
|
self.master = args[0]
|
|
if not branchExists(self.master):
|
|
die("Branch %s does not exist" % self.master)
|
|
else:
|
|
return False
|
|
|
|
for i in self.update_shelve:
|
|
if i <= 0:
|
|
sys.exit("invalid changelist %d" % i)
|
|
|
|
if self.master:
|
|
allowSubmit = gitConfig("git-p4.allowSubmit")
|
|
if len(allowSubmit) > 0 and not self.master in allowSubmit.split(","):
|
|
die("%s is not in git-p4.allowSubmit" % self.master)
|
|
|
|
upstream, settings = findUpstreamBranchPoint()
|
|
self.depotPath = settings['depot-paths'][0]
|
|
if len(self.origin) == 0:
|
|
self.origin = upstream
|
|
|
|
if len(self.update_shelve) > 0:
|
|
self.shelve = True
|
|
|
|
if self.preserveUser:
|
|
if not self.canChangeChangelists():
|