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

298 lines
10 KiB
Python

# Copyright Epic Games, Inc. All Rights Reserved.
import os
import sys
import p4utils
import unrealcmd
from peafour import P4
import subprocess as sp
from pathlib import Path
#-------------------------------------------------------------------------------
class _Bisectomatron(object):
def __init__(self, length):
self._good = 0
self._bad = length - 1
self._jump = -1
self._ugly = set()
self._bisect()
def get_good(self): return self._good
def get_bad(self): return self._bad
def get_index(self): return self._index
def get_state(self): return self._good, self._index, self._bad, self._ugly
def __bool__(self):
return (self._bad - self._good) > len(self._ugly) + 1
def _bisect(self):
self._index = (self._good + self._bad) // 2
while self._index in self._ugly:
self._index += self._jump
def good(self):
self._ugly = {x for x in self._ugly if x > self._index}
self._good = self._index
self._jump = 1
self._bisect()
def bad(self):
self._ugly = {x for x in self._ugly if x < self._index}
self._bad = self._index
self._jump = -1
self._bisect()
def ugly(self):
self._ugly.add(self._index)
while self:
jump = self._jump
self._jump = -jump - (jump // abs(jump))
self._index += jump
if self._index not in self._ugly:
break
#-------------------------------------------------------------------------------
def _resolve_script_path(script_path):
if os.name != "nt" and script_path.endswith(".sh"):
raise ValueError(f"A suitable script interpreter should also specified; 'bash {script_path}'");
import shutil
if whiched := shutil.which(script_path):
return Path(whiched)
script_path = Path(script_path).absolute()
if script_path.is_file():
return script_path
raise ValueError(f"Script '{script_path}' does not exist");
#-------------------------------------------------------------------------------
class Bisect(unrealcmd.Cmd):
""" Bisects a range of changelists between 'good' and 'bad' (exclusive). If
a failed-build is reported, then bisection expands outwards from the active
changelist until bisection resumed.
The optional 'script' argument can be used to automate a bisection. Which change
is considered next is controlled by the scripts exit code; 0=good, 80=bad, and
90=failed_build. The exit codes are chosen to avoid interference from general
scripting errors. Here is an example Batch script that could be used with the
command line '.p4 bisect 123 456 -- path/to/script.bat test_case debug';
@echo off
.build editor %2
if errorlevel 1 exit 90
.run editor %2 -- -stdout -ExecCmds="Automation RunTests %1;Quit"
if errorlevel 1 exit 80
Here the 'test_case' and 'debug' arguments given when running the bisect are
passed on to the script and appear in '%1' and '%2' respectively. """
good = unrealcmd.Arg(int, "First changelist that is in good shape")
bad = unrealcmd.Arg(int, "Changelist that is known to be bad")
script = unrealcmd.Arg([str], "Path and arguments to a script to automate bisection")
dryrun = unrealcmd.Opt(False, "Do not sync or run the given script")
silentsync = unrealcmd.Opt(False, "Hide the output from '.p4 sync'")
clsfromcwd = unrealcmd.Opt(False, "Use the current directory to build candidate changelists")
def _user_choice(self):
return input("[g]ood, [b]ad, [f]ailed-build? ").lower()
def _script_choice(self):
if self.args.dryrun:
return _user_choice()
cmd = self._script
if self._script[0].endswith(".py"):
cmd = (sys.executable, *cmd)
ret = sp.run(cmd).returncode
if ret == 80: return "b"
if ret == 0: return "g"
if ret == 90: return "f"
self.print_warning("Unexpected script exit code;", ret)
return self._user_choice()
def _get_candidate_changes(self, good, bad):
# Check we've a branch-type UE context to get paths from
ue_context = None
use_cwd = self.args.clsfromcwd
try:
ue_context = self.get_unreal_context()
use_cwd = use_cwd or (ue_context.get_branch() == None)
except EnvironmentError:
use_cwd = True
# Fetch changes
depot_paths = []
if use_cwd or not ue_context:
depot_paths += "..."
else:
depot_paths.append(ue_context.get_engine().get_dir() / "...")
if project := ue_context.get_project():
depot_paths.append(project.get_dir() / "...")
temp_cls = []
self.print_info("Fetching changes from Perforce")
for i, depot_path in enumerate(depot_paths):
print(i, depot_path, end="")
temp_cls += [x for x in P4.changes(f"{depot_path}@{good},@{bad}", L=True)]
print()
# Sort and dedupe
temp_cls.sort(key=lambda x: x.change)
changes = temp_cls[:1]
for i, candidate in enumerate(temp_cls[1:]):
if candidate.change != temp_cls[i].change:
changes.append(candidate)
# Can't bisect a range that's too small
if len(changes) < 3:
raise EnvironmentError(f"Perforce returned too few changelists between {good} and {bad}")
print(len(changes), " changes to bisect")
# Remove CRs and LFs for neat(ish) printing
for item in changes:
item.desc = item.desc.replace("\n", " ")
item.desc = item.desc.replace("\r", "")
return changes
def main(self):
p4utils.login()
# Work out how we're going to interrogate bisect progress
self.print_info("Operating mode")
if self.args.script:
script, *args = self.args.script
script = _resolve_script_path(script)
self._script = (str(script), *args)
choice = self._script_choice
print("Script: ", script)
print("Arguments:", *args)
else:
print("Interactive")
choice = self._user_choice
# Our range of candidates submits must contain one good and one bad
# submit as we're really looking for a state change.
good, bad = self.args.good, self.args.bad
if good >= bad:
raise ValueError("Invalid range of changelists given (f >= l)")
# Ask Perforce for some changes
changes = self._get_candidate_changes(good, bad)
# Run the bisect
self.print_info("Bisection")
sync_stdout = sp.DEVNULL if self.args.silentsync else None
index_generator = _Bisectomatron(len(changes))
while index_generator:
index = index_generator.get_index()
change = changes[index]
# Sync
if not self.args.dryrun:
print(f"Syncing {change.change}...", end="" if sync_stdout else "\n")
sync_cl = changes[index].change
sync_ret = sp.run(
("_p4", "sync", sync_cl),
stdout=sync_stdout
)
if exitcode := sync_ret.returncode:
print()
raise RuntimeError(f"Failed syncing changelist {sync_cl} (exitcode={exitcode})")
print(" done")
# Pause and and ask the user what to do next.
while True:
good = index_generator.get_good()
bad = index_generator.get_bad()
print(f"\n\n{changes[index].change} ({good},{index},{bad}) {changes[index].desc[:64]}")
c = choice()
if c == "g":
# Search earlier
index_generator.good()
break
elif c == "b":
# Search later
index_generator.bad()
break
elif c == "f":
# Expand out from index looking for a successful compile
index_generator.ugly()
break
else:
print(f"Unknown input '{c}'!")
good_index = index_generator.get_good()
bad_index = index_generator.get_bad()
good_cl = changes[good_index]
bad_cl = changes[bad_index]
print("")
print("")
print("Bisection complete")
print("")
print("Good change:", good_cl.change)
print("Bad change: ", bad_cl.change)
#-------------------------------------------------------------------------------
def _test_bisectomatron():
import random
random.seed(37)
j = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def run(length, error_at, ugly=None):
def print_legend():
if ugly:
ugs = bytearray(b" " * (ugly[1] + 1))
for x in range(ugly[0], ugly[1] + 1):
ugs[x] = ord("x")
print(" ", ugs.decode(), sep="")
guide = ("_" * error_at) + "|" + ("-" * (length - error_at - 1))
print("[", guide, "]", sep="")
line = bytearray(b" " * length)
b = _Bisectomatron(length);
i = 0
while b:
index = b.get_index()
line[index] = j[i]
if ugly and index >= ugly[0] and index <= ugly[1]: b.ugly()
elif index < error_at: b.good()
else: b.bad()
i += 1
line[b.get_bad()] = ord("*")
ok = b.get_bad() == error_at
if ugly:
ok = error_at > b.get_good() and error_at <= b.get_bad()
if not ok:
print_legend()
print(" ", line.decode(), sep="")
print()
print(b.get_good(), b.get_bad())
for i in range(50000):
print(i, end="\r")
length = random.randint(0, 55) + 3
error_at = 1 + random.randint(0, length - 2)
run(length, error_at)
print()
for i in range(50000):
print(i, end="\r")
length = random.randint(3, 55)
error_at = random.randint(1, length - 2)
ugly = [0, 0]
ugly[0] = random.randint(1, length - 2)
ugly[1] = random.randint(ugly[0], length - 2)
run(length, error_at, ugly)