Files
2025-05-18 13:04:45 +08:00

2356 lines
94 KiB
C#

// 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<string> SyncFilter { get; } = new List<string>();
public Dictionary<string, IArchive?> ArchiveTypeToArchive { get; } = new Dictionary<string, IArchive?>();
public Dictionary<string, bool> DeleteFiles { get; } = new Dictionary<string, bool>();
public Dictionary<string, bool> ClobberFiles { get; } = new Dictionary<string, bool>();
public List<ConfigObject> UserBuildStepObjects { get; } = new List<ConfigObject>();
public HashSet<Guid> CustomBuildSteps { get; } = new HashSet<Guid>();
public Dictionary<string, string> AdditionalVariables { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public PerforceSyncOptions? PerforceSyncOptions { get; set; }
public List<HaveRecord> HaveFiles { get; } = new List<HaveRecord>(); // Cached when sync filter has changed
public List<SnapshotSettings> Snapshots { get; } = new List<SnapshotSettings>();
public string SnapshotHost { get; set; } = "";
public string SnapshotNamespace { get; set; } = "";
// May be updated during sync
public ConfigFile? ProjectConfigFile { get; set; }
public IReadOnlyList<string>? ProjectStreamFilter { get; set; }
public WorkspaceUpdateContext(int changeNumber, WorkspaceUpdateOptions options, BuildConfig editorConfig, string[]? syncFilter, List<ConfigObject> userBuildSteps, HashSet<Guid>? 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<string> Paths { get; } = new List<string>();
public bool Hidden { get; set; }
public List<Guid> Requires { get; } = new List<Guid>();
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<Guid, bool> GetDefault(IEnumerable<WorkspaceSyncCategory> categories)
{
return categories.ToDictionary(x => x.UniqueId, x => x.Enable);
}
public static Dictionary<Guid, bool> GetDelta(Dictionary<Guid, bool> source, Dictionary<Guid, bool> target)
{
Dictionary<Guid, bool> changes = new Dictionary<Guid, bool>();
foreach (KeyValuePair<Guid, bool> 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<Guid, bool> categories, Dictionary<Guid, bool> delta)
{
foreach (KeyValuePair<Guid, bool> 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<ProjectInfo> 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<PerforceResponse<WhereRecord>> 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<PrintRecord<string[]>> 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<string?> TryGetStreamPrefixAsync(IPerforceConnection perforce, string streamName, CancellationToken cancellationToken)
{
string? currentStreamName = streamName;
while (!String.IsNullOrEmpty(currentStreamName))
{
PerforceResponse<StreamRecord> 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<List<string>> Batches { get; }
List<string>? _commands;
List<string>? _deleteCommands;
long _size;
public SyncBatchBuilder(int? maxCommandsPerList, long? maxSizePerList)
{
MaxCommandsPerList = maxCommandsPerList ?? PerforceSyncOptions.DefaultMaxCommandsPerBatch;
MaxSizePerList = maxSizePerList ?? PerforceSyncOptions.DefaultMaxSizePerBatch;
Batches = new Queue<List<string>>();
}
public void Add(string newCommand, long newSize)
{
if (newSize == 0)
{
if (_deleteCommands == null || _deleteCommands.Count >= MaxCommandsPerList)
{
_deleteCommands = new List<string>();
Batches.Enqueue(_deleteCommands);
}
_deleteCommands.Add(newCommand);
}
else
{
if (_commands == null || _commands.Count >= MaxCommandsPerList || _size + newSize >= MaxSizePerList)
{
_commands = new List<string>();
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<string, long> IncludedFiles = new Dictionary<string, long>();
public Dictionary<string, SyncTree> NameToSubTree = new Dictionary<string, SyncTree>(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<string, long> file in IncludedFiles)
{
builder.Add($"{prefix}/{file.Key}@{changeNumber}", file.Value);
}
foreach (KeyValuePair<string, SyncTree> pair in NameToSubTree)
{
pair.Value.GetOptimizedSyncCommandsWithExcludedFiles($"{prefix}/{PerforceUtils.EscapePath(pair.Key)}", changeNumber, builder);
}
}
}
private void GetOptimizedSyncCommandsWithoutExcludedFiles(string prefix, int changeNumber, SyncBatchBuilder builder)
{
foreach (KeyValuePair<string, long> file in IncludedFiles)
{
builder.Add($"{prefix}/{file.Key}@{changeNumber}", file.Value);
}
foreach (KeyValuePair<string, SyncTree> 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<int> ExecuteShellCommandAsync(string commandLine, Action<string> 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<Tuple<string, TimeSpan>> times = new List<Tuple<string, TimeSpan>>();
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<LoginRecord> loginResponse = await perforce.TryGetLoginStateAsync(cancellationToken);
if (!loginResponse.Succeeded)
{
return (WorkspaceUpdateResult.FailedToSyncLoginExpired, "User is not logged in.");
}
// Figure out which paths to sync
List<string> relativeSyncPaths = GetRelativeSyncPaths(project, (Context.Options & WorkspaceUpdateOptions.SyncAllProjects) != 0, Context.SyncFilter);
List<string> syncPaths = new List<string>(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<HaveRecord> haveFiles = Context.HaveFiles;
if (haveFiles.Count == 0)
{
using (RecordCounter haveCounter = new RecordCounter(Progress, "Sync filter changed; checking workspace..."))
{
await foreach (PerforceResponse<HaveRecord> 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<string> removeDepotPathsBag = new ConcurrentBag<string>();
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<string> removeDepotPaths = removeDepotPathsBag.ToList();
for (int i=0; i<Math.Min(removeDepotPaths.Count, MaxLogFiles); i++)
{
logger.LogInformation(" {DepotFile}", removeDepotPaths[i]);
}
if (removeDepotPaths.Count > 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<string, bool> prevDeleteFiles = new Dictionary<string, bool>(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<string> remainingDepotPathsToRemove = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
remainingDepotPathsToRemove.UnionWith(removeDepotPaths);
// Build the list of revisions to sync
List<string> revisionsToRemove = new List<string>();
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<string, TimeSpan>("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<string> syncDepotPaths = new List<string>();
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<SyncFile> syncFiles = new List<SyncFile>();
await foreach (PerforceResponse<SyncRecord> 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<OpenedRecord> 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<string> 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<string, TimeSpan>("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<string> remainingDepotPaths = new HashSet<string>(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<string, TimeSpan>("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<string> versionStreams = new HashSet<string>(Context.ProjectConfigFile.GetValues("Perforce.VersionStreams", Array.Empty<string>()), 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<WhereRecord> 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<string, TimeSpan>("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<string, string> versionHeaderStrings = new Dictionary<string, string>();
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<string, string>(), Context.ChangeNumber, logger, cancellationToken))
{
return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {ObjectVersionFileName}.");
}
}
else
{
if (!await UpdateVersionFile(perforce, project.ClientRootPath + BuildVersionFileName, new Dictionary<string, string>(), Context.ChangeNumber, logger, cancellationToken))
{
return (WorkspaceUpdateResult.FailedToSync, $"Failed to update {BuildVersionFileName}");
}
Dictionary<string, string> versionStrings = new Dictionary<string, string>();
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<FStatRecord> 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<string, string> 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<string, TimeSpan>("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<string, TimeSpan>("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<string, TimeSpan>("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<Task<int>> snapshotTasks = new List<Task<int>>();
Action<string, string> 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<string, TimeSpan>("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<string, TimeSpan>("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<Guid, ConfigObject> buildStepObjects = ConfigUtils.GetDefaultBuildStepObjects(project, editorTargetName, Context.EditorConfig, Context.ProjectConfigFile, usingPrecompiledEditor);
BuildStep.MergeBuildStepObjects(buildStepObjects, Context.ProjectConfigFile.GetValues("Build.Step", Array.Empty<string>()).Select(x => new ConfigObject(x)));
BuildStep.MergeBuildStepObjects(buildStepObjects, Context.UserBuildStepObjects);
// Construct build steps from them
List<BuildStep> 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<string>()))
{
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<string, string> 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<string, TimeSpan>("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<string, TimeSpan> 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<bool> 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<FStatRecord> records = await perforce.FStatAsync(files, cancellationToken).ToListAsync(cancellationToken);
return !records.Any(x => x.IsMapped);
}
public static List<string> GetSyncPaths(ProjectInfo project, bool syncAllProjects, string[] syncFilter)
{
List<string> syncPaths = GetRelativeSyncPaths(project, syncAllProjects, syncFilter);
return syncPaths.Select(x => project.ClientRootPath + x).ToList();
}
public static List<string> GetRelativeSyncPaths(ProjectInfo project, bool syncAllProjects, IReadOnlyList<string>? syncFilter)
{
List<string> syncPaths = new List<string>();
// 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<string> RemainingDepotPaths;
public Queue<List<string>> SyncCommandLists;
public string StatusMessage;
public WorkspaceUpdateResult Result = WorkspaceUpdateResult.Success;
public SyncState(HashSet<string> remainingDepotPaths, Queue<List<string>> syncCommandLists)
{
TotalDepotPaths = remainingDepotPaths.Count;
RemainingDepotPaths = remainingDepotPaths;
SyncCommandLists = syncCommandLists;
StatusMessage = "Succeeded.";
}
}
static Task<(WorkspaceUpdateResult, string)> SyncFileRevisions(IPerforceConnection perforce, string prefix, WorkspaceUpdateContext context, List<string> syncCommands, HashSet<string> remainingDepotPaths, ProgressValue progress, ILogger logger, CancellationToken cancellationToken)
{
Queue<List<string>> syncCommandLists = new Queue<List<string>>();
foreach (IReadOnlyList<string> 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<List<string>> syncCommandLists, HashSet<string> 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<IPerforceConnection> childConnections = new List<IPerforceConnection>();
List<Task> childTasks = new List<Task>(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<SyncRecord, ILogger> 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<SyncRecord, ILogger> 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<string> 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<string> syncCommands, Action<SyncRecord> syncOutput, CancellationToken cancellationToken)
{
// Sync them all. Explicitly disable parallel syncing here to avoid shelling out to p4.exe.
List<PerforceResponse<SyncRecord>> responses = await perforce.TrySyncAsync(SyncOptions.None, -1, 0, -1, -1, -1, -1, syncCommands, cancellationToken).ToListAsync(cancellationToken);
List<string> tamperedFiles = new List<string>();
foreach (PerforceResponse<SyncRecord> 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<PerforceResponse<SyncRecord>> 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<ConfigFile> ReadProjectConfigFile(DirectoryReference localRootPath, FileReference selectedLocalFileName, ILogger logger)
{
// Find the valid config file paths
DirectoryInfo engineDir = DirectoryReference.Combine(localRootPath, "Engine").ToDirectoryInfo();
List<FileInfo> 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<IReadOnlyList<string>?> ReadProjectStreamFilter(IPerforceConnection perforce, ConfigFile projectConfigFile, CancellationToken cancellationToken)
{
string? streamListDepotPath = projectConfigFile.GetValue("Options.QuickSelectStreamList", null);
if (streamListDepotPath == null)
{
return null;
}
PerforceResponse<PrintRecord<string[]>> 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<bool> HasModifiedSourceFiles(IPerforceConnection perforce, ProjectInfo project, CancellationToken cancellationToken)
{
List<OpenedRecord> 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<List<FStatRecord>> FindUnresolvedFiles(IPerforceConnection perforce, IEnumerable<string> syncPaths, CancellationToken cancellationToken)
{
List<FStatRecord> unresolvedFiles = new List<FStatRecord>();
foreach (string syncPath in syncPaths)
{
List<FStatRecord> records = await perforce.FStatAsync(FStatOptions.OnlyUnresolved, syncPath, cancellationToken).ToListAsync(cancellationToken);
unresolvedFiles.AddRange(records);
}
return unresolvedFiles;
}
static Task<bool> UpdateVersionFile(IPerforceConnection perforce, string clientPath, Dictionary<string, string> versionStrings, int changeNumber, ILogger logger, CancellationToken cancellationToken)
{
return UpdateVersionFile(perforce, clientPath, changeNumber, text => UpdateVersionStrings(text, versionStrings), logger, cancellationToken);
}
static async Task<bool> UpdateVersionFile(IPerforceConnection perforce, string clientPath, int changeNumber, Func<string, string> update, ILogger logger, CancellationToken cancellationToken)
{
List<PerforceResponse<FStatRecord>> 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<PrintRecord<string[]>> 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<string, string> versionStrings)
{
using StringWriter writer = new StringWriter();
foreach (string line in text.Split('\n'))
{
string newLine = line;
foreach (KeyValuePair<string, string> 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<string, object> obj = JsonSerializer.Deserialize<Dictionary<string, object>>(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<bool> 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<string, float> CurrentProgress => Progress.Current;
}
}