# Copyright Epic Games, Inc. All Rights Reserved. import os import unreal import p4utils import flow.cmd import unrealcmd from peafour import P4 from pathlib import Path #------------------------------------------------------------------------------- class _SyncBase(unrealcmd.Cmd): def _write_p4sync_txt_header(self, out): out.write("# lines starting with a '#' are comments\n") out.write("# lines prefixed with '-' are excluded from your sync\n") out.write("# -.../Android/...\n") out.write("# -/Engine/Source/...\n") out.write("# -*.uasset\n") #------------------------------------------------------------------------------- class Sync(_SyncBase): """ Syncs the current project and engine directories to the given changelist (or latest if none is provided). If the '--all' option is specified then the branch will be searched locally for existing .uproject files, scheduling each one to be synced. Specifying 'have' as the chagelist to sync will use the same value as returned by '.info'. The sync can be filtered with a .p4sync.txt file. Lines prefixed with a '-' (e.g. "-.../SootySweep/...") will be excluded from the sync and anything already synced is de-synced. The command will read .p4sync.txt files from two locations; 1. /.p4sync.txt 2. ~/.ushell/.p4sync.txt (where ~ is USERPROFILE on Windows) Quick edit the branch's .p4sync.txt file with '.p4 sync edit'. An example of a .p4sync.txt file is as follows; # a comment -.../Android/... -/Engine/Source/... -*.uasset """ changelist = unrealcmd.Arg("", "The changelist to sync to ('now' if unspecified)") noresolve = unrealcmd.Opt(False, "Do not run 'p4 resolve -am' after syncing") dryrun = unrealcmd.Opt(False, "Only pretend to do the sync") all = unrealcmd.Opt(False, "Sync all the branch's projects found locally") addprojs = unrealcmd.Opt("", "Comma-separated names of additional projects to sync") clobber = unrealcmd.Opt(False, "Clobber writable files when syncing") echo = unrealcmd.Opt(False, "Echo depot paths as they are synced") complete_changelist = ("have", ) def complete_addprojs(self, prefix): # Fake a _local_root local_root = Path(os.getcwd()) for parent in local_root.parents: if (parent / "GenerateProjectFiles.bat").is_file(): local_root = parent break self._local_root = local_root # Now we can fetch an approximate context and list it's projects ue_context = self._try_get_unreal_context() if not ue_context: return branch = ue_context.get_branch() if not branch: return return (x.stem for x in branch.read_projects()) @unrealcmd.Cmd.summarise def main(self): self._client_spec_restore = None try: return self._main_impl() finally: try: if self._client_spec_restore: print("Restoring client spec") client_spec = self._client_spec_restore client_spec["Options"] = client_spec["Options"].replace("clobber", "noclobber") client_spec["Description"] = client_spec["Description"].replace("{ushell_clobber_patch}", "") P4.client(i=True).run(input_data=client_spec) except: pass def _setup(self): self.print_info("Perforce environment") # Check there's a valid Perforce environment. username = p4utils.login() if not p4utils.ensure_p4config(): self.print_warning("Unable to establish a P4CONFIG") # Get some info about the Perforce environment and show it to the user info = P4.info().run() print("Client:", info.clientName) print(" User:", info.userName) print("Server:", getattr(info, "proxyAddress", info.serverAddress)) # Inform the user if Perforce didn't find the client. if info.clientName == "*unknown*": client_name = p4utils.get_p4_set("P4CLIENT") _, p4config_name = p4utils.has_p4config(".") raise EnvironmentError(f"Client '{client_name}' not found. Please check P4CLIENT setting in '{p4config_name}'") # So that P4.where can succeed we sync one known file first. This also # ensures we can accomodate an unsynced stream switch. for x in P4.sync(f"//{info.clientName}/GenerateProjectFiles.bat").read(on_error=False): pass # Find the root of the current branch self.print_info("Discovering branch root") branch_root = p4utils.get_branch_root(f"//{info.clientName}/Engine/Source") print("Branch root:", branch_root) # Map branch root somewhere on the local file system local_root = P4.where(branch_root + "X").path local_root = local_root[:-1] # to strip 'X' print("Local root:", local_root) self._info = info self._branch_root = branch_root self._local_root = local_root def _try_get_unreal_context(self): try: ue_context = self.get_unreal_context() branch = ue_context.get_branch() # If branch doesn't match os.getcwd() then ditch it if not (branch and branch.get_dir().samefile(self._local_root)): raise EnvironmentError except EnvironmentError: try: cwd = os.getcwd() ue_context = unreal.Context(cwd) except EnvironmentError: ue_context = None return ue_context def _add_paths(self, syncer): # Add the set of paths that all syncs should include syncer.add_path(self._local_root + "*") syncer.add_path(self._local_root + "Engine/...") templates = self._local_root + "Templates/..." if P4.files(templates, m=1).run(on_error=lambda x: None) is not None: syncer.add_path(templates) # If we've a valid context by this point we can try and use it. glob_for_projects = False self._current_cl = 0 if ue_context := self._try_get_unreal_context(): project = ue_context.get_project() if self.args.all or not project: if branch := ue_context.get_branch(): project_count = 0 self.print_info("Syncing all known projects") for uproj_path in branch.read_projects(): print(uproj_path.stem) syncer.add_path(str(uproj_path.parent) + "/...") project_count += 1 # If we have somehow managed to not find any projects then # fallback to globbing for them. if not project_count: print("No projects found via .uprojectdirs") print("Falling back to a glob") glob_for_projects = True else: # By default the active project is synced self.print_info("Single project sync") print("Project:", project.get_name()) syncer.add_path(str(project.get_dir()) + "/...") # Extra projects if self.args.addprojs and not self.args.all: add_projects = self.args.addprojs.replace("/", ",") add_projects = (x.strip() for x in add_projects.split(",")) add_projects = {x for x in add_projects if x} known_projects = list(ue_context.get_branch().read_projects()) known_projects = {x.stem.lower():x for x in known_projects} self.print_info("Additional projects to sync;") for add_project in add_projects: print(add_project, ": ", sep="", end="") add_project = add_project.lower() if add_project not in known_projects: print("not found") continue add_project = known_projects[add_project] add_project = add_project.parent syncer.add_path(str(add_project) + "/...") print(add_project) engine_info = ue_context.get_engine().get_info() self._current_cl = engine_info.get("Changelist", 0) else: glob_for_projects = True if glob_for_projects: # There does not appear to be a fully formed branch so we will infer # `--all` here on behalf of the user. self.print_info("Syncing all projects by **/.uproject") for uproj_path in Path(self._local_root).glob("**/*.uproject"): print(uproj_path.stem) syncer.add_path(str(uproj_path.parent) + "/...") def _get_have_cl(self): ue_context = self._try_get_unreal_context() if not ue_context or ue_context.get_branch() is None: raise EnvironmentError("No UE branch found - cannot determine 'have' changelist") engine = ue_context.get_engine() info = engine.get_info() ret = int(info["Changelist"]) if ret <= 0: raise ValueError(f"Unexpected branch changelist '{ret}'") return ret def _main_impl(self): self._setup() # Determine the changelist to sync sync_cl = self.args.changelist if sync_cl == "have": sync_cl = self._get_have_cl() sync_cl = int(sync_cl or -1) if sync_cl < 0: sync_cl = int(P4.changes(self._branch_root + "...", m=1).change) # Remove "noclobber" from the user's client spec client = P4.client(o=True).run() client_spec = client.as_dict() client_spec.setdefault("Description", "") if self.args.clobber: self.print_info("Checking for 'noclobber'") if "noclobber" in client_spec["Options"]: client_spec["Options"] = client_spec["Options"].replace("noclobber", "clobber") client_spec["Description"] += "{ushell_clobber_patch}" self._client_spec_restore = client_spec.copy() if not self.args.dryrun or True: print(f"Patching {client.Client} with 'clobber'") P4.client(i=True).run(input_data=client_spec) else: print("Clobbering is already active") if not self._client_spec_restore: if "{ushell_clobber_patch}" in client_spec["Description"]: if "noclobber" not in client_spec["Options"]: self._client_spec_restore = client_spec.copy() # Add the paths we always want to sync syncer = p4utils.Syncer() self._add_paths(syncer) # Load and parse the .p4sync.txt file self._apply_p4sync_txt(syncer) version_cl = 0 build_ver_path = self._local_root + "Engine/Build/Build.version" try: # Special case to force sync Build.version. It can get easily modified # without Perforce's knowledge, complicating the sync. if not self.args.dryrun: P4.sync(build_ver_path + "@" + str(sync_cl), qf=True).run(on_error=False) # GO! self.print_info("Scheduling sync") print("Changelist:", sync_cl, f"(was {self._current_cl})") print("Requesting... ", end="") syncer.schedule(sync_cl) self.print_info("Syncing") ok = syncer.sync(dryrun=self.args.dryrun, echo=self.args.echo) if self.args.dryrun or not ok: return ok # Sync succeeded, update cl for build.version even if something goes wrong with resolving version_cl = sync_cl # Auto-resolve on behalf of the user. if not self.args.noresolve: conflicts = set() self.print_info("Resolving") for item in P4.resolve(am=True).read(on_error=False): path = getattr(item, "fromFile", None) if not path: continue path = path[len(self._branch_root):] if getattr(item, "how", None): conflicts.remove(path) print(path) else: conflicts.add(path) for conflict in conflicts: print(flow.cmd.text.light_red(conflict)) except KeyboardInterrupt: print() if not self.args.dryrun: self.print_warning(f"Sync interrupted! Writing build.version to CL {version_cl}") return False finally: if not self.args.dryrun: # Record the synced changelist in Build.version with open(build_ver_path, "r") as x: lines = list(x.readlines()) import stat build_ver_mode = os.stat(build_ver_path).st_mode os.chmod(build_ver_path, build_ver_mode|stat.S_IWRITE) with open(build_ver_path, "w") as x: for line in lines: if r'"Changelist"' in line: line = line.split(":", 2) line = line[0] + f": {version_cl},\n" elif r'"BranchName"' in line: line = "\t\"BranchName\": \"X\"\n" line = line.replace("X", self._branch_root[:-1].replace("/", "+")) x.write(line) def _apply_p4sync_txt(self, syncer): view_filter = syncer.get_view_filter() def load_lines(index, path): print("Source:", index, os.path.normpath(path), end="") try: with open(path, "rt") as sync_config: lines = [x.strip() for x in sync_config] print(" ... ", len(lines), "lines") return lines except: print(" ... not found") def impl_exclusions(index, lines): def read_exclusions(): for line in lines: if line.startswith("-"): yield line[1:] elif line.startswith("$-"): yield line[2:] for i, line in enumerate(read_exclusions()): view = None if line.startswith("*."): view = ".../" + line elif line.startswith("/"): view = line[1:] elif line.startswith("..."): view = line print(" %d.%02d" % (index, i), "excl", end=" ") if view and (view.count("/") or "/*." in view or view.startswith("*.")): view = self._branch_root + view view_filter.add_exclude(view) print(view) else: view = view or line print(flow.cmd.text.light_yellow(view + " (ill-formed)")) def impl_extra_roots(index, lines): view_query = view_filter.get_query() def read_extra_roots(): for line in lines: if line.startswith("/"): yield line[1:] elif line.startswith("$/"): yield line[2:] for i, extra_root in enumerate(read_extra_roots()): extra_root = self._branch_root + extra_root syncer.add_path(extra_root) print(" %d.%02d" % (index, i), "sync", extra_root) if result := view_query.is_excluded(extra_root): print( " " * 11, flow.cmd.text.light_yellow(f"ignoring exclusion '{result}'"), "(contradiction)" ) view_filter.remove_exclude(result) self.print_info("Applying .p4sync.txt") liness = [] for index, dir in enumerate((self.get_home_dir(), self._local_root)): if lines := load_lines(index, dir + ".p4sync.txt"): liness.append((index, lines)) for index, lines in liness: impl_exclusions(index, lines) for index, lines in liness: impl_extra_roots(index, lines) #------------------------------------------------------------------------------- class Edit(_SyncBase): """ Opens .p4sync.txt in an editor. The editor is selected from environment variables P4EDITOR, GIT_EDITOR, and the system default editor. """ def main(self): username = p4utils.login() cwd = os.getcwd() client = p4utils.get_client_from_dir(cwd, username) if not client: raise EnvironmentError(f"Unable to establish the clientspec from '{cwd}'") _, root_dir = client path = Path(root_dir) / ".p4sync.txt" if not path.is_file(): with path.open("wt") as out: self._write_p4sync_txt_header(out) print("Editing", path) self.edit_file(path)