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

518 lines
20 KiB
Python

# Copyright Epic Games, Inc. All Rights Reserved.
import os
import re
import p4utils
import flow.cmd
import subprocess
from peafour import P4
#-------------------------------------------------------------------------------
def _untag_desc(desc):
tags = ("#fyi", "#review", "#codereview", "#robomerge")
out = ""
for line in desc.splitlines():
for tag in (x for x in tags if x in line):
line = line.replace(tag, f"#[{tag[1:]}]")
out += line + "\n"
return out
#-------------------------------------------------------------------------------
class Cherrypick(flow.cmd.Cmd):
""" Integrates/unshelves one or more changelists into the current branch,
resolving and clearing integration records as required. To clear integration
records only give a single pending changelist from the current client. Providing
a value for 'path' allows the cherrypick to be limited to a subset of files.
'path' should be either relative to a branch root; Engine/.../Runtime, or a
full depot path at the destination; //Dest/Stream/Engine/Source/... t.ex. The
command will *not* submit anything on your behalf. If in doubt you can use
'--dryrun' to preview the action. """
changelist = flow.cmd.Arg([int], "Changelist or shelve to edit-integrate")
path = flow.cmd.Opt("", "Restrict cherrypick to the given path (e.g. Engine/...)")
saferesolve = flow.cmd.Opt(False, "Resolve safely and not automatically")
noresolve = flow.cmd.Opt(False, "Skip the resolve step")
dryrun = flow.cmd.Opt(False, "Only pretend to do the cherrypick")
force = flow.cmd.Opt(False, "Force the operation through")
novalidate = flow.cmd.Opt(False, "Don't run the validation step")
alwayseddy = flow.cmd.Opt(False, "Always edigrate the result regardless of relation")
noeddy = flow.cmd.Opt(False, "Skip the edigrate step")
sync = flow.cmd.Opt(False, "Syncs target files to head before resolving")
virtual = flow.cmd.Opt(False, "Perform the integration server-side")
rawbranchspec = flow.cmd.Opt(False, "Ignore the internal branchspec that Perforce generates")
complete_changelist = False
def get_explicit_relations(self):
return ()
def main(self):
if not self.args.changelist:
raise ValueError("No changelist(s) given")
# Check the user's logged into the current Perforce environment
username = p4utils.login()
# Get info about where we're cherrypicking to
self.print_info("Determining destination")
info = P4.info().run()
print("Server:", getattr(info, "proxyAddress", info.serverAddress))
print("Client:", info.clientName)
if info.clientName == "*unknown*":
raise EnvironmentError("Unknown Perforce client")
# Don't let the user cherrypick to somewhere out of where they think they
# are inadvertently.
cwd = os.getcwd()
if os.path.normpath(info.clientRoot).lower() not in cwd.lower():
raise EnvironmentError(f"Directory '{cwd}' is not under client '{info.clientName}'")
dest_where = P4.where("Engine").depotFile
dest_root = p4utils.get_branch_root(dest_where)
print("Root path: " + dest_root)
# Condition self.args.path
if path := self.args.path:
path = path.replace("\\", "/")
if path.startswith("//"):
path = "//" + "/".join(x for x in path.split("/") if x)
if not path.lower().startswith(dest_root.lower()):
raise ValueError(f"'{path}' is unrelated to '{dest_root}'")
else:
path = dest_root + "/".join(x for x in path.split("/") if x)
self.args.path = path
# Get information about each input changelist
self.print_info("Fetching info on changelists to process")
descs = []
for cl in sorted(self.args.changelist):
print(cl, end="")
desc = P4.describe(str(cl), S=True).run()
descs.append(desc)
print(":", desc.desc.replace("\n", " ")[:68])
# If there's only one changelist and it's pending on the current client
# then clear integration records.
if len(descs) == 1:
desc = descs[0]
if desc.client == info.clientName and desc.status == "pending":
print("Change is pending changelist on current client")
return self._clear_integration_records(desc.change)
# Work out the branch root of each input changelist
self.print_info("Determining branch roots")
pick_rota = []
branch_roots = set()
for desc in descs:
path = getattr(desc, "path", None)
if not path:
depot_file = getattr(desc, "depotFile", None)
path = next(depot_file) if depot_file else ""
for branch_root in branch_roots:
if path.startswith(branch_root):
break
else:
branch_root = p4utils.get_branch_root(path) if path else ""
branch_roots.add(branch_root)
print(desc.change, branch_root)
pick_rota.append((branch_root, desc))
# Validate that we can proceed.
self.print_info("Validating input changelists")
for root, desc in pick_rota:
print(desc.change, end="")
if desc.status == "pending":
if not hasattr(desc, "shelved"):
print(" ... fail")
raise ValueError(f"Pending changelist {desc.change} has no shelved files")
elif root == dest_root:
print(" ... fail")
raise ValueError(f"Changelist {desc.change} already submitted to "
f"cherrypick destination '{dest_root}'")
elif not root:
print(" ... fail")
raise ValueError(f"Unable to determine branch root for {desc.change}")
print(" ... ok")
# Get a set of the destinations files
dest_paths = set()
for root, desc in pick_rota:
skip = len(root)
for src_path in desc.depotFile:
dest_paths.add(dest_root + src_path[skip:])
# Filter destination files by --path=
if path_spec := self.args.path:
print(f"Limiting input to {path_spec} : ", end="")
path_spec = path_spec.replace("...", "@")
path_spec = path_spec.replace(".", "\\.")
path_spec = path_spec.replace("*", "[^/]*")
path_spec = path_spec.replace("@", ".*")
prev_dest_paths_len = len(dest_paths)
dest_paths = [x for x in dest_paths if re.search(path_spec, x, re.IGNORECASE)]
print(prev_dest_paths_len - len(dest_paths), "of", prev_dest_paths_len, "removed")
# Check that none of the destination files are aleady opened for edit
self.print_info("Validating destination")
print("Affected files:", len(dest_paths))
print("Checking for open files")
validate = not (self.args.novalidate or self.args.force)
if len(dest_paths) > 500:
validate = False
self.print_warning("Skipping validation. Source changelist is too big")
if validate:
def read_dest_paths_and_count():
count = 1
for x in dest_paths:
print("\r" + str(count), "file(s) checked", end="")
count += 1
yield x
blocked = False
opened = P4.opened(read_dest_paths_and_count())
for item in opened:
self.print_warning("\r", item.depotFile[len(dest_root):])
blocked = True
if blocked:
raise EnvironmentError("The cherrypick of changelist"
f" {desc.change} is blocked because some files are"
" already open for edit at the destination.")
print()
print("All good. No target files were found to be already open for edit")
elif self.args.force:
self.print_warning("Validation was explicitly skipped")
# Create a target changelist for the cherrypick(s)
cl_desc = ""
for _, desc in pick_rota:
cl_desc += _untag_desc(desc.desc).rstrip()
cl_desc += "\n"
for _, desc in pick_rota:
cl_desc += f"\n#ushell-cherrypick of {desc.change} by {desc.user}"
cl_spec = { "Change" : "new", "Description" : cl_desc }
P4.change(i=True).run(input_data=cl_spec)
dest_cl = P4.changes(c=info.clientName, m=1, s="pending").change
server_version = 0.0
if m := re.match(r".*\/(20\d+\.\d+)\/.*", info.serverVersion):
server_version = float(m.groups()[0])
# Integrate or unshelve each input changelist
specs = {}
self.print_info("Cherrypicking into", dest_cl)
for src_root, desc in pick_rota:
cl = desc.change
branch_spec, file_spec = specs.get(src_root, (None, None))
if not branch_spec:
print("Creating branch spec for", src_root)
branch_spec = p4utils.TempBranchSpec("cherrypick", username, src_root, dest_root, self.args.rawbranchspec)
print(str(branch_spec))
file_spec = src_root + "..."
if path := self.args.path:
file_spec = src_root + path[len(dest_root):]
print(f"Limiting to path '{file_spec}'")
specs[src_root] = branch_spec, file_spec
def on_info(info):
output = info.data
if "must resolve" in output or "also opened" in output:
return
print("")
self.print_warning(info.data)
p4_args = {
"b" : branch_spec,
"n" : self.args.dryrun,
"f" : self.args.force,
"v" : self.args.virtual,
"c" : dest_cl,
}
if desc.status == "pending":
p4_args["-bypass-exclusive-lock"] = True
print(f"Unshelving {cl} from", src_root, end="")
file_spec_dest = file_spec.replace(src_root, dest_root)
unshelve = P4.unshelve(file_spec_dest, s=cl, **p4_args)
for x in unshelve.read(on_info=on_info): pass
print("")
else:
# 2024.1 no longer allows branch spec merges by default so we must also pass -F
if server_version >= 2024.1:
p4_args["F"] = True
print(f"Integrating {cl} from", src_root, end="")
integrate = P4.integrate(s=file_spec + f"@{cl},@{cl}", **p4_args)
for x in integrate.read(on_info=on_info): pass
print("")
# We can't do anymore pretending after this point.
if self.args.dryrun:
P4.change(dest_cl, d=True).run()
return
# Collect files that might be in other changelists
if self.args.force:
P4.reopen(dest_paths, c=dest_cl).run(on_error=False)
self.print_info("Resolving")
# Sync to latest if requested to do so
if self.args.sync:
print("Syncing to head first (--sync)", end="")
def read_sync_paths(cl):
for item in P4.opened(c=cl):
yield item.depotFile
sync = P4.sync(read_sync_paths(dest_cl), q=True)
for x in sync.read(on_error=False):
pass
print("")
# Resolve
resolve_count = 0
def print_error(error):
print("\r", end="")
msg = error.data.rstrip()
if msg.startswith("No file(s)"):
print(msg)
else:
self.print_error(msg)
if self.args.noresolve:
print("Skipping resolve")
else:
resolve_args = {
"as" : self.args.saferesolve,
"am" : not self.args.saferesolve,
"f" : self.args.force,
"c" : dest_cl,
}
resolve = P4.resolve(**resolve_args)
for item in resolve.read(on_error=print_error):
if getattr(item, "clientFile", None):
resolve_count += 1
print("\r" + str(resolve_count), "file(s) resolved", end="")
if resolve_count:
print("")
# Report conflicted files
resolve = P4.resolve(c=dest_cl, n=True)
for item in resolve.read(on_error=False):
if name := getattr(item, "fromFile", None):
self.print_error(name[len(src_root):])
# Queue up some details to display when everything's complete
class OnExit(object):
def __del__(self):
print("\nCherrypick complete;", dest_cl)
on_exit = OnExit()
# If there are branch/integrates in dest_cl there's nothing more to do
for item in P4.opened(c=dest_cl):
if item.action in ["integrate", "branch"]:
break
else:
print("No integrated files found")
return
# Post-process the cherrypick'd files.
self.print_info("Processing integration records")
if self.args.noeddy:
self.print_warning("Skipped")
return
# There's nothing more to do if the source and destination are related.
relations_allowed = (len(specs) == 1)
relations_allowed &= (descs[0].status != "pending")
relations_allowed &= not self.args.alwayseddy
dest_stream = getattr(info, "clientStream", None)
if dest_stream and relations_allowed:
print("Checking relations between src and dest")
def get_stream_parent(stream):
stream_info = P4.stream(stream, o=True).run()
parent = stream_info.Parent
while parent.startswith("//"):
if stream_info.Type != "virtual":
break
stream_info = P4.stream(parent, o=True).run()
parent = stream_info.Parent
return parent
# Check if we've fetched from a parent stream.
dest_parent = get_stream_parent(dest_stream)
if src_root.startswith(dest_parent):
print(f"Not required; {src_root} and {dest_root} are related")
return
# Check if we've fetch from a child stream
src_client = P4.client(descs[0].client, o=True).run()
src_stream = getattr(src_client, "Stream", None)
if src_stream and dest_root.startswith(get_stream_parent(src_stream)):
print(f"Not required; {src_root} and {dest_root} are related")
return
# No stream relation but maybe there's an explicit branch relation
if relations_allowed:
explicit_relations = self.get_explicit_relations()
for relatives in explicit_relations:
related = src_root.startswith(relatives[0]) and dest_root.startswith(relatives[1])
related |= src_root.startswith(relatives[1]) and dest_root.startswith(relatives[0])
if related:
print("Not required; explicit relations;")
print(" ", relatives[0])
print(" ", relatives[1])
return
ret = self._clear_integration_records(dest_cl)
return ret
def _resolve_prompt(self, changelist):
P4.resolve(c=changelist, n=True).run()
self.print_error("There are pending conflicts which must be resolved before continuing")
print("Now you have a few courses of action available to you;")
print(" [r]esolve with P4V")
print(" [c]ommand line resolve")
print(" re[v]ert and exit")
print(" retry [Enter]")
print(" abort [Ctrl-C]")
choice = input("Which one do you fancy? [r/c/v/Enter/Ctrl-C] ")
try:
if choice == "c":
cmd = ("p4", "resolve", "-du", "-c", changelist)
subprocess.run(cmd)
elif choice == "r":
#p4utils.run_p4vc("p4vc", "resolve", "-c", changelist, "...") # this just doesn't work...
p4utils.run_p4vc("pendingchanges")
input("Press Enter to continue (Ctrl-C to abort)...")
elif choice == "v":
self.print_info("")
cmd = ("p4", "revert", "-wc", changelist, "...")
subprocess.run(cmd)
cmd = ("p4", "change", "-d", changelist)
subprocess.run(cmd)
return False
except FileNotFoundError:
self.print_error("Failed to run command;", *cmd)
print()
def _clear_integration_records(self, changelist):
self.print_info("Clearing integration records from", changelist)
# Editgrates are destructive so we'll need to interact with the user if
# they have pending resolves.
try:
while True:
self._resolve_prompt(changelist)
except P4.Error:
pass
# Check the pending changelist is a pending
open_files = list(P4.opened(c=changelist))
if not open_files:
self.print_warning(f"Changelist {changelist} is empty")
return False
# Work out what to do with each file
to_edit = []
to_add = []
to_delete = []
to_move = []
action_map = {
"edit" : to_edit,
"integrate" : to_edit,
"add" : to_add,
"branch" : to_add,
"delete" : to_delete,
"move/delete" : to_move,
"move/add" : None,
}
for item in open_files:
action_list = action_map.get(item.action)
if action_list != None:
action_list.append(item)
print(" Adds:", len(to_add))
print(" Edits:", len(to_edit))
print("Deletes:", len(to_delete))
print(" Moves:", len(to_move))
print(" Total:", len(open_files))
self.print_info("Clearing integration records")
# Sync any edited files that are not on the client
to_sync = [x.depotFile for x in to_edit if x.haveRev == "none"]
if to_sync:
print("Syncing edited files that are not on the client...", end="")
for item in P4.sync(to_sync, q=True):
pass
print("done")
def read_files(items):
for item in items:
yield item.depotFile
# Server-revert and edit/add/delete files to drop integrate records.
print("Reverting server side .. ", end="")
revert = P4.revert("//...", c=changelist, k=True)
for action in revert:
pass
print("done")
p4args = {
"c" : changelist,
}
if to_edit:
print("Reopening .............. ", end="")
for item in P4.edit(read_files(to_edit), **p4args):
pass
print("done")
if to_add:
print("Adding ................. ", end="")
for item in P4.add(read_files(to_add), c=changelist):
pass
print("done")
if to_delete:
print("Deleting ............... ", end="")
for item in P4.delete(read_files(to_delete), **p4args):
pass
print("done")
if to_move:
print("Applying moves;")
print(" Reopening ............ ", end="")
for item in P4.edit(read_files(to_move), **p4args):
pass
print("done")
print(" Moving ............... ", end="")
for item in to_move:
P4.move(item.depotFile, item.movedFile, **p4args).run()
print("done")
return True