357 lines
12 KiB
Python
357 lines
12 KiB
Python
# Copyright Epic Games, Inc. All Rights Reserved.
|
|
|
|
import re
|
|
import os
|
|
import fzf
|
|
import time
|
|
import shutil
|
|
import p4utils
|
|
import tempfile
|
|
import flow.cmd
|
|
from peafour import P4
|
|
import subprocess as sp
|
|
|
|
#-------------------------------------------------------------------------------
|
|
def _as_p4_path(path_rev):
|
|
path, rev = map(str, path_rev)
|
|
hash_pound = "" if "@" in str(rev) else "#"
|
|
return f"{path}{hash_pound}{rev}"
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# 'p4 diff2' can be very slow. We'll use Python's built in difflib
|
|
class _Difflib(object):
|
|
def __init__(self, left_path_rev, right_path_rev):
|
|
class P4PrintText(object):
|
|
def __init__(self): self.data = b""
|
|
def on_text(self, t): self.data += t
|
|
|
|
left_print = P4PrintText()
|
|
on_text = left_print.on_text
|
|
for x in P4.print(_as_p4_path(left_path_rev)).read(on_text=on_text):
|
|
pass
|
|
|
|
right_print = P4PrintText()
|
|
on_text = right_print.on_text
|
|
for x in P4.print(_as_p4_path(right_path_rev)).read(on_text=on_text):
|
|
pass
|
|
|
|
import io
|
|
import difflib
|
|
diff = difflib.unified_diff(
|
|
list(io.StringIO(left_print.data.decode(errors="ignore"))),
|
|
list(io.StringIO(right_print.data.decode(errors="ignore"))),
|
|
fromfile="from",
|
|
tofile="to"
|
|
)
|
|
self._diff_iter = iter(diff)
|
|
self.stdout = self
|
|
|
|
def readline(self, *args):
|
|
ret = next(self._diff_iter, None)
|
|
if ret:
|
|
return ret.encode()
|
|
return b""
|
|
|
|
def wait(self): pass
|
|
def close(self): pass
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# 'p4 diff2' can be very slow, we can use 'git diff' and 'p4 print'
|
|
class _Diff2(object):
|
|
_git_bin = "git"
|
|
|
|
def __init__(self, left_path_rev, right_path_rev):
|
|
self._left_path = None
|
|
self._right_path = None
|
|
|
|
# Fetch the left file
|
|
left_fd, left_path = tempfile.mkstemp()
|
|
on_text = lambda x: os.write(left_fd, x)
|
|
for x in P4.print(_as_p4_path(left_path_rev)).read(on_text=on_text):
|
|
pass
|
|
os.close(left_fd)
|
|
self._left_path = left_path
|
|
|
|
# Fetch the right file
|
|
right_fd, right_path = tempfile.mkstemp()
|
|
on_text = lambda x: os.write(right_fd, x)
|
|
for x in P4.print(_as_p4_path(right_path_rev)).read(on_text=on_text):
|
|
pass
|
|
os.close(right_fd)
|
|
self._right_path = right_path
|
|
|
|
# Spin up git diff
|
|
diff = (
|
|
_Diff2._git_bin,
|
|
"diff",
|
|
"--minimal",
|
|
"--unified=1",
|
|
"--diff-algorithm=histogram", # myers, patience
|
|
left_path,
|
|
right_path
|
|
)
|
|
proc = sp.Popen(diff, stdout=sp.PIPE, stderr=sp.DEVNULL)
|
|
self.stdout = proc.stdout
|
|
self.wait = proc.wait
|
|
|
|
def __del__(self):
|
|
def do_unlink(path):
|
|
try: os.unlink(path)
|
|
except (OSError, FileNotFoundError): pass
|
|
|
|
if self._right_path: do_unlink(self._right_path)
|
|
if self._left_path: do_unlink(self._left_path)
|
|
|
|
#-------------------------------------------------------------------------------
|
|
def _get_diff_hunk_for_line(right_line_no, left_path_rev, right_path_rev=None):
|
|
if right_path_rev:
|
|
proc = _Difflib(left_path_rev, right_path_rev)
|
|
else:
|
|
cmd = ("p4", "diff", "-Od", "-du", _as_p4_path(left_path_rev))
|
|
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL)
|
|
|
|
def inner_iter():
|
|
for line in line_iter:
|
|
if line.startswith(b"@@"):
|
|
break
|
|
yield line
|
|
|
|
proc.stdout.close()
|
|
proc.wait()
|
|
|
|
offset = 0
|
|
line_iter = iter(proc.stdout.readline, b"")
|
|
for line in (x for x in line_iter if x.startswith(b"@@")):
|
|
m = re.match(rb"@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)", line)
|
|
if not m:
|
|
continue
|
|
left_no, left_n, right_no, right_n = map(int, m.groups())
|
|
|
|
if right_line_no >= right_no and right_line_no <= right_no + right_n:
|
|
return inner_iter(), (left_no, right_no)
|
|
|
|
if right_no > right_line_no:
|
|
break
|
|
|
|
offset += left_n - right_n
|
|
|
|
proc.stdout.close()
|
|
proc.wait()
|
|
return None, right_line_no + offset
|
|
|
|
#-------------------------------------------------------------------------------
|
|
def _remap_line_no(right_line_no, left_path_rev, right_path_rev=None):
|
|
line_iter, param = _get_diff_hunk_for_line(right_line_no, left_path_rev, right_path_rev)
|
|
if not line_iter:
|
|
return param
|
|
|
|
left_no, right_no = param
|
|
for line in line_iter:
|
|
if line[0] == ord(b"-"):
|
|
left_no += 1
|
|
continue
|
|
|
|
if right_no == right_line_no:
|
|
break
|
|
|
|
if line[0] == ord(b" "):
|
|
left_no += 1
|
|
right_no += 1
|
|
elif line[0] == ord(b"+"):
|
|
right_no += 1
|
|
|
|
return left_no
|
|
|
|
#-------------------------------------------------------------------------------
|
|
def _get_edit_rev(path_rev, line_no):
|
|
annotate_iter = P4.annotate(_as_p4_path(path_rev), dwl=True, q=True).read()
|
|
header = next(annotate_iter)
|
|
try: lines = list(next(annotate_iter) for x in range(line_no))
|
|
except RuntimeError: raise IndexError
|
|
return int(lines[-1].lower)
|
|
|
|
#-------------------------------------------------------------------------------
|
|
def _select_line(path_rev):
|
|
proc = sp.Popen(("p4", "print", "-q", _as_p4_path(path_rev)), stdout=sp.PIPE)
|
|
|
|
def line_iter():
|
|
for i, line in enumerate(proc.stdout):
|
|
yield "%4d %s" % (i + 1, line.strip().decode())
|
|
|
|
line_no = 0
|
|
for line in fzf.run(line_iter()):
|
|
if m := re.match(r"\s*(\d+)", line):
|
|
line_no = int(m.group(1))
|
|
break
|
|
|
|
proc.wait()
|
|
proc.stdout.close()
|
|
return line_no
|
|
|
|
|
|
|
|
#-------------------------------------------------------------------------------
|
|
class Who(flow.cmd.Cmd):
|
|
""" Find the changelist that modified a particular line of a file. Who will
|
|
do its best to follow integrations and moves to find the changelist that edited
|
|
the line. If no line number is given then an prompt will be given to select
|
|
the line to follow. If 'path' has a '#rev' or '@cl' suffix then no remapping
|
|
against local edits is skipped."""
|
|
path = flow.cmd.Arg(str, "Path to the file (P4 paths & #rev/@cl suffix allowed)")
|
|
line = flow.cmd.Arg(0, "Line number to check modifications of")
|
|
printdiff = flow.cmd.Opt(False, "Print a unified diff of the line change - no P4V")
|
|
noremap = flow.cmd.Opt(False, "Skip remapping line numbers against local edits")
|
|
|
|
def main(self):
|
|
# Get the input file, we'll assume it has a good Perforce environment so
|
|
# we'll chdir alongside it. Might be edited so diff it to adjust line
|
|
self.print_info("Parsing input")
|
|
path = self.args.path
|
|
|
|
if "#" in path:
|
|
path_rev = path.split("#", 1)
|
|
remap_local = False
|
|
elif "@" in path:
|
|
path, rev = path.split("@", 1)
|
|
path_rev = (path, "@" + rev)
|
|
remap_local = False
|
|
else:
|
|
path_rev = (path, "head")
|
|
remap_local = True
|
|
path = path_rev[0]
|
|
|
|
# Try and pick up a suitable Perforce environment from the on-disk file
|
|
if os.path.isfile(path):
|
|
path_rev = (path, "have")
|
|
path_dir, path = os.path.split(path)
|
|
if path_dir:
|
|
os.chdir(path_dir)
|
|
|
|
# Path needs converting to a depot path
|
|
if os.path.isfile(path) or not path.startswith("//"):
|
|
path_rev = (P4.where(path).depotFile, path_rev[1])
|
|
print("Depot path:", path_rev[0])
|
|
|
|
# Is the file a binary file?
|
|
is_binary = False
|
|
try:
|
|
fstat = P4.fstat(path_rev[0] + "#head", m=1, T="headType").run()
|
|
is_binary = "binary" in fstat.headType
|
|
except P4.Error:
|
|
pass
|
|
|
|
if is_binary:
|
|
raise ValueError("Unable to follow line-by-line edits of binary files")
|
|
|
|
line_no = self.args.line
|
|
if line_no <= 0:
|
|
if self.is_interactive():
|
|
print("Select a line from file;", end="")
|
|
line_no = _select_line(path_rev)
|
|
if line_no <= 0:
|
|
self.print_error("No line was selected")
|
|
return False
|
|
print("Using line", line_no)
|
|
elif remap_local and not self.args.noremap:
|
|
for _ in P4.opened(path_rev[0]):
|
|
self.print_warning(path_rev[0], "is open for edit")
|
|
print("Attempting to remap line number against local edits")
|
|
line_no = _remap_line_no(line_no, path_rev)
|
|
print("Line", line_no, "perhaps")
|
|
break
|
|
|
|
# Use annotate to find out how a line changed, going further if the
|
|
# change it was an integration or branch
|
|
self.print_info("Following edits", flow.cmd.text.grey("... (a)nnotate (f)ilelog (d)iff"))
|
|
who_cl = 0
|
|
while not who_cl:
|
|
print(" ", _as_p4_path(path_rev), end="\r")
|
|
def print_step(x):
|
|
print(flow.cmd.text.grey(x), end="")
|
|
|
|
# Who last edited out target line?
|
|
print_step("a")
|
|
try:
|
|
edit_rev = _get_edit_rev(path_rev, line_no)
|
|
edit_path_rev = (path_rev[0], edit_rev)
|
|
except IndexError:
|
|
raise IndexError(f"Line {line_no} does not seem to exist")
|
|
|
|
# Adjust the edit path if it came from somewhere else
|
|
print_step("f")
|
|
log = P4.filelog(_as_p4_path(edit_path_rev), s=True, m=1).run()
|
|
action = getattr(log, "action0")
|
|
if action not in ("branch", "integrate", "edit", "add", "move/add"):
|
|
assert False, "Unknown action; " + action + str(log.as_dict())
|
|
|
|
if action not in ("edit", "add"):
|
|
try:
|
|
for how, path, rev in zip(getattr(log, "how0,"), getattr(log, "file0,"), getattr(log, "erev0,")):
|
|
if "from" in how:
|
|
edit_path_rev = (path, int(rev[1:]))
|
|
break
|
|
else:
|
|
assert False, "no 'from' filelog.how found"
|
|
except AttributeError:
|
|
# This is probably as far as we can go.
|
|
who_cl = getattr(log, "change0")
|
|
else:
|
|
who_cl = getattr(log, "change0")
|
|
|
|
# Remap the line number from where we are (path-rev) to the edit
|
|
print_step("d")
|
|
edit_line_no = _remap_line_no(line_no, edit_path_rev, path_rev)
|
|
if edit_line_no != line_no:
|
|
print("\n ", line_no, "->", edit_line_no, end="")
|
|
|
|
path_rev = edit_path_rev
|
|
line_no = edit_line_no
|
|
print()
|
|
|
|
# Inform the user about the change
|
|
print()
|
|
self.print_info("Attribution")
|
|
white = flow.cmd.text.white
|
|
print(white("Where: "), _as_p4_path(edit_path_rev))
|
|
print(white("Line: "), edit_line_no)
|
|
desc = P4.describe(who_cl).run()
|
|
print(white("Changelist:"), desc.change)
|
|
print(white("User: "), desc.user)
|
|
print(white("Time: "), time.ctime(int(desc.time)))
|
|
print(white("Description:"))
|
|
print(flow.cmd.text.light_yellow(desc.desc.rstrip()), sep="")
|
|
|
|
# Open the change in a gui.
|
|
if not self.args.printdiff and self.is_interactive():
|
|
try:
|
|
print("Starting pv4c...", end="")
|
|
p4utils.run_p4vc("change", desc.change)
|
|
print()
|
|
return
|
|
except:
|
|
print()
|
|
self.print_warning("Unabled to start 'p4vc'")
|
|
|
|
# Show a unfied diff of the line edit
|
|
self.print_info("Diff")
|
|
|
|
# Is the change an add action?
|
|
if edit_path_rev[1] == 1:
|
|
print("(No diff to show. This is revision 1)")
|
|
return
|
|
|
|
prev_path_rev = (edit_path_rev[0], edit_path_rev[1] - 1)
|
|
hunk_iter, param = _get_diff_hunk_for_line(line_no, prev_path_rev, edit_path_rev)
|
|
if not hunk_iter:
|
|
return
|
|
|
|
max_width = shutil.get_terminal_size(fallback=(9999,0))[0] - 2
|
|
for line in hunk_iter:
|
|
prefix = line[:1].decode()
|
|
line = line[1:].rstrip().expandtabs(4).decode()
|
|
|
|
decorator = lambda x: x
|
|
if prefix == "+": decorator = flow.cmd.text.green
|
|
elif prefix == "-": decorator = flow.cmd.text.red
|
|
print(decorator(prefix + line[:max_width]))
|