Files
UnrealEngine/Engine/Extras/ushell/channels/unreal/perforce/cmds/who.py
2025-05-18 13:04:45 +08:00

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]))