518 lines
20 KiB
Python
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
|