// Copyright Epic Games, Inc. All Rights Reserved. #include "UncontrolledChangelistsModule.h" #include "CoreGlobals.h" #include "Algo/AnyOf.h" #include "Algo/Copy.h" #include "Algo/Find.h" #include "Algo/ForEach.h" #include "Algo/Transform.h" #include "AssetRegistry/AssetRegistryModule.h" #include "Dom/JsonObject.h" #include "FileHelpers.h" #include "HAL/FileManager.h" #include "HAL/IConsoleManager.h" #include "ISourceControlModule.h" #include "ISourceControlProvider.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "Misc/ScopedSlowTask.h" #include "Misc/CoreDelegates.h" #include "PackagesDialog.h" #include "Serialization/JsonSerializer.h" #include "Serialization/JsonWriter.h" #include "SourceControlHelpers.h" #include "SourceControlOperations.h" #include "SourceControlPreferences.h" #include "Styling/SlateTypes.h" #include "UObject/ObjectSaveContext.h" #define LOCTEXT_NAMESPACE "UncontrolledChangelists" void FUncontrolledChangelistsModule::FStartupTask::DoWork() { double StartTime = FPlatformTime::Seconds(); UE_LOG(LogSourceControl, Log, TEXT("Uncontrolled asset enumeration started...")); FAssetRegistryModule& AssetRegistryModule = FModuleManager::GetModuleChecked(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); TArray Assets; const bool bIncludeOnlyOnDiskAssets = true; AssetRegistry.GetAllAssets(Assets, bIncludeOnlyOnDiskAssets); for(const FAssetData& AssetData : Assets) { if (IsEngineExitRequested()) { break; } Owner->OnAssetAddedInternal(AssetData, AddedAssetsCache, true); } UE_LOG(LogSourceControl, Log, TEXT("Uncontrolled asset enumeration finished in %s seconds (Found %d uncontrolled assets)"), *FString::SanitizeFloat(FPlatformTime::Seconds() - StartTime), AddedAssetsCache.Num()); } void FUncontrolledChangelistsModule::StartupModule() { bIsEnabled = USourceControlPreferences::AreUncontrolledChangelistsEnabled(); if (!IsEnabled()) { return; } // Adds Default Uncontrolled Changelist if it is not already present. GetDefaultUncontrolledChangelistState(); LoadState(); FAssetRegistryModule& AssetRegistryModule = FModuleManager::GetModuleChecked(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); OnAssetAddedDelegateHandle = AssetRegistry.OnAssetAdded().AddLambda([](const struct FAssetData& AssetData) { Get().OnAssetAdded(AssetData); }); OnObjectPreSavedDelegateHandle = FCoreUObjectDelegates::OnObjectPreSave.AddLambda([](UObject* InAsset, const FObjectPreSaveContext& InPreSaveContext) { Get().OnObjectPreSaved(InAsset, InPreSaveContext); }); OnEndFrameDelegateHandle = FCoreDelegates::OnEndFrame.AddLambda([]() { Get().OnEndFrame(); }); StartupTask = MakeUnique>(this); StartupTask->StartBackgroundTask(); } void FUncontrolledChangelistsModule::ShutdownModule() { if (StartupTask) { StartupTask->EnsureCompletion(); StartupTask = nullptr; } if (bIsStateDirty) { SaveState(); bIsStateDirty = false; } FAssetRegistryModule* AssetRegistryModulePtr = static_cast(FModuleManager::Get().GetModule(TEXT("AssetRegistry"))); // Check in case AssetRegistry has already been shutdown. if (AssetRegistryModulePtr != nullptr) { AssetRegistryModulePtr->Get().OnAssetAdded().Remove(OnAssetAddedDelegateHandle); } FCoreUObjectDelegates::OnObjectPreSave.Remove(OnObjectPreSavedDelegateHandle); FCoreDelegates::OnEndFrame.Remove(OnEndFrameDelegateHandle); } bool FUncontrolledChangelistsModule::IsEnabled() const { return bIsEnabled; } TArray FUncontrolledChangelistsModule::GetChangelistStates() const { TArray UncontrolledChangelistStates; if (IsEnabled()) { Algo::Transform(UncontrolledChangelistsStateCache, UncontrolledChangelistStates, [](const auto& Pair) { return Pair.Value; }); } return UncontrolledChangelistStates; } bool FUncontrolledChangelistsModule::OnMakeWritable(const FString& InFilename) { if (!IsEnabled()) { return false; } AddedAssetsCache.Add(FPaths::ConvertRelativePathToFull(InFilename)); return true; } bool FUncontrolledChangelistsModule::OnNewFilesAdded(const TArray& InFilenames) { return AddToUncontrolledChangelist(InFilenames); } bool FUncontrolledChangelistsModule::OnSaveWritable(const FString& InFilename) { return AddToUncontrolledChangelist({ InFilename }); } bool FUncontrolledChangelistsModule::OnDeleteWritable(const FString& InFilename) { return AddToUncontrolledChangelist({ InFilename }); } bool FUncontrolledChangelistsModule::AddToUncontrolledChangelist(const TArray& InFilenames) { if (!IsEnabled()) { return false; } TRACE_CPUPROFILER_EVENT_SCOPE(FUncontrolledChangelistsModule::AddToUncontrolledChangelist); TArray FullPaths; FullPaths.Reserve(InFilenames.Num()); Algo::Transform(InFilenames, FullPaths, [](const FString& Filename) { return FPaths::ConvertRelativePathToFull(Filename); }); // Remove from reconcile cache for (const FString& FullPath : FullPaths) { AddedAssetsCache.Remove(FullPath); } return AddFilesToDefaultUncontrolledChangelist(FullPaths, FUncontrolledChangelistState::ECheckFlags::NotCheckedOut); } void FUncontrolledChangelistsModule::UpdateStatus() { bool bHasStateChanged = false; if (!IsEnabled()) { return; } for (FUncontrolledChangelistsStateCache::ElementType& Pair : UncontrolledChangelistsStateCache) { FUncontrolledChangelistsStateCache::ValueType& UncontrolledChangelistState = Pair.Value; bHasStateChanged |= UncontrolledChangelistState->UpdateStatus(); } if (bHasStateChanged) { OnStateChanged(); } } FText FUncontrolledChangelistsModule::GetReconcileStatus() const { if (StartupTask && !StartupTask->IsDone()) { return LOCTEXT("ProcessingAssetsStatus", "Processing assets..."); } return FText::Format(LOCTEXT("ReconcileStatus", "Assets to check for reconcile: {0}"), FText::AsNumber(AddedAssetsCache.Num())); } bool FUncontrolledChangelistsModule::OnReconcileAssets() { FScopedSlowTask Scope(0, LOCTEXT("ProcessingAssetsProgress", "Processing assets")); const bool bShowCancelButton = false; const bool bAllowInPIE = false; Scope.MakeDialogDelayed(1.0f, bShowCancelButton, bAllowInPIE); if (StartupTask) { while (!StartupTask->WaitCompletionWithTimeout(0.016)) { Scope.EnterProgressFrame(0.f); } AddedAssetsCache.Append(StartupTask->GetTask().GetAddedAssetsCache()); StartupTask = nullptr; } if ((!IsEnabled()) || AddedAssetsCache.IsEmpty()) { return false; } Scope.EnterProgressFrame(0.f, LOCTEXT("ReconcileAssetsProgress", "Reconciling assets")); CleanAssetsCaches(); bool bHasStateChanged = AddFilesToDefaultUncontrolledChangelist(AddedAssetsCache.Array(), FUncontrolledChangelistState::ECheckFlags::All); AddedAssetsCache.Empty(); return bHasStateChanged; } void FUncontrolledChangelistsModule::OnAssetAdded(const FAssetData& AssetData) { if (!IsEnabled()) { return; } OnAssetAddedInternal(AssetData, AddedAssetsCache, false); } void FUncontrolledChangelistsModule::OnAssetAddedInternal(const FAssetData& AssetData, TSet& InAddedAssetsCache, bool bInStartupTask) { if (AssetData.HasAnyPackageFlags(PKG_Cooked)) { return; } FPackagePath PackagePath; if (!FPackagePath::TryFromPackageName(AssetData.PackageName, PackagePath)) { return; } // No need to check for existence when running startup task if (!bInStartupTask) { if (FPackageName::IsTempPackage(PackagePath.GetPackageName())) { return; // Ignore temp packages } if(!FPackageName::DoesPackageExist(PackagePath, &PackagePath)) { return; // If the package does not exist on disk there is nothing more to do } } const FString LocalFullPath(PackagePath.GetLocalFullPath()); if (LocalFullPath.IsEmpty()) { return; } FString Fullpath = FPaths::ConvertRelativePathToFull(LocalFullPath); if (Fullpath.IsEmpty()) { return; } if (!IFileManager::Get().IsReadOnly(*Fullpath)) { InAddedAssetsCache.Add(MoveTemp(Fullpath)); } } static bool ExecuteRevertOperation(const TArray& InFilenames) { ISourceControlModule& SourceControlModule = ISourceControlModule::Get(); ISourceControlProvider& SourceControlProvider = SourceControlModule.GetProvider(); TArray UpdatedFilestates; auto BuildFileString = [](const TArray& Files) -> FString { TStringBuilder<2048> Builder; Builder.Join(Files, TEXT(", ")); return Builder.ToString(); }; if (SourceControlProvider.GetState(InFilenames, UpdatedFilestates, EStateCacheUsage::ForceUpdate) != ECommandResult::Succeeded) { UE_LOG(LogSourceControl, Error, TEXT("Failed to update the source control files states for %s."), *BuildFileString(InFilenames)); return false; } TArray FilesToDelete; TArray FilesToRevert; for (const FSourceControlStateRef& Filestate : UpdatedFilestates) { if (Filestate->IsSourceControlled()) { FilesToRevert.Add(Filestate->GetFilename()); } else { FilesToDelete.Add(Filestate->GetFilename()); } } if (!FilesToRevert.IsEmpty()) { TSharedRef ForceSyncOperation = ISourceControlOperation::Create(); ForceSyncOperation->SetForce(true); ForceSyncOperation->SetLastSyncedFlag(true); if (SourceControlProvider.Execute(ForceSyncOperation, FilesToRevert) != ECommandResult::Succeeded) { UE_LOG(LogSourceControl, Error, TEXT("Failed to sync the following files to a previous version: %s."), *BuildFileString(FilesToRevert)); return false; } } IFileManager& FileManager = IFileManager::Get(); bool bSuccess = true; for (const FString& FileToDelete : FilesToDelete) { const bool bRequireExists = true; const bool bEvenReadOnly = false; const bool bQuiet = false; if (!FileManager.Delete(*FileToDelete, bRequireExists, bEvenReadOnly, bQuiet)) { UE_LOG(LogSourceControl, Error, TEXT("Failed to delete %s."), *FileToDelete); bSuccess = false; } } SourceControlModule.GetOnFilesDeleted().Broadcast(FilesToDelete); return bSuccess; } bool FUncontrolledChangelistsModule::OnRevert(const TArray& InFilenames) { bool bSuccess = false; if (!IsEnabled() || InFilenames.IsEmpty()) { return true; } bSuccess = SourceControlHelpers::ApplyOperationAndReloadPackages(InFilenames, ExecuteRevertOperation); UpdateStatus(); return bSuccess; } void FUncontrolledChangelistsModule::OnObjectPreSaved(UObject* InObject, const FObjectPreSaveContext& InPreSaveContext) { if (!IsEnabled()) { return; } // Make sure we are catching the top level asset object to avoid processing same package multiple times if (!InObject || !InObject->IsAsset()) { return; } // Ignore procedural save and autosaves if (InPreSaveContext.IsProceduralSave() || ((InPreSaveContext.GetSaveFlags() & SAVE_FromAutosave) != 0)) { return; } FString Fullpath = FPaths::ConvertRelativePathToFull(InPreSaveContext.GetTargetFilename()); if (Fullpath.IsEmpty()) { return; } AddedAssetsCache.Add(MoveTemp(Fullpath)); } void FUncontrolledChangelistsModule::MoveFilesToUncontrolledChangelist(const TArray& InControlledFileStates, const TArray& InUncontrolledFileStates, const FUncontrolledChangelist& InUncontrolledChangelist) { bool bHasStateChanged = false; if (!IsEnabled()) { return; } FUncontrolledChangelistsStateCache::ValueType* ChangelistState = UncontrolledChangelistsStateCache.Find(InUncontrolledChangelist); if (ChangelistState == nullptr) { return; } TArray Filenames; Algo::Transform(InControlledFileStates, Filenames, [](const FSourceControlStateRef& State) { return State->GetFilename(); }); ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider(); auto RevertOperation = ISourceControlOperation::Create(); // Revert controlled files RevertOperation->SetSoftRevert(true); SourceControlProvider.Execute(RevertOperation, Filenames); // Removes selected Uncontrolled Files from their Uncontrolled Changelists for (const auto& Pair : UncontrolledChangelistsStateCache) { const FUncontrolledChangelistStateRef& UncontrolledChangelistState = Pair.Value; UncontrolledChangelistState->RemoveFiles(InUncontrolledFileStates); } Algo::Transform(InUncontrolledFileStates, Filenames, [](const FSourceControlStateRef& State) { return State->GetFilename(); }); // Add all files to their UncontrolledChangelist bHasStateChanged = (*ChangelistState)->AddFiles(Filenames, FUncontrolledChangelistState::ECheckFlags::None); if (bHasStateChanged) { OnStateChanged(); } } void FUncontrolledChangelistsModule::MoveFilesToControlledChangelist(const TArray& InUncontrolledFileStates, const FSourceControlChangelistPtr& InChangelist, TFunctionRef&)> InOpenConflictDialog) { if (!IsEnabled()) { return; } TArray UncontrolledFilenames; Algo::Transform(InUncontrolledFileStates, UncontrolledFilenames, [](const FSourceControlStateRef& State) { return State->GetFilename(); }); MoveFilesToControlledChangelist(UncontrolledFilenames, InChangelist, InOpenConflictDialog); } void FUncontrolledChangelistsModule::MoveFilesToControlledChangelist(const TArray& InUncontrolledFiles, const FSourceControlChangelistPtr& InChangelist, TFunctionRef&)> InOpenConflictDialog) { if (!IsEnabled()) { return; } ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider(); TArray UpdatedFilestates; // Get updated filestates to check Checkout capabilities. SourceControlProvider.GetState(InUncontrolledFiles, UpdatedFilestates, EStateCacheUsage::ForceUpdate); TArray FilesConflicts; TArray FilesToAdd; TArray FilesToCheckout; TArray FilesToDelete; // Check if we can Checkout files or mark for add for (const FSourceControlStateRef& Filestate : UpdatedFilestates) { const FString& Filename = Filestate->GetFilename(); if (!Filestate->IsSourceControlled()) { FilesToAdd.Add(Filename); } else if (!IFileManager::Get().FileExists(*Filename)) { FilesToDelete.Add(Filename); } else if (Filestate->CanCheckout()) { FilesToCheckout.Add(Filename); } else { FilesConflicts.Add(Filestate); FilesToCheckout.Add(Filename); } } bool bCanProceed = true; // If we detected conflict, asking user if we should proceed. if (!FilesConflicts.IsEmpty()) { bCanProceed = InOpenConflictDialog(FilesConflicts); } if (bCanProceed) { if (!FilesToCheckout.IsEmpty()) { SourceControlProvider.Execute(ISourceControlOperation::Create(), InChangelist, FilesToCheckout); } if (!FilesToAdd.IsEmpty()) { SourceControlProvider.Execute(ISourceControlOperation::Create(), InChangelist, FilesToAdd); } if (!FilesToDelete.IsEmpty()) { SourceControlProvider.Execute(ISourceControlOperation::Create(), InChangelist, FilesToDelete); } // UpdateStatus so UncontrolledChangelists can remove files from their cache if they were present before checkout. UpdateStatus(); } } TOptional FUncontrolledChangelistsModule::CreateUncontrolledChangelist(const FText& InDescription) { if (!IsEnabled()) { return TOptional(); } // Default constructor will generate a new GUID. FUncontrolledChangelist NewUncontrolledChangelist; UncontrolledChangelistsStateCache.Emplace(NewUncontrolledChangelist, MakeShared(NewUncontrolledChangelist, InDescription)); OnStateChanged(); return NewUncontrolledChangelist; } void FUncontrolledChangelistsModule::EditUncontrolledChangelist(const FUncontrolledChangelist& InUncontrolledChangelist, const FText& InNewDescription) { if (!IsEnabled()) { return; } if (InUncontrolledChangelist.IsDefault()) { UE_LOG(LogSourceControl, Error, TEXT("Cannot edit Default Uncontrolled Changelist.")); return; } FUncontrolledChangelistStateRef* UncontrolledChangelistState = UncontrolledChangelistsStateCache.Find(InUncontrolledChangelist); if (UncontrolledChangelistState == nullptr) { UE_LOG(LogSourceControl, Error, TEXT("Cannot find Uncontrolled Changelist %s in cache."), *InUncontrolledChangelist.ToString()); return; } (*UncontrolledChangelistState)->SetDescription(InNewDescription); OnStateChanged(); } void FUncontrolledChangelistsModule::DeleteUncontrolledChangelist(const FUncontrolledChangelist& InUncontrolledChangelist) { if (!IsEnabled()) { return; } if (InUncontrolledChangelist.IsDefault()) { UE_LOG(LogSourceControl, Error, TEXT("Cannot delete Default Uncontrolled Changelist.")); return; } FUncontrolledChangelistStateRef* UncontrolledChangelistState = UncontrolledChangelistsStateCache.Find(InUncontrolledChangelist); if (UncontrolledChangelistState == nullptr) { UE_LOG(LogSourceControl, Error, TEXT("Cannot find Uncontrolled Changelist %s in cache."), *InUncontrolledChangelist.ToString()); return; } if ((*UncontrolledChangelistState)->ContainsFiles()) { UE_LOG(LogSourceControl, Error, TEXT("Cannot delete Uncontrolled Changelist %s while it contains files."), *InUncontrolledChangelist.ToString()); return; } // Get Deleted Offline files and move them to the Default UCL so that we don't lose them GetDefaultUncontrolledChangelistState()->AddFiles((*UncontrolledChangelistState)->GetDeletedOfflineFiles().Array(), FUncontrolledChangelistState::ECheckFlags::None); UncontrolledChangelistsStateCache.Remove(InUncontrolledChangelist); OnStateChanged(); } void FUncontrolledChangelistsModule::OnStateChanged() { bIsStateDirty = true; } void FUncontrolledChangelistsModule::OnEndFrame() { if (bIsStateDirty) { OnUncontrolledChangelistModuleChanged.Broadcast(); SaveState(); bIsStateDirty = false; } } void FUncontrolledChangelistsModule::CleanAssetsCaches() { // Remove files we are already tracking in Uncontrolled Changelists for (FUncontrolledChangelistsStateCache::ElementType& Pair : UncontrolledChangelistsStateCache) { FUncontrolledChangelistsStateCache::ValueType& UncontrolledChangelistState = Pair.Value; UncontrolledChangelistState->RemoveDuplicates(AddedAssetsCache); } } bool FUncontrolledChangelistsModule::AddFilesToDefaultUncontrolledChangelist(const TArray& InFilenames, const FUncontrolledChangelistState::ECheckFlags InCheckFlags) { bool bHasStateChanged = false; FUncontrolledChangelistStateRef UncontrolledChangelistState = GetDefaultUncontrolledChangelistState(); // Try to add files, they will be added only if they pass the required checks bHasStateChanged = UncontrolledChangelistState->AddFiles(InFilenames, InCheckFlags); if (bHasStateChanged) { OnStateChanged(); } return bHasStateChanged; } FUncontrolledChangelistStateRef FUncontrolledChangelistsModule::GetDefaultUncontrolledChangelistState() { FUncontrolledChangelist DefaultUncontrolledChangelist(FUncontrolledChangelist::DEFAULT_UNCONTROLLED_CHANGELIST_GUID); FUncontrolledChangelistStateRef* DefaultUncontrolledChangelistState = UncontrolledChangelistsStateCache.Find(DefaultUncontrolledChangelist); if (DefaultUncontrolledChangelistState != nullptr) { return *DefaultUncontrolledChangelistState; } return UncontrolledChangelistsStateCache.Emplace(MoveTemp(DefaultUncontrolledChangelist), MakeShared(DefaultUncontrolledChangelist, FUncontrolledChangelistState::DEFAULT_UNCONTROLLED_CHANGELIST_DESCRIPTION)); } void FUncontrolledChangelistsModule::SaveState() const { TSharedPtr RootObject = MakeShareable(new FJsonObject); TArray> UncontrolledChangelistsArray; RootObject->SetNumberField(VERSION_NAME, VERSION_NUMBER); for (const auto& Pair : UncontrolledChangelistsStateCache) { const FUncontrolledChangelist& UncontrolledChangelist = Pair.Key; FUncontrolledChangelistStateRef UncontrolledChangelistState = Pair.Value; TSharedPtr UncontrolledChangelistObject = MakeShareable(new FJsonObject); UncontrolledChangelist.Serialize(UncontrolledChangelistObject.ToSharedRef()); UncontrolledChangelistState->Serialize(UncontrolledChangelistObject.ToSharedRef()); UncontrolledChangelistsArray.Add(MakeShareable(new FJsonValueObject(UncontrolledChangelistObject))); } RootObject->SetArrayField(CHANGELISTS_NAME, UncontrolledChangelistsArray); using FStringWriter = TJsonWriter>; using FStringWriterFactory = TJsonWriterFactory>; FString RootObjectStr; TSharedRef Writer = FStringWriterFactory::Create(&RootObjectStr); FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer); FFileHelper::SaveStringToFile(RootObjectStr, *GetPersistentFilePath()); } void FUncontrolledChangelistsModule::LoadState() { FString ImportJsonString; TSharedPtr RootObject; uint32 VersionNumber = 0; const TArray>* UncontrolledChangelistsArray = nullptr; if (!FFileHelper::LoadFileToString(ImportJsonString, *GetPersistentFilePath())) { return; } TSharedRef> JsonReader = TJsonReaderFactory<>::Create(ImportJsonString); if (!FJsonSerializer::Deserialize(JsonReader, RootObject)) { UE_LOG(LogSourceControl, Error, TEXT("Cannot deserialize RootObject.")); return; } if (!RootObject->TryGetNumberField(VERSION_NAME, VersionNumber)) { UE_LOG(LogSourceControl, Error, TEXT("Cannot get field %s."), VERSION_NAME); return; } if (VersionNumber > VERSION_NUMBER) { UE_LOG(LogSourceControl, Error, TEXT("Version number is invalid (file: %u, current: %u)."), VersionNumber, VERSION_NUMBER); return; } if (!RootObject->TryGetArrayField(CHANGELISTS_NAME, UncontrolledChangelistsArray)) { UE_LOG(LogSourceControl, Error, TEXT("Cannot get field %s."), CHANGELISTS_NAME); return; } for (const TSharedPtr& JsonValue : *UncontrolledChangelistsArray) { FUncontrolledChangelist TempKey; TSharedRef JsonObject = JsonValue->AsObject().ToSharedRef(); if (!TempKey.Deserialize(JsonObject)) { UE_LOG(LogSourceControl, Error, TEXT("Cannot deserialize FUncontrolledChangelist.")); continue; } FUncontrolledChangelistsStateCache::ValueType& UncontrolledChangelistState = UncontrolledChangelistsStateCache.FindOrAdd(MoveTemp(TempKey), MakeShared(TempKey)); UncontrolledChangelistState->Deserialize(JsonObject); } UE_LOG(LogSourceControl, Display, TEXT("Uncontrolled Changelist persistency file loaded %s"), *GetPersistentFilePath()); } FString FUncontrolledChangelistsModule::GetPersistentFilePath() const { return FPaths::ProjectSavedDir() + TEXT("SourceControl/UncontrolledChangelists.json"); } FString FUncontrolledChangelistsModule::GetUObjectPackageFullpath(const UObject* InObject) const { FString Fullpath = TEXT(""); if (InObject == nullptr) { return Fullpath; } const UPackage* Package = InObject->GetPackage(); if (Package == nullptr) { return Fullpath; } const FString LocalFullPath(Package->GetLoadedPath().GetLocalFullPath()); if (LocalFullPath.IsEmpty()) { return Fullpath; } Fullpath = FPaths::ConvertRelativePathToFull(LocalFullPath); return Fullpath; } IMPLEMENT_MODULE(FUncontrolledChangelistsModule, UncontrolledChangelists); #undef LOCTEXT_NAMESPACE