// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.OIDC; using EpicGames.Perforce; using EpicGames.ProjectStore; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace UnrealGameSync { [Flags] public enum WorkspaceUpdateOptions { Sync = 0x01, SyncSingleChange = 0x02, AutoResolveChanges = 0x04, GenerateProjectFiles = 0x08, SyncArchives = 0x10, Build = 0x20, Clean = 0x40, ScheduledBuild = 0x80, RunAfterSync = 0x100, OpenSolutionAfterSync = 0x200, ContentOnly = 0x400, UpdateFilter = 0x800, SyncAllProjects = 0x1000, IncludeAllProjectsInSolution = 0x2000, RemoveFilteredFiles = 0x4000, Clobber = 0x8000, Refilter = 0x10000, UprojectSpecificSolution = 0x20000, FastSync = 0x40000, ImportSnapshots = 0x80000 } public enum WorkspaceUpdateResult { Canceled, FailedToSync, FailedToSyncLoginExpired, FilesToDelete, FilesToResolve, FilesToClobber, FailedToCompile, FailedToCompileWithCleanWorkspace, Success, } public class PerforceSyncOptions { public const int DefaultNumRetries = 0; public const int DefaultNumThreads = 4; public const int DefaultTcpBufferSize = 0; public const int DefaultFileBufferSize = 0; public const int DefaultMaxCommandsPerBatch = 200; public const int DefaultMaxSizePerBatch = 128 * 1024 * 1024; public const int DefaultNumSyncErrorRetries = 0; public const int DefaultSyncErrorRetryDelay = 0; public const bool DefaultUseFastSync = false; public const bool DefaultUseNativeLibrary = true; public int? NumThreads { get; set; } public int? MaxCommandsPerBatch { get; set; } public int? MaxSizePerBatch { get; set; } public int? NumSyncErrorRetries { get; set; } public int? SyncErrorRetryDelay { get; set; } public bool? UseFastSync { get; set; } public bool? UseNativeClient { get; set; } public PerforceSyncOptions Clone() { return (PerforceSyncOptions)MemberwiseClone(); } } public class WorkspaceUpdateContext { public DateTime StartTime { get; set; } = DateTime.UtcNow; public int ChangeNumber { get; set; } public int? CodeChangeNumber { get; set; } public WorkspaceUpdateOptions Options { get; set; } public BuildConfig EditorConfig { get; set; } public List SyncFilter { get; } = new List(); public Dictionary ArchiveTypeToArchive { get; } = new Dictionary(); public Dictionary DeleteFiles { get; } = new Dictionary(); public Dictionary ClobberFiles { get; } = new Dictionary(); public List UserBuildStepObjects { get; } = new List(); public HashSet CustomBuildSteps { get; } = new HashSet(); public Dictionary AdditionalVariables { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public PerforceSyncOptions? PerforceSyncOptions { get; set; } public List HaveFiles { get; } = new List(); // Cached when sync filter has changed public List Snapshots { get; } = new List(); public string SnapshotHost { get; set; } = ""; public string SnapshotNamespace { get; set; } = ""; // May be updated during sync public ConfigFile? ProjectConfigFile { get; set; } public IReadOnlyList? ProjectStreamFilter { get; set; } public WorkspaceUpdateContext(int changeNumber, WorkspaceUpdateOptions options, BuildConfig editorConfig, string[]? syncFilter, List userBuildSteps, HashSet? customBuildSteps) { ChangeNumber = changeNumber; Options = options; EditorConfig = editorConfig; if (syncFilter != null) { SyncFilter.AddRange(syncFilter); } UserBuildStepObjects = userBuildSteps; if (customBuildSteps != null) { CustomBuildSteps.UnionWith(customBuildSteps); } } public static WorkspaceUpdateOptions GetOptionsFromConfig(GlobalSettings globalSettings, UserWorkspaceSettings workspaceSettings) { WorkspaceUpdateOptions options = 0; if (globalSettings.AutoResolveConflicts) { options |= WorkspaceUpdateOptions.AutoResolveChanges; } if (workspaceSettings.Filter.AllProjects ?? globalSettings.Filter.AllProjects ?? false) { options |= WorkspaceUpdateOptions.SyncAllProjects; } if (workspaceSettings.Filter.AllProjectsInSln ?? globalSettings.Filter.AllProjectsInSln ?? false) { options |= WorkspaceUpdateOptions.IncludeAllProjectsInSolution; } if (workspaceSettings.Filter.UprojectSpecificSln ?? globalSettings.Filter.UprojectSpecificSln ?? false) { options |= WorkspaceUpdateOptions.UprojectSpecificSolution; } if (globalSettings.Perforce.UseFastSync is true) { options |= WorkspaceUpdateOptions.FastSync; } return options; } } public class WorkspaceSyncCategory { public Guid UniqueId { get; set; } public bool Enable { get; set; } public string Name { get; set; } public List Paths { get; } = new List(); public bool Hidden { get; set; } public List Requires { get; } = new List(); public WorkspaceSyncCategory(Guid uniqueId) : this(uniqueId, "Unnamed") { } public WorkspaceSyncCategory(Guid uniqueId, string name, params string[] paths) { UniqueId = uniqueId; Enable = true; Name = name; Paths.AddRange(paths); } public static Dictionary GetDefault(IEnumerable categories) { return categories.ToDictionary(x => x.UniqueId, x => x.Enable); } public static Dictionary GetDelta(Dictionary source, Dictionary target) { Dictionary changes = new Dictionary(); foreach (KeyValuePair pair in target) { bool value; if (!source.TryGetValue(pair.Key, out value) || value != pair.Value) { changes[pair.Key] = pair.Value; } } return changes; } public static void ApplyDelta(Dictionary categories, Dictionary delta) { foreach (KeyValuePair pair in delta) { categories[pair.Key] = pair.Value; } } public override string ToString() { return Name; } } public class ProjectInfo { public DirectoryReference LocalRootPath { get; } // ie. local mapping of clientname + branchpath public string ClientName { get; } public string BranchPath { get; } // starts with a slash if non-empty. does not end with a slash. public string ProjectPath { get; } // starts with a slash, uses forward slashes public string? StreamName { get; } // name of the current stream public string ProjectIdentifier { get; } // stream path to project public bool IsEnterpriseProject { get; } // whether it's an enterprise project // derived properties public FileReference LocalFileName => new FileReference(LocalRootPath.FullName + ProjectPath); public string ClientRootPath => $"//{ClientName}{BranchPath}"; public string ClientFileName => $"//{ClientName}{BranchPath}{ProjectPath}"; public string TelemetryProjectIdentifier => PerforceUtils.GetClientOrDepotDirectoryName(ProjectIdentifier); public DirectoryReference EngineDir => DirectoryReference.Combine(LocalRootPath, "Engine"); public DirectoryReference? ProjectDir => ProjectPath.EndsWith(".uproject", StringComparison.OrdinalIgnoreCase) ? LocalFileName.Directory : null; public DirectoryReference DataFolder => GetDataFolder(LocalRootPath); public DirectoryReference CacheFolder => GetCacheFolder(LocalRootPath); public bool IsUEFNProject => ProjectPath.EndsWith(".uefnproject", StringComparison.OrdinalIgnoreCase); // whether it's a UEFN project public static DirectoryReference GetDataFolder(DirectoryReference workspaceDir) => DirectoryReference.Combine(workspaceDir, ".ugs"); public static DirectoryReference GetCacheFolder(DirectoryReference workspaceDir) => DirectoryReference.Combine(workspaceDir, ".ugs", "cache"); public ProjectInfo(DirectoryReference localRootPath, ReadOnlyWorkspaceState state) : this(localRootPath, state.ClientName, state.BranchPath, state.ProjectPath, state.StreamName, state.ProjectIdentifier, state.IsEnterpriseProject) { } public ProjectInfo(DirectoryReference localRootPath, string clientName, string branchPath, string projectPath, string? streamName, string projectIdentifier, bool isEnterpriseProject) { ValidateBranchPath(branchPath); ValidateProjectPath(projectPath); LocalRootPath = localRootPath; ClientName = clientName; BranchPath = branchPath; ProjectPath = projectPath; StreamName = streamName; ProjectIdentifier = projectIdentifier; IsEnterpriseProject = isEnterpriseProject; } public static async Task CreateAsync(IPerforceConnection perforceClient, UserWorkspaceSettings settings, CancellationToken cancellationToken) { string? streamName = await perforceClient.GetCurrentStreamAsync(cancellationToken); // Get a unique name for the project that's selected. For regular branches, this can be the depot path. For streams, we want to include the stream name to encode imports. string newSelectedProjectIdentifier; if (streamName != null) { string expectedPrefix = String.Format("//{0}/", perforceClient.Settings.ClientName); if (!settings.ClientProjectPath.StartsWith(expectedPrefix, StringComparison.InvariantCultureIgnoreCase)) { throw new UserErrorException($"Unexpected client path; expected '{settings.ClientProjectPath}' to begin with '{expectedPrefix}'"); } string? streamPrefix = await TryGetStreamPrefixAsync(perforceClient, streamName, cancellationToken); if (streamPrefix == null) { throw new UserErrorException("Unable to get stream prefix"); } newSelectedProjectIdentifier = String.Format("{0}/{1}", streamPrefix, settings.ClientProjectPath.Substring(expectedPrefix.Length)); } else { List> records = await perforceClient.TryWhereAsync(settings.ClientProjectPath, cancellationToken).Where(x => !x.Succeeded || !x.Data.Unmap).ToListAsync(cancellationToken); if (!records.Succeeded() || records.Count != 1) { throw new UserErrorException($"Couldn't get depot path for {settings.ClientProjectPath}"); } newSelectedProjectIdentifier = records[0].Data.DepotFile; Match match = Regex.Match(newSelectedProjectIdentifier, "//([^/]+)/"); if (match.Success) { DepotRecord depot = await perforceClient.GetDepotAsync(match.Groups[1].Value, cancellationToken); if (depot.Type == "stream") { throw new UserErrorException($"Cannot use a legacy client ({perforceClient.Settings.ClientName}) with a stream depot ({depot.Depot})."); } } } // Figure out if it's an enterprise project. Use the synced version if we have it. bool isEnterpriseProject = false; if (settings.ClientProjectPath.EndsWith(".uproject", StringComparison.InvariantCultureIgnoreCase)) { string text; if (FileReference.Exists(settings.LocalProjectPath)) { text = await FileReference.ReadAllTextAsync(settings.LocalProjectPath, cancellationToken); } else { PerforceResponse> projectLines = await perforceClient.TryPrintLinesAsync(settings.ClientProjectPath, cancellationToken); if (!projectLines.Succeeded) { throw new UserErrorException($"Unable to get contents of {settings.ClientProjectPath}"); } text = String.Join("\n", projectLines.Data.Contents!); } isEnterpriseProject = Utility.IsEnterpriseProjectFromText(text); } return new ProjectInfo(settings.RootDir, settings.ClientName, settings.BranchPath, settings.ProjectPath, streamName, newSelectedProjectIdentifier, isEnterpriseProject); } static async Task TryGetStreamPrefixAsync(IPerforceConnection perforce, string streamName, CancellationToken cancellationToken) { string? currentStreamName = streamName; while (!String.IsNullOrEmpty(currentStreamName)) { PerforceResponse response = await perforce.TryGetStreamAsync(currentStreamName, false, cancellationToken); if (!response.Succeeded) { return null; } StreamRecord streamSpec = response.Data; if (streamSpec.Type != "virtual") { return currentStreamName; } currentStreamName = streamSpec.Parent; } return null; } public static void ValidateBranchPath(string branchPath) { if (branchPath.Length > 0 && (!branchPath.StartsWith("/", StringComparison.Ordinal) || branchPath.EndsWith("/", StringComparison.Ordinal))) { throw new ArgumentException("Branch path must start with a slash, and not end with a slash", nameof(branchPath)); } } public static void ValidateProjectPath(string projectPath) { if (!projectPath.StartsWith("/", StringComparison.Ordinal)) { throw new ArgumentException("Project path must start with a slash", nameof(projectPath)); } if (!projectPath.EndsWith(".uproject", StringComparison.OrdinalIgnoreCase) && !projectPath.EndsWith(".uprojectdirs", StringComparison.OrdinalIgnoreCase) && !projectPath.EndsWith(".uefnproject", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Project path must be to a .uproject, .uefnproject or .uprojectdirs file", nameof(projectPath)); } } } public class WorkspaceUpdate { const string BuildVersionFileName = "/Engine/Build/Build.version"; const string VersionHeaderFileName = "/Engine/Source/Runtime/Launch/Resources/Version.h"; const string ObjectVersionFileName = "/Engine/Source/Runtime/Core/Private/UObject/ObjectVersion.cpp"; static readonly string LocalVersionHeaderFileName = VersionHeaderFileName.Replace('/', Path.DirectorySeparatorChar); static readonly string LocalObjectVersionFileName = ObjectVersionFileName.Replace('/', Path.DirectorySeparatorChar); static readonly SemaphoreSlim _updateSemaphore = new SemaphoreSlim(1); class RecordCounter : IDisposable { readonly ProgressValue _progress; readonly string _message; int _count; readonly Stopwatch _timer = Stopwatch.StartNew(); public RecordCounter(ProgressValue progress, string message) { _progress = progress; _message = message; progress.Set(message); } public void Dispose() { UpdateMessage(); } public void Increment() { _count++; if (_timer.ElapsedMilliseconds > 250) { UpdateMessage(); } } public void UpdateMessage() { _progress.Set(String.Format("{0} ({1:N0})", _message, _count)); _timer.Restart(); } } class SyncBatchBuilder { public int MaxCommandsPerList { get; } public long MaxSizePerList { get; } public Queue> Batches { get; } List? _commands; List? _deleteCommands; long _size; public SyncBatchBuilder(int? maxCommandsPerList, long? maxSizePerList) { MaxCommandsPerList = maxCommandsPerList ?? PerforceSyncOptions.DefaultMaxCommandsPerBatch; MaxSizePerList = maxSizePerList ?? PerforceSyncOptions.DefaultMaxSizePerBatch; Batches = new Queue>(); } public void Add(string newCommand, long newSize) { if (newSize == 0) { if (_deleteCommands == null || _deleteCommands.Count >= MaxCommandsPerList) { _deleteCommands = new List(); Batches.Enqueue(_deleteCommands); } _deleteCommands.Add(newCommand); } else { if (_commands == null || _commands.Count >= MaxCommandsPerList || _size + newSize >= MaxSizePerList) { _commands = new List(); Batches.Enqueue(_commands); _size = 0; } _commands.Add(newCommand); _size += newSize; } } } class SyncTree { public bool CanUseWildcard; public bool CanContainExcludedFiles { get; set; } = false; public int TotalIncludedFiles; public long TotalSize; public int TotalExcludedFiles; public Dictionary IncludedFiles = new Dictionary(); public Dictionary NameToSubTree = new Dictionary(StringComparer.OrdinalIgnoreCase); public SyncTree(bool canUseWildcard, bool canContainExcludedFiles) { CanUseWildcard = canUseWildcard; CanContainExcludedFiles = canContainExcludedFiles; } public SyncTree FindOrAddSubTree(string name) { SyncTree? result; if (!NameToSubTree.TryGetValue(name, out result)) { result = new SyncTree(CanUseWildcard, CanContainExcludedFiles); NameToSubTree.Add(name, result); } return result; } public void IncludeFile(string path, long size, ILogger logger) => IncludeFile(path, path, size, logger); private void IncludeFile(string fullPath, string path, long size, ILogger logger) { int idx = path.IndexOf('/', StringComparison.Ordinal); if (idx == -1) { if (!IncludedFiles.ContainsKey(path)) { IncludedFiles.Add(path, size); } } else { SyncTree subTree = FindOrAddSubTree(path.Substring(0, idx)); subTree.IncludeFile(fullPath, path.Substring(idx + 1), size, logger); } TotalIncludedFiles++; TotalSize += size; } public void ExcludeFile(string path) { if (!CanContainExcludedFiles) { return; } int idx = path.IndexOf('/', StringComparison.Ordinal); if (idx != -1) { SyncTree subTree = FindOrAddSubTree(path.Substring(0, idx)); subTree.ExcludeFile(path.Substring(idx + 1)); } TotalExcludedFiles++; } public void GetOptimizedSyncCommands(string prefix, int changeNumber, SyncBatchBuilder builder) { if (CanContainExcludedFiles) { GetOptimizedSyncCommandsWithExcludedFiles(prefix, changeNumber, builder); } else { GetOptimizedSyncCommandsWithoutExcludedFiles(prefix, changeNumber, builder); } } private void GetOptimizedSyncCommandsWithExcludedFiles(string prefix, int changeNumber, SyncBatchBuilder builder) { if (CanUseWildcard && TotalExcludedFiles == 0 && TotalSize < builder.MaxSizePerList) { builder.Add($"{prefix}/...@{changeNumber}", TotalSize); } else { foreach (KeyValuePair file in IncludedFiles) { builder.Add($"{prefix}/{file.Key}@{changeNumber}", file.Value); } foreach (KeyValuePair pair in NameToSubTree) { pair.Value.GetOptimizedSyncCommandsWithExcludedFiles($"{prefix}/{PerforceUtils.EscapePath(pair.Key)}", changeNumber, builder); } } } private void GetOptimizedSyncCommandsWithoutExcludedFiles(string prefix, int changeNumber, SyncBatchBuilder builder) { foreach (KeyValuePair file in IncludedFiles) { builder.Add($"{prefix}/{file.Key}@{changeNumber}", file.Value); } foreach (KeyValuePair pair in NameToSubTree) { pair.Value.GetOptimizedSyncCommandsWithoutExcludedFiles($"{prefix}/{PerforceUtils.EscapePath(pair.Key)}", changeNumber, builder); } } } public WorkspaceUpdateContext Context { get; } public ProgressValue Progress { get; } = new ProgressValue(); public WorkspaceUpdate(WorkspaceUpdateContext context) { Context = context; } class SyncFile { public string DepotFile; public string RelativePath; public long Size; public SyncFile(string depotFile, string relativePath, long size) { DepotFile = depotFile; RelativePath = relativePath; Size = size; } }; class SemaphoreScope : IDisposable { readonly SemaphoreSlim _semaphore; public bool HasLock { get; private set; } public SemaphoreScope(SemaphoreSlim semaphore) { _semaphore = semaphore; } public bool TryAcquire() { HasLock = HasLock || _semaphore.Wait(0); return HasLock; } public async Task AcquireAsync(CancellationToken cancellationToken) { await _semaphore.WaitAsync(cancellationToken); HasLock = true; } public void Release() { if (HasLock) { _semaphore.Release(); HasLock = false; } } public void Dispose() => Release(); } public static string ShellScriptExt { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "bat" : "sh"; public static Task ExecuteShellCommandAsync(string commandLine, Action processOutput, CancellationToken cancellationToken) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { string cmdExe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); return Utility.ExecuteProcessAsync(cmdExe, null, $"/C \"{commandLine}\"", processOutput, cancellationToken); } else { string shellExe = "/bin/sh"; return Utility.ExecuteProcessAsync(shellExe, null, $"{commandLine}", processOutput, cancellationToken); } } public async Task<(WorkspaceUpdateResult, string)> ExecuteAsync(IPerforceSettings perforceSettings, ProjectInfo project, WorkspaceStateWrapper stateMgr, ILogger logger, CancellationToken cancellationToken) { using IPerforceConnection perforce = await PerforceConnection.CreateAsync(new PerforceSettings(perforceSettings) { EnableHangMonitor = false }, logger); ReadOnlyWorkspaceState state = stateMgr.Current; List> times = new List>(); int numFilesSynced = 0; if (Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) || Context.Options.HasFlag(WorkspaceUpdateOptions.SyncSingleChange)) { using (TelemetryStopwatch syncTelemetryStopwatch = new TelemetryStopwatch("Workspace_Sync", project.TelemetryProjectIdentifier)) { logger.LogInformation("Syncing to {Change} on {ServerAndPort} as {UserName}...", Context.ChangeNumber, perforceSettings.ServerAndPort, perforceSettings.UserName); syncTelemetryStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); // Make sure we're logged in PerforceResponse loginResponse = await perforce.TryGetLoginStateAsync(cancellationToken); if (!loginResponse.Succeeded) { return (WorkspaceUpdateResult.FailedToSyncLoginExpired, "User is not logged in."); } // Figure out which paths to sync List relativeSyncPaths = GetRelativeSyncPaths(project, (Context.Options & WorkspaceUpdateOptions.SyncAllProjects) != 0, Context.SyncFilter); List syncPaths = new List(relativeSyncPaths.Select(x => project.ClientRootPath + x)); // Get the user's sync filter FileFilter userFilter = new FileFilter(FileFilterType.Include); userFilter.AddRules(Context.SyncFilter.Select(x => x.Trim()).Where(x => x.Length > 0 && !x.StartsWith(";", StringComparison.Ordinal) && !x.StartsWith("#", StringComparison.Ordinal))); // Check if the new sync filter matches the previous one. If not, we'll enumerate all files in the workspace and make sure there's nothing extra there. string? nextSyncFilterHash = null; #pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms using (SHA1 sha = SHA1.Create()) { StringBuilder combinedFilter = new StringBuilder(); foreach (string relativeSyncPath in relativeSyncPaths) { combinedFilter.AppendFormat("{0}\n", relativeSyncPath); } if (Context.SyncFilter.Count > 0) { combinedFilter.Append("--FROM--\n"); combinedFilter.Append(String.Join("\n", Context.SyncFilter)); } nextSyncFilterHash = StringUtils.FormatHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(combinedFilter.ToString()))); } #pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms // If the hash differs, enumerate everything in the workspace to find what needs to be removed if (nextSyncFilterHash != state.CurrentSyncFilterHash || (Context.Options & WorkspaceUpdateOptions.Refilter) != 0) { // disable fast sync to make sure that all the files are considered logger.LogInformation("Disabling fast sync as filters are being reconsidered."); Context.Options &= ~WorkspaceUpdateOptions.FastSync; using (TelemetryStopwatch filterStopwatch = new TelemetryStopwatch("Workspace_Sync_FilterChanged", project.TelemetryProjectIdentifier)) { logger.LogInformation("Filter has changed ({PrevHash} -> {NextHash}); finding files in workspace that need to be removed.", (String.IsNullOrEmpty(state.CurrentSyncFilterHash)) ? "None" : state.CurrentSyncFilterHash, nextSyncFilterHash); filterStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); // Find all the files that are in this workspace List haveFiles = Context.HaveFiles; if (haveFiles.Count == 0) { using (RecordCounter haveCounter = new RecordCounter(Progress, "Sync filter changed; checking workspace...")) { await foreach (PerforceResponse record in perforce.TryHaveAsync(FileSpecList.Any, cancellationToken)) { if (record.Succeeded) { haveFiles.Add(record.Data); haveCounter.Increment(); } else { return (WorkspaceUpdateResult.FailedToSync, $"Unable to query files ({record})."); } } } } // Build a filter for the current sync paths FileFilter syncPathsFilter = new FileFilter(FileFilterType.Exclude); foreach (string relativeSyncPath in relativeSyncPaths) { syncPathsFilter.Include(relativeSyncPath); } // Remove all the files that are not included by the filter const int MaxLogFiles = 1000; ConcurrentBag removeDepotPathsBag = new ConcurrentBag(); Parallel.ForEach(haveFiles, haveFile => { try { FileReference fullPath = new FileReference(haveFile.Path); if (MatchFilter(project, fullPath, syncPathsFilter) && !MatchFilter(project, fullPath, userFilter)) { removeDepotPathsBag.Add(haveFile.DepotFile); } } catch (PathTooLongException) { // We don't actually care about this when looking for files to remove. Perforce may think that it's synced the path, and silently failed. Just ignore it. } }); List removeDepotPaths = removeDepotPathsBag.ToList(); for (int i=0; i MaxLogFiles) { logger.LogInformation(" ...and {NumFiles} others.", removeDepotPaths.Count - MaxLogFiles); } // Check if there are any paths outside the regular sync paths if (removeDepotPaths.Count > 0 && (Context.Options & WorkspaceUpdateOptions.RemoveFilteredFiles) == 0) { bool deleteListMatches = true; Dictionary prevDeleteFiles = new Dictionary(Context.DeleteFiles, StringComparer.OrdinalIgnoreCase); Context.DeleteFiles.Clear(); foreach (string removeDepotPath in removeDepotPaths) { bool delete; if (!prevDeleteFiles.TryGetValue(removeDepotPath, out delete)) { deleteListMatches = false; delete = true; } Context.DeleteFiles[removeDepotPath] = delete; } if (!deleteListMatches) { return (WorkspaceUpdateResult.FilesToDelete, $"Cancelled after finding {Context.DeleteFiles.Count} files excluded by filter"); } removeDepotPaths.RemoveAll(x => !Context.DeleteFiles[x]); } // Actually delete any files that we don't want if (removeDepotPaths.Count > 0) { // Clear the current sync filter hash. If the sync is canceled, we'll be in an indeterminate state, and we should always clean next time round. state = stateMgr.Modify(x => x.CurrentSyncFilterHash = "INVALID"); // Find all the depot paths that will be synced HashSet remainingDepotPathsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase); remainingDepotPathsToRemove.UnionWith(removeDepotPaths); // Build the list of revisions to sync List revisionsToRemove = new List(); revisionsToRemove.AddRange(removeDepotPaths.Select(x => String.Format("{0}#0", x))); (WorkspaceUpdateResult, string) removeResult = await SyncFileRevisions(perforce, "Removing files...", Context, revisionsToRemove, remainingDepotPathsToRemove, Progress, logger, cancellationToken); if (removeResult.Item1 != WorkspaceUpdateResult.Success) { return removeResult; } } // Update the sync filter hash. We've removed any files we need to at this point. state = stateMgr.Modify(x => x.CurrentSyncFilterHash = nextSyncFilterHash); times.Add(new Tuple("Sync Filters Changed", filterStopwatch.Stop("Success"))); } } // Create a filter for all the files we don't want FileFilter filter = new FileFilter(userFilter); filter.Exclude(BuildVersionFileName); if (Context.Options.HasFlag(WorkspaceUpdateOptions.ContentOnly)) { filter.Exclude("*.usf"); filter.Exclude("*.ush"); } // Create a tree to store the sync path bool canContainExcludedFiles = !Context.Options.HasFlag(WorkspaceUpdateOptions.FastSync); SyncTree syncTree = new SyncTree(false, canContainExcludedFiles); if (!Context.Options.HasFlag(WorkspaceUpdateOptions.SyncSingleChange)) { foreach (string relativeSyncPath in relativeSyncPaths) { const string wildcardSuffix = "/..."; if (relativeSyncPath.EndsWith(wildcardSuffix, StringComparison.Ordinal)) { SyncTree leaf = syncTree; string[] fragments = relativeSyncPath.Split('/'); for (int idx = 1; idx < fragments.Length - 1; idx++) { leaf = leaf.FindOrAddSubTree(fragments[idx]); } leaf.CanUseWildcard = true; } } } // Find all the server changes, and anything that's opened for edit locally. We need to sync files we have open to schedule a resolve. SyncBatchBuilder batchBuilder = new SyncBatchBuilder(Context.PerforceSyncOptions?.MaxCommandsPerBatch, Context.PerforceSyncOptions?.MaxSizePerBatch); List syncDepotPaths = new List(); using (RecordCounter counter = new RecordCounter(Progress, "Filtering files...")) { using (TelemetryStopwatch filterStopwatch = new TelemetryStopwatch("Workspace_Sync_FilteringFiles", project.TelemetryProjectIdentifier)) { filterStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); // Track the total new bytes that will be required on disk when syncing. Add an extra 100MB for padding. long requiredFreeSpace = 100 * 1024 * 1024; string syncFilter = Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) ? $"{Context.ChangeNumber}" : $"={Context.ChangeNumber}"; if (Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) && Context.Options.HasFlag(WorkspaceUpdateOptions.FastSync)) { if (state.CurrentChangeNumber > 0 && Context.ChangeNumber >= state.CurrentChangeNumber) { syncFilter = $"{state.CurrentChangeNumber},{Context.ChangeNumber}"; logger.LogInformation("Using Fast Sync between current changelist {CurrentChangelist} and target changelist {TargetChangelist}", state.CurrentChangeNumber, Context.ChangeNumber); } else { if (state.CurrentChangeNumber <= 0) { logger.LogInformation("Fast Sync cannot be used with current changelist {CurrentChangelist} and target changelist {TargetChangelist} because there is no current change list to compare to.", state.CurrentChangeNumber, Context.ChangeNumber); } if (Context.ChangeNumber < state.CurrentChangeNumber) { logger.LogInformation("Fast Sync cannot be used with current changelist {CurrentChangelist} and target changelist {TargetChangelist} because workspace syncing backwards.", state.CurrentChangeNumber, Context.ChangeNumber); } } } foreach (string syncPath in syncPaths) { async Task<(WorkspaceUpdateResult, string)> inlineGetSyncRecords(bool firstTry) { List syncFiles = new List(); await foreach (PerforceResponse response in perforce.TrySyncAsync(SyncOptions.PreviewOnly, -1, 0, -1, -1, -1, -1, $"{syncPath}@{syncFilter}", cancellationToken)) { if (!response.Succeeded) { // if there is a failure retry without fast sync if (firstTry && Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) && Context.Options.HasFlag(WorkspaceUpdateOptions.FastSync)) { // change sync filter syncFilter = Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) ? $"{Context.ChangeNumber}" : $"={Context.ChangeNumber}"; Context.Options &= ~WorkspaceUpdateOptions.FastSync; syncTree.CanContainExcludedFiles = false; logger.LogWarning("Couldn't enumerate changes matching with fast sync enabled: {SyncPath}. Disabling fast sync and retrying.", syncPath); return await inlineGetSyncRecords(false); } return (WorkspaceUpdateResult.FailedToSync, $"Couldn't enumerate changes matching {syncPath}."); } if (response.Info != null) { logger.LogInformation("Note: {Note}", response.Info.Data); continue; } SyncRecord record = response.Data; // Get the full local path string relativePath; try { FileReference syncFile = new FileReference(record.Path.ToString()); relativePath = PerforceUtils.GetClientRelativePath(project.LocalRootPath, syncFile); } catch (PathTooLongException) { logger.LogInformation("The local path for {Path} exceeds the maximum allowed by Windows. Re-sync your workspace to a directory with a shorter name, or delete the file from the server.", record.Path); return (WorkspaceUpdateResult.FailedToSync, "File exceeds maximum path length allowed by Windows."); } // Create the sync record long syncSize = (record.Action == SyncAction.Deleted) ? 0 : record.FileSize; syncFiles.Add(new SyncFile(record.DepotFile.ToString(), relativePath, syncSize)); counter.Increment(); } // Also sync the currently open files await foreach (PerforceResponse response in perforce.TryOpenedAsync(OpenedOptions.None, FileSpecList.Any, cancellationToken)) { if (!response.Succeeded) { return (WorkspaceUpdateResult.FailedToSync, $"Couldn't enumerate changes matching {syncPath}."); } OpenedRecord record = response.Data; if (!String.IsNullOrEmpty(record.DepotFile) && !String.IsNullOrEmpty(record.ClientFile)) { if (record.Action != FileAction.Add && record.Action != FileAction.Branch && record.Action != FileAction.MoveAdd) { string relativePath = PerforceUtils.GetClientRelativePath(record.ClientFile); syncFiles.Add(new SyncFile(record.DepotFile, relativePath, 0)); } } } // Enumerate all the files to be synced. NOTE: depotPath is escaped, whereas clientPath is not. List syncRelativePaths = new(); foreach (SyncFile syncRecord in syncFiles) { if (filter.Matches(syncRecord.RelativePath)) { syncTree.IncludeFile(PerforceUtils.EscapePath(syncRecord.RelativePath), syncRecord.Size, logger); syncDepotPaths.Add(syncRecord.DepotFile); syncRelativePaths.Add(syncRecord.RelativePath); requiredFreeSpace += syncRecord.Size; } else { syncTree.ExcludeFile(PerforceUtils.EscapePath(syncRecord.RelativePath)); } } Parallel.ForEach(syncRelativePaths, syncRelativePath => { // If the file exists the required free space can be reduced as those bytes will be replaced. FileInfo localFileInfo = FileReference.Combine(project.LocalRootPath, syncRelativePath).ToFileInfo(); if (localFileInfo.Exists) { Interlocked.Add(ref requiredFreeSpace, -localFileInfo.Length); } }); // return success return (WorkspaceUpdateResult.Success, String.Empty); } (WorkspaceUpdateResult, string) ret = await inlineGetSyncRecords(true); if (ret.Item1 != WorkspaceUpdateResult.Success) { return ret; } } try { DirectoryInfo localRootInfo = project.LocalRootPath.ToDirectoryInfo(); DriveInfo drive = new DriveInfo(localRootInfo.FullName); if (drive.AvailableFreeSpace < requiredFreeSpace) { logger.LogInformation("Syncing requires {RequiredSpace} which exceeds the {AvailableSpace} available free space on {Drive}.", StringUtils.FormatBytesString(requiredFreeSpace), StringUtils.FormatBytesString(drive.AvailableFreeSpace), drive.Name); return (WorkspaceUpdateResult.FailedToSync, "Not enough available free space."); } } catch (SystemException) { logger.LogInformation("Unable to check available free space for {RootPath}.", project.LocalRootPath); } times.Add(new Tuple("Sync Filtering Files", filterStopwatch.Stop("Success"))); } } syncTree.GetOptimizedSyncCommands(project.ClientRootPath, Context.ChangeNumber, batchBuilder); // Clear the current sync changelist, in case we cancel if (!Context.Options.HasFlag(WorkspaceUpdateOptions.SyncSingleChange)) { state = stateMgr.Modify(x => { x.CurrentChangeNumber = -1; x.AdditionalChangeNumbers.Clear(); }); } // Find all the depot paths that will be synced HashSet remainingDepotPaths = new HashSet(StringComparer.OrdinalIgnoreCase); remainingDepotPaths.UnionWith(syncDepotPaths); using (TelemetryStopwatch transferStopwatch = new TelemetryStopwatch("Workspace_Sync_TransferFiles", project.TelemetryProjectIdentifier)) { transferStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName, IncludedFiles = syncTree.TotalIncludedFiles, ExcludedFiles = syncTree.TotalExcludedFiles, Size = syncTree.TotalSize, NumThreads = Context.PerforceSyncOptions?.NumThreads ?? PerforceSyncOptions.DefaultNumThreads }); (WorkspaceUpdateResult, string) syncResult = await SyncFileRevisions(perforce, "Syncing files...", Context, batchBuilder.Batches, remainingDepotPaths, Progress, logger, cancellationToken); if (syncResult.Item1 != WorkspaceUpdateResult.Success) { transferStopwatch.AddData(new { SyncResult = syncResult.Item2.ToString(), CompletedFilesFiles = syncDepotPaths.Count - remainingDepotPaths.Count }); return syncResult; } TimeSpan stop = transferStopwatch.Stop("Ok"); transferStopwatch.AddData(new { TransferRate = syncTree.TotalSize / Math.Max(transferStopwatch.Elapsed.TotalSeconds, 0.0001f) }); times.Add(new Tuple("Sync Files Transfer", stop)); } int versionChangeNumber = -1; if (Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) && !Context.Options.HasFlag(WorkspaceUpdateOptions.UpdateFilter)) { // Read the new config file Context.ProjectConfigFile = await ReadProjectConfigFile(project.LocalRootPath, project.LocalFileName, logger); Context.ProjectStreamFilter = await ReadProjectStreamFilter(perforce, Context.ProjectConfigFile, cancellationToken); // Get the branch name string? branchOrStreamName = await perforce.GetCurrentStreamAsync(cancellationToken); if (branchOrStreamName != null) { // If it's a virtual stream, take the concrete parent stream instead HashSet versionStreams = new HashSet(Context.ProjectConfigFile.GetValues("Perforce.VersionStreams", Array.Empty()), StringComparer.OrdinalIgnoreCase); while (!versionStreams.Contains(branchOrStreamName)) { StreamRecord streamSpec = await perforce.GetStreamAsync(branchOrStreamName, false, cancellationToken); if (streamSpec.Type != "virtual" || streamSpec.Parent == "none" || streamSpec.Parent == null) { break; } branchOrStreamName = streamSpec.Parent; } } else { // Otherwise use the depot path for GenerateProjectFiles.bat in the root of the workspace List files = await perforce.WhereAsync(project.ClientRootPath + "/GenerateProjectFiles.bat", cancellationToken).Where(x => !x.Unmap).ToListAsync(cancellationToken); if (files.Count != 1) { return (WorkspaceUpdateResult.FailedToSync, $"Couldn't determine branch name for {project.ClientFileName}."); } branchOrStreamName = PerforceUtils.GetClientOrDepotDirectoryName(files[0].DepotFile); } logger.LogInformation(""); // Get the last code change int codeChangeNumber = Context.CodeChangeNumber ?? 0; if (codeChangeNumber == 0) { using (TelemetryStopwatch findingLastCodeChangeStopwatch = new TelemetryStopwatch("Workspace_Sync_FindingLastCodeChange", project.TelemetryProjectIdentifier)) { findingLastCodeChangeStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); logger.LogInformation("Finding last code change for CL {Number}...", Context.ChangeNumber); // If we are syncing to a newer change than the last code change we found (and it is not the first sync in a workspace) // go head and use the last code change we found as the minimum change in our query int? minChangeNumber = null; if ((Context.ChangeNumber >= state.CurrentCodeChangeNumber) && (state.CurrentCodeChangeNumber > 0)) { minChangeNumber = state.CurrentCodeChangeNumber; } string[] codeRules = Utility.GetCodeFilter(Context.ProjectConfigFile); try { await foreach (PerforceChangeDetails details in Utility.EnumerateChangeDetails(perforce, minChangeNumber, maxChangeNumber: Context.ChangeNumber, syncPaths, codeRules, cancellationToken)) { if (details.ContainsCode) { codeChangeNumber = details.Number; break; } } } catch (EpicGames.Perforce.PerforceException) { logger.LogInformation("Falling back to the slow way of finding last code change for CL {Number}...", Context.ChangeNumber); await foreach (PerforceChangeDetails details in Utility.EnumerateChangeDetails(perforce, null, maxChangeNumber: Context.ChangeNumber, syncPaths, codeRules, cancellationToken)) { if (details.ContainsCode) { codeChangeNumber = details.Number; break; } } } if (codeChangeNumber == 0) { return (WorkspaceUpdateResult.FailedToSync, $"Could not find any code changes before CL {Context.ChangeNumber}."); } logger.LogInformation("Using code CL {Number}", codeChangeNumber); times.Add(new Tuple("Sync Finding Last Code Change", findingLastCodeChangeStopwatch.Stop("Success"))); } } // Set the version change if (Context.ProjectConfigFile.GetValue("Options.VersionToLastCodeChange", true)) { versionChangeNumber = codeChangeNumber; } else { versionChangeNumber = Context.ChangeNumber; } // Update the version files if (Context.ProjectConfigFile.GetValue("Options.UseFastModularVersioningV2", false)) { bool isLicenseeVersion = await IsLicenseeVersion(perforce, project, cancellationToken); if (!await UpdateVersionFile(perforce, project.ClientRootPath + BuildVersionFileName, Context.ChangeNumber, text => UpdateBuildVersion(text, Context.ChangeNumber, versionChangeNumber, branchOrStreamName, isLicenseeVersion), logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {BuildVersionFileName}."); } } else if (Context.ProjectConfigFile.GetValue("Options.UseFastModularVersioning", false)) { bool isLicenseeVersion = await IsLicenseeVersion(perforce, project, cancellationToken); if (!await UpdateVersionFile(perforce, project.ClientRootPath + BuildVersionFileName, Context.ChangeNumber, text => UpdateBuildVersion(text, Context.ChangeNumber, versionChangeNumber, branchOrStreamName, isLicenseeVersion), logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {BuildVersionFileName}"); } Dictionary versionHeaderStrings = new Dictionary(); versionHeaderStrings["#define ENGINE_IS_PROMOTED_BUILD"] = " (0)"; versionHeaderStrings["#define BUILT_FROM_CHANGELIST"] = " 0"; versionHeaderStrings["#define BRANCH_NAME"] = " \"" + branchOrStreamName.Replace('/', '+') + "\""; if (!await UpdateVersionFile(perforce, project.ClientRootPath + VersionHeaderFileName, versionHeaderStrings, Context.ChangeNumber, logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {VersionHeaderFileName}."); } if (!await UpdateVersionFile(perforce, project.ClientRootPath + ObjectVersionFileName, new Dictionary(), Context.ChangeNumber, logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {ObjectVersionFileName}."); } } else { if (!await UpdateVersionFile(perforce, project.ClientRootPath + BuildVersionFileName, new Dictionary(), Context.ChangeNumber, logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {BuildVersionFileName}"); } Dictionary versionStrings = new Dictionary(); versionStrings["#define ENGINE_VERSION"] = " " + versionChangeNumber.ToString(); versionStrings["#define ENGINE_IS_PROMOTED_BUILD"] = " (0)"; versionStrings["#define BUILT_FROM_CHANGELIST"] = " " + versionChangeNumber.ToString(); versionStrings["#define BRANCH_NAME"] = " \"" + branchOrStreamName.Replace('/', '+') + "\""; if (!await UpdateVersionFile(perforce, project.ClientRootPath + VersionHeaderFileName, versionStrings, Context.ChangeNumber, logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {VersionHeaderFileName}"); } if (!await UpdateVersionFile(perforce, project.ClientRootPath + ObjectVersionFileName, versionStrings, Context.ChangeNumber, logger, cancellationToken)) { return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {ObjectVersionFileName}"); } } } // Check if there are any files which need resolving List unresolvedFiles = await FindUnresolvedFiles(perforce, syncPaths, cancellationToken); if (unresolvedFiles.Count > 0 && Context.Options.HasFlag(WorkspaceUpdateOptions.AutoResolveChanges)) { foreach (FStatRecord unresolvedFile in unresolvedFiles) { if (unresolvedFile.DepotFile != null) { await perforce.ResolveAsync(-1, ResolveOptions.Automatic, unresolvedFile.DepotFile, cancellationToken); } } unresolvedFiles = await FindUnresolvedFiles(perforce, syncPaths, cancellationToken); } if (unresolvedFiles.Count > 0) { logger.LogInformation("{NumFiles} files need resolving:", unresolvedFiles.Count); foreach (FStatRecord unresolvedFile in unresolvedFiles) { logger.LogInformation(" {ClientFile}", unresolvedFile.ClientFile); } return (WorkspaceUpdateResult.FilesToResolve, "Files need resolving."); } // Continue processing sync-only actions if (Context.Options.HasFlag(WorkspaceUpdateOptions.Sync) && !Context.Options.HasFlag(WorkspaceUpdateOptions.UpdateFilter)) { Context.ProjectConfigFile ??= await ReadProjectConfigFile(project.LocalRootPath, project.LocalFileName, logger); // Execute any project specific post-sync steps string[]? postSyncSteps = Context.ProjectConfigFile.GetValues("Sync.Step", null); if (postSyncSteps != null) { using (TelemetryStopwatch postSyncStopwatch = new TelemetryStopwatch("Workspace_Sync_PostSyncSteps", project.TelemetryProjectIdentifier)) { postSyncStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); logger.LogInformation(""); logger.LogInformation("Executing post-sync steps..."); Dictionary postSyncVariables = ConfigUtils.GetWorkspaceVariables(project, Context.ChangeNumber, versionChangeNumber, null, Context.ProjectConfigFile, perforceSettings); foreach (string postSyncStep in postSyncSteps.Select(x => x.Trim())) { ConfigObject postSyncStepObject = new ConfigObject(postSyncStep); string toolFileName = Utility.ExpandVariables(postSyncStepObject.GetValue("FileName", ""), postSyncVariables); if (toolFileName != null) { string toolArguments = Utility.ExpandVariables(postSyncStepObject.GetValue("Arguments", ""), postSyncVariables); logger.LogInformation("post-sync> Running {FileName} {Arguments}", toolFileName, toolArguments); if (!File.Exists(toolFileName)) { return (WorkspaceUpdateResult.FailedToSync, $"Unable to find {toolFileName}. You may have an incomplete sync; try cleaning your workspace."); } int resultFromTool = await Utility.ExecuteProcessAsync(toolFileName, null, toolArguments, line => ProcessOutput(line, "post-sync> ", Progress, logger), cancellationToken); if (resultFromTool != 0) { return (WorkspaceUpdateResult.FailedToSync, $"Post-sync step terminated with exit code {resultFromTool}."); } } } times.Add(new Tuple("Sync Post Sync Steps", postSyncStopwatch.Stop("Success"))); } } } // Update the current state state = stateMgr.Modify(x => { if (Context.Options.HasFlag(WorkspaceUpdateOptions.SyncSingleChange)) { x.AdditionalChangeNumbers.Add(Context.ChangeNumber); } else { x.CurrentChangeNumber = Context.ChangeNumber; x.CurrentCodeChangeNumber = versionChangeNumber; } }); // Update the timing info times.Add(new Tuple("Sync Total", syncTelemetryStopwatch.Stop("Success"))); // Save the number of files synced numFilesSynced = syncDepotPaths.Count; logger.LogInformation(""); } } // Extract an archive from the depot path if (Context.Options.HasFlag(WorkspaceUpdateOptions.SyncArchives)) { using (TelemetryStopwatch stopwatch = new TelemetryStopwatch("Workspace_SyncArchives", project.TelemetryProjectIdentifier)) { stopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); // Create the directory for extracted archive manifests DirectoryReference manifestDirectoryName; if (project.LocalFileName.HasExtension(".uproject")) { manifestDirectoryName = DirectoryReference.Combine(project.LocalFileName.Directory, "Saved", "UnrealGameSync"); } else { manifestDirectoryName = DirectoryReference.Combine(project.LocalFileName.Directory, "Engine", "Saved", "UnrealGameSync"); } DirectoryReference.CreateDirectory(manifestDirectoryName); // Sync and extract (or just remove) the given archives foreach ((string archiveType, IArchive? archive) in Context.ArchiveTypeToArchive) { // the archive type does not require cleaning we skip this bool removeOldBinaries = true; if (archive != null && archive.RemoveOldBinaries.HasValue) { removeOldBinaries = archive.RemoveOldBinaries.HasValue; } // Remove any existing binaries FileReference manifestFileName = FileReference.Combine(manifestDirectoryName, String.Format("{0}.zipmanifest", archiveType)); if (removeOldBinaries && FileReference.Exists(manifestFileName)) { bool isNotLastSyncedEditorArchive = archiveType != IArchiveChannel.EditorArchiveType || (archive != null && archive.Key != state.LastSyncEditorArchive); if (isNotLastSyncedEditorArchive) { logger.LogInformation("Removing {ArchiveType} binaries...", archiveType); Progress.Set(String.Format("Removing {0} binaries...", archiveType), 0.0f); ArchiveUtils.RemoveExtractedFiles(project.LocalRootPath, manifestFileName, Progress, logger); FileReference.Delete(manifestFileName); logger.LogInformation(""); } } // If we have a new depot path, sync it down and extract it if (archive != null) { string archiveKey = archive.Key; if (archiveType != IArchiveChannel.EditorArchiveType || archiveKey != state.LastSyncEditorArchive) { logger.LogInformation("Syncing {ArchiveType} binaries...", archiveType); Progress.Set(String.Format("Syncing {0} binaries...", archiveType), 0.0f); if (!await archive.DownloadAsync(perforce, project.LocalRootPath, manifestFileName, logger, Progress, CancellationToken.None)) { return (WorkspaceUpdateResult.FailedToSync, $"Couldn't read {archiveKey}"); } // Update last synced editor archive if (archiveType == IArchiveChannel.EditorArchiveType) { state = stateMgr.Modify(x => { x.LastSyncEditorArchive = archiveKey; }); } } else { logger.LogInformation("Skipping {ArchiveType} binaries download, already downloaded", IArchiveChannel.EditorArchiveType); } } } // Update the state state = stateMgr.Modify(x => { x.ExpandedArchiveTypes.Clear(); x.ExpandedArchiveTypes.UnionWith(Context.ArchiveTypeToArchive.Where(x => x.Value != null).Select(x => x.Key)); }); // Add the finish time times.Add(new Tuple("Archive", stopwatch.Stop("Success"))); } } if (Context.Options.HasFlag(WorkspaceUpdateOptions.ImportSnapshots)) { if (project.ProjectDir == null) { logger.LogWarning("Unable to determine project directory to use for snapshot import - snapshot import will be skipped"); } else if (project.StreamName == null) { logger.LogWarning("Unable to determine stream to use for snapshot import - snapshot import will be skipped"); } else { using (TelemetryStopwatch stopwatch = new TelemetryStopwatch("Workspace_ImportSnapshots", project.TelemetryProjectIdentifier)) { stopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); List> snapshotTasks = new List>(); Action runApp = (string app, string cmdLine) => { logger.LogInformation("zen> Running Zen command {App} {CommandLine}", app, cmdLine); snapshotTasks.Add(ExecuteShellCommandAsync(app + " " + cmdLine, line => ProcessOutput(line, "zen> ", Progress, logger), cancellationToken)); }; try { BuildIndex buildIndex = await UnrealCloudDDCBuildIndex.CreateBuildIndexAsync(Context.SnapshotHost, Context.SnapshotNamespace, (string app, string cmdLine) => runApp(app, cmdLine), true); DirectoryReference cookedPlatformDir = DirectoryReference.Combine(project.ProjectDir, "Saved", "Cooked", "{Platform}"); foreach (SnapshotSettings snapshot in Context.Snapshots) { Build build = buildIndex.GetBuild(project.StreamName, project.LocalFileName.GetFileNameWithoutAnyExtensions(), snapshot.Platform, snapshot.Runtime, Context.ChangeNumber, true); if (build.GetChangelist() != Context.ChangeNumber) { logger.LogWarning("No exact match found for target changelist {}. Closest changelist found was {}. You may require an incremental cook for this snapshot to function properly.", Context.ChangeNumber, build.GetChangelist()); } build.Import(project.LocalFileName, project.LocalRootPath, project.EngineDir, cookedPlatformDir, false, false); } await Task.WhenAll(snapshotTasks); times.Add(new Tuple("ImportSnapshots", stopwatch.Stop("Success"))); } catch (BuildIndexException e) { logger.LogWarning("Failed to import snapshots: {}", e.Message); } } } } // Take the lock before doing anything else. Building and generating project files can only be done on one workspace at a time. using SemaphoreScope scope = new SemaphoreScope(_updateSemaphore); if (Context.Options.HasFlag(WorkspaceUpdateOptions.GenerateProjectFiles) || Context.Options.HasFlag(WorkspaceUpdateOptions.Build)) { if (!scope.TryAcquire()) { logger.LogInformation("Waiting for other workspaces to finish..."); await scope.AcquireAsync(cancellationToken); } } // Generate project files in the workspace if (Context.Options.HasFlag(WorkspaceUpdateOptions.GenerateProjectFiles)) { using (TelemetryStopwatch stopwatch = new TelemetryStopwatch("Workspace_GenerateProjectFiles", project.TelemetryProjectIdentifier)) { Progress.Set("Generating project files...", 0.0f); stopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); StringBuilder commandLine = new StringBuilder(); commandLine.AppendFormat("\"{0}\"", FileReference.Combine(project.LocalRootPath, $"GenerateProjectFiles.{ShellScriptExt}")); if (!Context.Options.HasFlag(WorkspaceUpdateOptions.IncludeAllProjectsInSolution)) { if (project.LocalFileName.HasExtension(".uproject")) { commandLine.AppendFormat(" -Project=\"{0}\"", project.LocalFileName); // Uproject specific solutions are only valid if a Source folder exists if (Context.Options.HasFlag(WorkspaceUpdateOptions.UprojectSpecificSolution) && DirectoryReference.Exists(DirectoryReference.Combine(project.LocalFileName.Directory, "Source"))) { commandLine.Append(" -Game"); } } } commandLine.Append(" -progress"); logger.LogInformation("Generating project files..."); logger.LogInformation("gpf> Running {Arguments}", commandLine); int generateProjectFilesResult = await ExecuteShellCommandAsync(commandLine.ToString(), line => ProcessOutput(line, "gpf> ", Progress, logger), cancellationToken); if (generateProjectFilesResult != 0) { return (WorkspaceUpdateResult.FailedToCompile, $"Failed to generate project files (exit code {generateProjectFilesResult})."); } logger.LogInformation(""); times.Add(new Tuple("Prj gen", stopwatch.Stop("Success"))); } } // Build everything using MegaXGE if (Context.Options.HasFlag(WorkspaceUpdateOptions.Build)) { Context.ProjectConfigFile ??= await ReadProjectConfigFile(project.LocalRootPath, project.LocalFileName, logger); // Figure out the new editor target TargetReceipt defaultEditorReceipt = ConfigUtils.CreateDefaultEditorReceipt(project, Context.ProjectConfigFile, Context.EditorConfig); FileReference editorTargetFile = ConfigUtils.GetEditorTargetFile(project, Context.ProjectConfigFile); string editorTargetName = editorTargetFile.GetFileNameWithoutAnyExtensions(); FileReference editorReceiptFile = ConfigUtils.GetReceiptFile(project, Context.ProjectConfigFile, editorTargetFile, Context.EditorConfig.ToString()); // Get the build steps bool usingPrecompiledEditor = Context.ArchiveTypeToArchive.TryGetValue(IArchiveChannel.EditorArchiveType, out IArchive? archive) && archive != null; Dictionary buildStepObjects = ConfigUtils.GetDefaultBuildStepObjects(project, editorTargetName, Context.EditorConfig, Context.ProjectConfigFile, usingPrecompiledEditor); BuildStep.MergeBuildStepObjects(buildStepObjects, Context.ProjectConfigFile.GetValues("Build.Step", Array.Empty()).Select(x => new ConfigObject(x))); BuildStep.MergeBuildStepObjects(buildStepObjects, Context.UserBuildStepObjects); // Construct build steps from them List buildSteps = buildStepObjects.Values.Select(x => new BuildStep(x)).OrderBy(x => (x.OrderIndex == -1) ? 10000 : x.OrderIndex).ToList(); if (Context.CustomBuildSteps != null && Context.CustomBuildSteps.Count > 0) { buildSteps.RemoveAll(x => !Context.CustomBuildSteps.Contains(x.UniqueId)); } else if (Context.Options.HasFlag(WorkspaceUpdateOptions.ScheduledBuild)) { buildSteps.RemoveAll(x => !x.ScheduledSync); } else { buildSteps.RemoveAll(x => !x.NormalSync); } // Check if the last successful build was before a change that we need to force a clean for bool forceClean = false; if (state.LastBuiltChangeNumber != 0) { foreach (string cleanBuildChange in Context.ProjectConfigFile.GetValues("ForceClean.Changelist", Array.Empty())) { int changeNumber; if (Int32.TryParse(cleanBuildChange, out changeNumber)) { if ((state.LastBuiltChangeNumber >= changeNumber) != (state.CurrentChangeNumber >= changeNumber)) { logger.LogInformation("Forcing clean build due to changelist {Change}.", changeNumber); logger.LogInformation(""); forceClean = true; break; } } } } // Execute them all using (TelemetryStopwatch stopwatch = new TelemetryStopwatch("Workspace_Build", project.TelemetryProjectIdentifier)) { Progress.Set("Starting build...", 0.0f); stopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); // Execute all the steps float maxProgressFraction = 0.0f; foreach (BuildStep step in buildSteps) { maxProgressFraction += (float)step.EstimatedDuration / (float)Math.Max(buildSteps.Sum(x => x.EstimatedDuration), 1); Progress.Set(step.StatusText ?? "Executing build step"); Progress.Push(maxProgressFraction); logger.LogInformation("{Status}", step.StatusText); DirectoryReference batchFilesDir = DirectoryReference.Combine(project.LocalRootPath, "Engine", "Build", "BatchFiles"); if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { batchFilesDir = DirectoryReference.Combine(batchFilesDir, "Mac"); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { batchFilesDir = DirectoryReference.Combine(batchFilesDir, "Linux"); } if (step.IsValid()) { // Get the build variables for this step TargetReceipt? editorReceipt; if (!ConfigUtils.TryReadEditorReceipt(project, editorReceiptFile, out editorReceipt)) { editorReceipt = defaultEditorReceipt; } Dictionary variables = ConfigUtils.GetWorkspaceVariables(project, state.CurrentChangeNumber, state.CurrentCodeChangeNumber, editorReceipt, Context.ProjectConfigFile, perforceSettings, Context.AdditionalVariables); // Handle all the different types of steps switch (step.Type) { case BuildStepType.Compile: using (TelemetryStopwatch stepStopwatch = new TelemetryStopwatch("Workspace_Execute_Compile", project.TelemetryProjectIdentifier)) { stepStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); stepStopwatch.AddData(new { Target = step.Target }); FileReference buildBat = FileReference.Combine(batchFilesDir, $"Build.{ShellScriptExt}"); string commandLine = $"\"{buildBat}\" {step.Target} {step.Platform} {step.Configuration} {Utility.ExpandVariables(step.Arguments ?? "", variables)} -NoHotReloadFromIDE"; if (Context.Options.HasFlag(WorkspaceUpdateOptions.Clean) || forceClean) { logger.LogInformation("ubt> Running {Arguments}", commandLine + " -clean"); await ExecuteShellCommandAsync(commandLine + " -clean", line => ProcessOutput(line, "ubt> ", Progress, logger), cancellationToken); } logger.LogInformation("ubt> Running {FileName} {Arguments}", buildBat, commandLine + " -progress"); int resultFromBuild = await ExecuteShellCommandAsync(commandLine + " -progress", line => ProcessOutput(line, "ubt> ", Progress, logger), cancellationToken); if (resultFromBuild != 0) { stepStopwatch.Stop("Failed"); WorkspaceUpdateResult result; if (await HasModifiedSourceFiles(perforce, project, cancellationToken) || Context.UserBuildStepObjects.Count > 0) { result = WorkspaceUpdateResult.FailedToCompile; } else { result = WorkspaceUpdateResult.FailedToCompileWithCleanWorkspace; } return (result, $"Failed to compile {step.Target}"); } stepStopwatch.Stop("Success"); } break; case BuildStepType.Cook: using (TelemetryStopwatch stepStopwatch = new TelemetryStopwatch("Workspace_Execute_Cook", project.TelemetryProjectIdentifier)) { stepStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); stepStopwatch.AddData(new { Project = Path.GetFileNameWithoutExtension(step.FileName) }); FileReference localRunUat = FileReference.Combine(batchFilesDir, $"RunUAT.{ShellScriptExt}"); string arguments = String.Format("\"{0}\" -profile=\"{1}\"", localRunUat, FileReference.Combine(project.LocalRootPath, step.FileName ?? "unknown")); logger.LogInformation("uat> Running {FileName} {Argument}", localRunUat, arguments); int resultFromUat = await ExecuteShellCommandAsync(arguments, line => ProcessOutput(line, "uat> ", Progress, logger), cancellationToken); if (resultFromUat != 0) { stepStopwatch.Stop("Failed"); return (WorkspaceUpdateResult.FailedToCompile, $"Cook failed. ({resultFromUat})"); } stepStopwatch.Stop("Success"); } break; case BuildStepType.Other: using (TelemetryStopwatch stepStopwatch = new TelemetryStopwatch("Workspace_Execute_Custom", project.TelemetryProjectIdentifier)) { stepStopwatch.AddData(new { MachineName = System.Net.Dns.GetHostName(), DomainName = Environment.UserDomainName, ServerAndPort = perforce.Settings.ServerAndPort, ClientName = perforce.Settings.ClientName, UserName = perforce.Settings.UserName }); stepStopwatch.AddData(new { FileName = Path.GetFileNameWithoutExtension(step.FileName) }); FileReference toolFileName = FileReference.Combine(project.LocalRootPath, Utility.ExpandVariables(step.FileName ?? "unknown", variables)); string toolWorkingDir = String.IsNullOrWhiteSpace(step.WorkingDir) ? toolFileName.Directory.FullName : Utility.ExpandVariables(step.WorkingDir, variables); string toolArguments = Utility.ExpandVariables(step.Arguments ?? "", variables); logger.LogInformation("tool> Running {Tool} {Arguments}", toolFileName, toolArguments); if (!FileReference.Exists(toolFileName)) { logger.LogInformation("Unable to find {FileName}. You may have an incomplete sync; try cleaning your workspace.", toolFileName); stepStopwatch.Stop("Failed"); return (WorkspaceUpdateResult.FailedToCompile, $"Unable to run {toolFileName}"); } if (step.UseLogWindow) { int resultFromTool = await Utility.ExecuteProcessAsync(toolFileName.FullName, toolWorkingDir, toolArguments, line => ProcessOutput(line, "tool> ", Progress, logger), cancellationToken); if (resultFromTool != 0) { stepStopwatch.Stop("Failed"); return (WorkspaceUpdateResult.FailedToCompile, $"Tool terminated with exit code {resultFromTool}."); } } else { ProcessStartInfo startInfo = new ProcessStartInfo(toolFileName.FullName, toolArguments); startInfo.WorkingDirectory = toolWorkingDir; using (Process.Start(startInfo)) { } } stepStopwatch.Stop("Success"); } break; } } logger.LogInformation(""); Progress.Pop(); } times.Add(new Tuple("Build", stopwatch.Stop("Success"))); } // Update the last successful build change number if (Context.CustomBuildSteps == null || Context.CustomBuildSteps.Count == 0) { state = stateMgr.Modify(x => x.LastBuiltChangeNumber = state.CurrentChangeNumber); } } // Write out all the timing information logger.LogInformation("Total time: {TotalTime}", FormatTime(times.Sum(x => (long)(x.Item2.TotalMilliseconds / 1000)))); foreach (Tuple time in times) { logger.LogInformation(" {TimeSeconds-16} {Name}", FormatTime((long)(time.Item2.TotalMilliseconds / 1000)), time.Item1); } if (numFilesSynced > 0) { logger.LogInformation("{NumFiles} files synced.", numFilesSynced); } DateTime finishTime = DateTime.Now; logger.LogInformation(""); logger.LogInformation("UPDATE SUCCEEDED ({FinishDate} {FinishTime})", finishTime.ToShortDateString(), finishTime.ToShortTimeString()); return (WorkspaceUpdateResult.Success, "Update succeeded"); } static void ProcessOutput(string line, string prefix, ProgressValue progress, ILogger logger) { string? parsedLine = ProgressTextWriter.ParseLine(line, progress); if (parsedLine != null) { logger.LogInformation("{Prefix}{Message}", prefix, parsedLine); } } static async Task IsLicenseeVersion(IPerforceConnection perforce, ProjectInfo project, CancellationToken cancellationToken) { string[] files = { project.ClientRootPath + "/Engine/Build/NotForLicensees/EpicInternal.txt", project.ClientRootPath + "/Engine/Restricted/NotForLicensees/Build/EpicInternal.txt" }; List records = await perforce.FStatAsync(files, cancellationToken).ToListAsync(cancellationToken); return !records.Any(x => x.IsMapped); } public static List GetSyncPaths(ProjectInfo project, bool syncAllProjects, string[] syncFilter) { List syncPaths = GetRelativeSyncPaths(project, syncAllProjects, syncFilter); return syncPaths.Select(x => project.ClientRootPath + x).ToList(); } public static List GetRelativeSyncPaths(ProjectInfo project, bool syncAllProjects, IReadOnlyList? syncFilter) { List syncPaths = new List(); // Check the client path is formatted correctly if (!project.ClientFileName.StartsWith(project.ClientRootPath + "/", StringComparison.OrdinalIgnoreCase)) { throw new Exception(String.Format("Expected '{0}' to start with '{1}'", project.ClientFileName, project.ClientRootPath)); } // Add the default project paths int lastSlashIdx = project.ClientFileName.LastIndexOf('/'); if (syncAllProjects || (!project.ClientFileName.EndsWith(".uproject", StringComparison.OrdinalIgnoreCase) && !project.ClientFileName.EndsWith(".uefnproject", StringComparison.OrdinalIgnoreCase)) || lastSlashIdx <= project.ClientRootPath.Length) { syncPaths.Add("/..."); } else if (project.ClientFileName.EndsWith(".uefnproject", StringComparison.OrdinalIgnoreCase)) { syncPaths.Add("/*"); syncPaths.Add("/Engine/..."); syncPaths.Add(project.ClientFileName.Substring(project.ClientRootPath.Length, lastSlashIdx - project.ClientRootPath.Length) + "/..."); } else { syncPaths.Add("/*"); syncPaths.Add("/Engine/..."); if (project.IsEnterpriseProject) { syncPaths.Add("/Enterprise/..."); } syncPaths.Add(project.ClientFileName.Substring(project.ClientRootPath.Length, lastSlashIdx - project.ClientRootPath.Length) + "/..."); } // Apply the sync filter to that list. We only want inclusive rules in the output list, but we can manually apply exclusions to previous entries. if (syncFilter != null) { foreach (string syncPath in syncFilter) { string trimSyncPath = syncPath.Trim(); if (trimSyncPath.StartsWith("/", StringComparison.Ordinal)) { syncPaths.Add(trimSyncPath); } else if (trimSyncPath.StartsWith("-/", StringComparison.Ordinal) && trimSyncPath.EndsWith("...", StringComparison.Ordinal)) { syncPaths.RemoveAll(x => x.StartsWith(trimSyncPath.Substring(1, trimSyncPath.Length - 4), StringComparison.Ordinal)); } } } // Sort the remaining paths by length, and remove any paths which are included twice syncPaths = syncPaths.OrderBy(x => x.Length).ToList(); for (int idx = 0; idx < syncPaths.Count; idx++) { string syncPath = syncPaths[idx]; if (syncPath.EndsWith("...", StringComparison.Ordinal)) { string syncPathPrefix = syncPath.Substring(0, syncPath.Length - 3); for (int otherIdx = syncPaths.Count - 1; otherIdx > idx; otherIdx--) { if (syncPaths[otherIdx].StartsWith(syncPathPrefix, StringComparison.Ordinal)) { syncPaths.RemoveAt(otherIdx); } } } } return syncPaths; } public static bool MatchFilter(ProjectInfo project, FileReference fileName, FileFilter filter) { bool match = true; if (fileName.IsUnderDirectory(project.LocalRootPath)) { string relativePath = fileName.MakeRelativeTo(project.LocalRootPath); if (!filter.Matches(relativePath)) { match = false; } } return match; } class SyncState { public int TotalDepotPaths; public HashSet RemainingDepotPaths; public Queue> SyncCommandLists; public string StatusMessage; public WorkspaceUpdateResult Result = WorkspaceUpdateResult.Success; public SyncState(HashSet remainingDepotPaths, Queue> syncCommandLists) { TotalDepotPaths = remainingDepotPaths.Count; RemainingDepotPaths = remainingDepotPaths; SyncCommandLists = syncCommandLists; StatusMessage = "Succeeded."; } } static Task<(WorkspaceUpdateResult, string)> SyncFileRevisions(IPerforceConnection perforce, string prefix, WorkspaceUpdateContext context, List syncCommands, HashSet remainingDepotPaths, ProgressValue progress, ILogger logger, CancellationToken cancellationToken) { Queue> syncCommandLists = new Queue>(); foreach (IReadOnlyList batch in syncCommands.Batch(2000)) { syncCommandLists.Enqueue(batch.ToList()); } return SyncFileRevisions(perforce, prefix, context, syncCommandLists, remainingDepotPaths, progress, logger, cancellationToken); } static async Task<(WorkspaceUpdateResult, string)> SyncFileRevisions(IPerforceConnection perforce, string prefix, WorkspaceUpdateContext context, Queue> syncCommandLists, HashSet remainingDepotPaths, ProgressValue progress, ILogger logger, CancellationToken cancellationToken) { // Figure out the number of additional background threads we want to run with. We can run worker on the current thread. int numThreads = context.PerforceSyncOptions?.NumThreads ?? PerforceSyncOptions.DefaultNumThreads; int numExtraThreads = Math.Max(Math.Min(syncCommandLists.Count, numThreads) - 1, 0); List childConnections = new List(); List childTasks = new List(numExtraThreads); try { // Create the state object shared by all the worker threads SyncState state = new SyncState(remainingDepotPaths, syncCommandLists); // Wrapper writer around the log class to prevent multiple threads writing to it at once ILogger logWrapper = logger; // Initialize Sync Progress UpdateSyncState(prefix, state, progress); // Delegate for updating the sync state after a file has been synced Action syncOutput = (record, localLog) => { UpdateSyncState(prefix, record, state, progress, localLog); }; // Create all the child threads for (int threadIdx = 0; threadIdx < numExtraThreads; threadIdx++) { int threadNumber = threadIdx + 2; // Create connection IPerforceConnection childConnection = await PerforceConnection.CreateAsync(perforce.Settings, perforce.Logger); childConnections.Add(childConnection); Task childTask = Task.Run(() => StaticSyncWorker(threadNumber, childConnection, context, state, syncOutput, logWrapper, cancellationToken), cancellationToken); childTasks.Add(childTask); } // Run one worker on the current thread await StaticSyncWorker(1, perforce, context, state, syncOutput, logWrapper, cancellationToken); // Allow the tasks to throw foreach (Task childTask in childTasks) { await childTask; } // Return the result that was set on the state object return (state.Result, state.StatusMessage); } finally { foreach (Task childTask in childTasks) { await childTask.ContinueWith(_ => { }, TaskScheduler.Default); // Swallow exceptions until we've disposed the connections } foreach (IPerforceConnection childConnection in childConnections) { childConnection.Dispose(); } } } static void UpdateSyncState(string prefix, SyncState state, ProgressValue progress) { lock (state) { string message = String.Format("{0} ({1:n0}/{2:n0})", prefix, state.TotalDepotPaths - state.RemainingDepotPaths.Count, state.TotalDepotPaths); float fraction = Math.Min((float)(state.TotalDepotPaths - state.RemainingDepotPaths.Count) / (float)state.TotalDepotPaths, 1.0f); progress.Set(message, fraction); } } static void UpdateSyncState(string prefix, SyncRecord record, SyncState state, ProgressValue progress, ILogger logger) { lock (state) { state.RemainingDepotPaths.Remove(record.DepotFile.ToString()); string message = String.Format("{0} ({1:n0}/{2:n0})", prefix, state.TotalDepotPaths - state.RemainingDepotPaths.Count, state.TotalDepotPaths); float fraction = Math.Min((float)(state.TotalDepotPaths - state.RemainingDepotPaths.Count) / (float)state.TotalDepotPaths, 1.0f); progress.Set(message, fraction); logger.LogInformation("p4> {Action} {Path}", record.Action, record.Path); } } static async Task StaticSyncWorker(int threadNumber, IPerforceConnection perforce, WorkspaceUpdateContext context, SyncState state, Action syncOutput, ILogger globalLog, CancellationToken cancellationToken) { PrefixedTextWriter threadLog = new PrefixedTextWriter(String.Format("{0}:", threadNumber), globalLog); for (; ; ) { // Remove the next batch that needs to be synced List syncCommands; lock (state) { if (state.Result == WorkspaceUpdateResult.Success && state.SyncCommandLists.Count > 0) { syncCommands = state.SyncCommandLists.Dequeue(); } else { break; } } WorkspaceUpdateResult result = WorkspaceUpdateResult.FailedToSync; string statusMessage = ""; int maxRetries = context.PerforceSyncOptions?.NumSyncErrorRetries ?? PerforceSyncOptions.DefaultNumSyncErrorRetries; int retryDelay = context.PerforceSyncOptions?.SyncErrorRetryDelay ?? PerforceSyncOptions.DefaultSyncErrorRetryDelay; for (int attempt = 0; ; attempt++) { // Sync the files string? errorMessage; (result, statusMessage, errorMessage) = await StaticSyncFileRevisions(perforce, context, syncCommands, record => syncOutput(record, threadLog), cancellationToken); if (result != WorkspaceUpdateResult.FailedToSync || attempt >= maxRetries) { break; } threadLog.LogWarning("Sync error ({Message}); waiting ({RetryDelay}ms) and retrying... ({Count}/{MaxCount})", errorMessage ?? "unknown", retryDelay, attempt + 1, maxRetries); if (retryDelay > 0) { await Task.Delay(retryDelay, cancellationToken); } } // If it failed, try to set it on the state if nothing else has failed first if (result != WorkspaceUpdateResult.Success) { lock (state) { if (state.Result == WorkspaceUpdateResult.Success) { state.Result = result; state.StatusMessage = statusMessage; } } break; } } } static async Task<(WorkspaceUpdateResult, string, string?)> StaticSyncFileRevisions(IPerforceConnection perforce, WorkspaceUpdateContext context, List syncCommands, Action syncOutput, CancellationToken cancellationToken) { // Sync them all. Explicitly disable parallel syncing here to avoid shelling out to p4.exe. List> responses = await perforce.TrySyncAsync(SyncOptions.None, -1, 0, -1, -1, -1, -1, syncCommands, cancellationToken).ToListAsync(cancellationToken); List tamperedFiles = new List(); foreach (PerforceResponse response in responses) { const string noClobberPrefix = "Can't clobber writable file "; if (response.Info != null) { continue; } else if (response.Succeeded) { syncOutput(response.Data); } else if (response.Error != null && response.Error.Generic == PerforceGenericCode.Client && response.Error.Data.StartsWith(noClobberPrefix, StringComparison.OrdinalIgnoreCase)) { tamperedFiles.Add(response.Error.Data.Substring(noClobberPrefix.Length).Trim()); } else { return (WorkspaceUpdateResult.FailedToSync, $"Aborted sync due to error ({response}). If you are on an unreliable connection, you may wish to increase the number of retries from Options > Application Settings... > Advanced.", response.ToString()); } } // If any files need to be clobbered, defer to the main thread to figure out which ones if (tamperedFiles.Count > 0) { if ((context.Options & WorkspaceUpdateOptions.Clobber) == 0) { int numNewFilesToClobber = 0; foreach (string tamperedFile in tamperedFiles) { if (!context.ClobberFiles.ContainsKey(tamperedFile)) { context.ClobberFiles[tamperedFile] = true; if (tamperedFile.EndsWith(LocalObjectVersionFileName, StringComparison.OrdinalIgnoreCase) || tamperedFile.EndsWith(LocalVersionHeaderFileName, StringComparison.OrdinalIgnoreCase)) { // Hack for UseFastModularVersioningV2; we don't need to update these files any more. continue; } numNewFilesToClobber++; } } if (numNewFilesToClobber > 0) { return (WorkspaceUpdateResult.FilesToClobber, $"Cancelled sync after checking files to clobber ({numNewFilesToClobber} new files).", null); } } foreach (string tamperedFile in tamperedFiles) { bool shouldClobber = (context.Options & WorkspaceUpdateOptions.Clobber) != 0 || context.ClobberFiles[tamperedFile]; if (shouldClobber) { List> response = await perforce.TrySyncAsync(SyncOptions.Force, -1, 0, -1, -1, -1, -1, tamperedFile, cancellationToken).ToListAsync(cancellationToken); if (!response.Succeeded()) { return (WorkspaceUpdateResult.FailedToSync, $"Couldn't sync {tamperedFile}.", response.ToString()); } } } } // All succeeded return (WorkspaceUpdateResult.Success, "Succeeded.", null); } public static async Task ReadProjectConfigFile(DirectoryReference localRootPath, FileReference selectedLocalFileName, ILogger logger) { // Find the valid config file paths DirectoryInfo engineDir = DirectoryReference.Combine(localRootPath, "Engine").ToDirectoryInfo(); List localConfigFiles = Utility.GetLocalConfigPaths(engineDir, selectedLocalFileName.ToFileInfo()); // Read them in ConfigFile projectConfig = new ConfigFile(); foreach (FileInfo localConfigFile in localConfigFiles) { try { string[] lines = await File.ReadAllLinesAsync(localConfigFile.FullName); projectConfig.Parse(lines); logger.LogDebug("Read config file from {FileName}", localConfigFile.FullName); } catch (Exception ex) { logger.LogWarning(ex, "Failed to read config file from {FileName}", localConfigFile.FullName); } } return projectConfig; } public static async Task?> ReadProjectStreamFilter(IPerforceConnection perforce, ConfigFile projectConfigFile, CancellationToken cancellationToken) { string? streamListDepotPath = projectConfigFile.GetValue("Options.QuickSelectStreamList", null); if (streamListDepotPath == null) { return null; } PerforceResponse> response = await perforce.TryPrintLinesAsync(streamListDepotPath, cancellationToken); if (!response.Succeeded) { return null; } return response.Data.Contents?.Select(x => x.Trim()).Where(x => x.Length > 0).ToList().AsReadOnly(); } static string FormatTime(long seconds) { if (seconds >= 60) { return String.Format("{0,3}m {1:00}s", seconds / 60, seconds % 60); } else { return String.Format(" {0,2}s", seconds); } } static async Task HasModifiedSourceFiles(IPerforceConnection perforce, ProjectInfo project, CancellationToken cancellationToken) { List openFiles = await perforce.OpenedAsync(OpenedOptions.None, project.ClientRootPath + "/...", cancellationToken).ToListAsync(cancellationToken); if (openFiles.Any(x => x.DepotFile.Contains("/Source/", StringComparison.OrdinalIgnoreCase))) { return true; } return false; } static async Task> FindUnresolvedFiles(IPerforceConnection perforce, IEnumerable syncPaths, CancellationToken cancellationToken) { List unresolvedFiles = new List(); foreach (string syncPath in syncPaths) { List records = await perforce.FStatAsync(FStatOptions.OnlyUnresolved, syncPath, cancellationToken).ToListAsync(cancellationToken); unresolvedFiles.AddRange(records); } return unresolvedFiles; } static Task UpdateVersionFile(IPerforceConnection perforce, string clientPath, Dictionary versionStrings, int changeNumber, ILogger logger, CancellationToken cancellationToken) { return UpdateVersionFile(perforce, clientPath, changeNumber, text => UpdateVersionStrings(text, versionStrings), logger, cancellationToken); } static async Task UpdateVersionFile(IPerforceConnection perforce, string clientPath, int changeNumber, Func update, ILogger logger, CancellationToken cancellationToken) { List> records = await perforce.TryFStatAsync(FStatOptions.None, clientPath, cancellationToken).ToListAsync(cancellationToken); if (!records.Succeeded()) { logger.LogInformation("Failed to query records for {ClientPath}", clientPath); return false; } if (records.Count > 1) { // Attempt to remove any existing file which is synced await perforce.SyncAsync(SyncOptions.Force, -1, $"{clientPath}#0", cancellationToken).ToListAsync(cancellationToken); // Try to get the mapped files again records = await perforce.TryFStatAsync(FStatOptions.None, clientPath, cancellationToken).ToListAsync(cancellationToken); if (!records.Succeeded()) { logger.LogInformation("Failed to query records for {ClientPath}", clientPath); return false; } } if (records.Count == 0) { logger.LogInformation("Ignoring {ClientPath}; not found on server.", clientPath); return true; } FStatRecord record = records[0].Data; string? localPath = record.ClientFile; // Actually a filesystem path if (localPath == null) { logger.LogInformation("Version file is not mapped to workspace ({ClientFile})", clientPath); return false; } string? depotPath = record.DepotFile; if (depotPath == null) { logger.LogInformation("Version file does not exist in depot ({ClientFile})", clientPath); return false; } PerforceResponse> response = await perforce.TryPrintLinesAsync($"{depotPath}@{changeNumber}", cancellationToken); if (!response.Succeeded) { logger.LogInformation("Couldn't get default contents of {DepotPath}", depotPath); return false; } string[]? contents = response.Data.Contents; if (contents == null) { logger.LogInformation("No data returned for {DepotPath}", depotPath); return false; } string text = String.Join("\n", contents); text = update(text); return await WriteVersionFile(perforce, localPath, depotPath, text, logger, cancellationToken); } static string UpdateVersionStrings(string text, Dictionary versionStrings) { using StringWriter writer = new StringWriter(); foreach (string line in text.Split('\n')) { string newLine = line; foreach (KeyValuePair versionString in versionStrings) { if (UpdateVersionLine(ref newLine, versionString.Key, versionString.Value)) { break; } } writer.WriteLine(newLine); } return writer.ToString(); } static string UpdateBuildVersion(string text, int changelist, int codeChangelist, string branchOrStreamName, bool isLicenseeVersion) { Dictionary obj = JsonSerializer.Deserialize>(text, Utility.DefaultJsonSerializerOptions)!; int prevCompatibleChangelist = 0; if (obj.TryGetValue("CompatibleChangelist", out object? prevCompatibleChangelistObj)) { if (!Int32.TryParse(prevCompatibleChangelistObj?.ToString(), out prevCompatibleChangelist)) { prevCompatibleChangelist = 0; } } int prevIsLicenseeVersion = 0; if (obj.TryGetValue("IsLicenseeVersion", out object? prevIsLicenseeVersionObj)) { if (!Int32.TryParse(prevIsLicenseeVersionObj?.ToString(), out prevIsLicenseeVersion)) { prevIsLicenseeVersion = 0; } } obj["Changelist"] = changelist; if (prevCompatibleChangelist == 0 || (prevIsLicenseeVersion != 0) != isLicenseeVersion) { // Don't overwrite the compatible changelist if we're in a hotfix release obj["CompatibleChangelist"] = codeChangelist; } obj["BranchName"] = branchOrStreamName.Replace('/', '+'); obj["IsPromotedBuild"] = 0; obj["IsLicenseeVersion"] = isLicenseeVersion ? 1 : 0; return JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = true, // do not escape + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); } static async Task WriteVersionFile(IPerforceConnection perforce, string localPath, string depotPath, string newText, ILogger logger, CancellationToken cancellationToken) { try { if (File.Exists(localPath) && await File.ReadAllTextAsync(localPath, cancellationToken) == newText) { logger.LogInformation("Ignored {FileName}; contents haven't changed", localPath); } else { Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); Utility.ForceDeleteFile(localPath); if (depotPath != null) { await perforce.SyncAsync(depotPath + "#0", cancellationToken).ToListAsync(cancellationToken); } await File.WriteAllTextAsync(localPath, newText, cancellationToken); logger.LogInformation("Written {FileName}", localPath); } return true; } catch (Exception ex) { logger.LogError(ex, "Failed to write to {FileName}.", localPath); return false; } } static bool UpdateVersionLine(ref string line, string prefix, string suffix) { int lineIdx = 0; int prefixIdx = 0; for (; ; ) { string? prefixToken = ReadToken(prefix, ref prefixIdx); if (prefixToken == null) { break; } string? lineToken = ReadToken(line, ref lineIdx); if (lineToken == null || lineToken != prefixToken) { return false; } } line = line.Substring(0, lineIdx) + suffix; return true; } static string? ReadToken(string line, ref int lineIdx) { for (; ; lineIdx++) { if (lineIdx == line.Length) { return null; } else if (!Char.IsWhiteSpace(line[lineIdx])) { break; } } int startIdx = lineIdx++; if (Char.IsLetterOrDigit(line[startIdx]) || line[startIdx] == '_') { while (lineIdx < line.Length && (Char.IsLetterOrDigit(line[lineIdx]) || line[lineIdx] == '_')) { lineIdx++; } } return line.Substring(startIdx, lineIdx - startIdx); } public Tuple CurrentProgress => Progress.Current; } }