2512 lines
84 KiB
C++
2512 lines
84 KiB
C++
// Copyright (c) 2014-2020 Sebastien Rombauts (sebastien.rombauts@gmail.com)
|
|
//
|
|
// Distributed under the MIT License (MIT) (See accompanying file LICENSE.txt
|
|
// or copy at http://opensource.org/licenses/MIT)
|
|
|
|
#include "GitSourceControlUtils.h"
|
|
|
|
#include "GitMessageLog.h"
|
|
#include "GitSourceControlCommand.h"
|
|
#include "GitSourceControlModule.h"
|
|
#include "GitSourceControlProvider.h"
|
|
#include "HAL/PlatformProcess.h"
|
|
|
|
#include "HAL/PlatformFile.h"
|
|
#if ENGINE_MAJOR_VERSION >= 5
|
|
#include "HAL/PlatformFileManager.h"
|
|
#else
|
|
#include "HAL/PlatformFilemanager.h"
|
|
#endif
|
|
|
|
#include "HAL/PlatformProcess.h"
|
|
#include "Interfaces/IPluginManager.h"
|
|
#include "ISourceControlModule.h"
|
|
#include "Misc/FileHelper.h"
|
|
#include "Misc/Paths.h"
|
|
#include "ISourceControlModule.h"
|
|
#include "GitSourceControlModule.h"
|
|
#include "GitSourceControlChangelistState.h"
|
|
#include "Logging/MessageLog.h"
|
|
#include "Misc/DateTime.h"
|
|
#include "Misc/ScopeLock.h"
|
|
#include "Misc/Timespan.h"
|
|
|
|
#include "PackageTools.h"
|
|
#include "FileHelpers.h"
|
|
#include "Misc/MessageDialog.h"
|
|
#include "UObject/ObjectSaveContext.h"
|
|
|
|
#include "Async/Async.h"
|
|
#include "UObject/Linker.h"
|
|
|
|
#ifndef GIT_DEBUG_STATUS
|
|
#define GIT_DEBUG_STATUS 0
|
|
#endif
|
|
|
|
#define LOCTEXT_NAMESPACE "GitSourceControl"
|
|
|
|
namespace GitSourceControlConstants
|
|
{
|
|
/** The maximum number of files we submit in a single Git command */
|
|
const int32 MaxFilesPerBatch = 50;
|
|
} // namespace GitSourceControlConstants
|
|
|
|
FGitScopedTempFile::FGitScopedTempFile(const FText& InText)
|
|
{
|
|
Filename = FPaths::CreateTempFilename(*FPaths::ProjectLogDir(), TEXT("Git-Temp"), TEXT(".txt"));
|
|
if (!FFileHelper::SaveStringToFile(InText.ToString(), *Filename, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("Failed to write to temp file: %s"), *Filename);
|
|
}
|
|
}
|
|
|
|
FGitScopedTempFile::~FGitScopedTempFile()
|
|
{
|
|
if (FPaths::FileExists(Filename))
|
|
{
|
|
if (!FPlatformFileManager::Get().GetPlatformFile().DeleteFile(*Filename))
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("Failed to delete temp file: %s"), *Filename);
|
|
}
|
|
}
|
|
}
|
|
|
|
const FString& FGitScopedTempFile::GetFilename() const
|
|
{
|
|
return Filename;
|
|
}
|
|
|
|
FDateTime FGitLockedFilesCache::LastUpdated = FDateTime::MinValue();
|
|
TMap<FString, FString> FGitLockedFilesCache::LockedFiles = TMap<FString, FString>();
|
|
|
|
void FGitLockedFilesCache::SetLockedFiles(const TMap<FString, FString>& newLocks)
|
|
{
|
|
for (auto lock : LockedFiles)
|
|
{
|
|
if (!newLocks.Contains(lock.Key))
|
|
{
|
|
OnFileLockChanged(lock.Key, lock.Value, false);
|
|
}
|
|
}
|
|
|
|
for (auto lock : newLocks)
|
|
{
|
|
if (!LockedFiles.Contains(lock.Key))
|
|
{
|
|
OnFileLockChanged(lock.Key, lock.Value, true);
|
|
}
|
|
}
|
|
|
|
LockedFiles = newLocks;
|
|
}
|
|
|
|
void FGitLockedFilesCache::AddLockedFile(const FString& filePath, const FString& lockUser)
|
|
{
|
|
LockedFiles.Add(filePath, lockUser);
|
|
OnFileLockChanged(filePath, lockUser, true);
|
|
}
|
|
|
|
void FGitLockedFilesCache::RemoveLockedFile(const FString& filePath)
|
|
{
|
|
FString user;
|
|
LockedFiles.RemoveAndCopyValue(filePath, user);
|
|
OnFileLockChanged(filePath, user, false);
|
|
}
|
|
|
|
void FGitLockedFilesCache::OnFileLockChanged(const FString& filePath, const FString& lockUser, bool locked)
|
|
{
|
|
const FString& LfsUserName = FGitSourceControlModule::Get().GetProvider().GetLockUser();
|
|
if (LfsUserName == lockUser)
|
|
{
|
|
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*filePath, !locked);
|
|
}
|
|
}
|
|
|
|
namespace GitSourceControlUtils
|
|
{
|
|
FString ChangeRepositoryRootIfSubmodule(const TArray<FString>& AbsoluteFilePaths, const FString& PathToRepositoryRoot)
|
|
{
|
|
FString Ret = PathToRepositoryRoot;
|
|
// note this is not going to support operations where selected files are in different repositories
|
|
|
|
for (auto& FilePath : AbsoluteFilePaths)
|
|
{
|
|
FString TestPath = FilePath;
|
|
while (!FPaths::IsSamePath(TestPath, PathToRepositoryRoot))
|
|
{
|
|
// Iterating over path directories, looking for .git
|
|
TestPath = FPaths::GetPath(TestPath);
|
|
|
|
if (TestPath.IsEmpty())
|
|
{
|
|
// early out if empty directory string to prevent infinite loop
|
|
UE_LOG(LogSourceControl, Error, TEXT("Can't find directory path for file :%s"), *FilePath);
|
|
break;
|
|
}
|
|
|
|
FString GitTestPath = TestPath + "/.git";
|
|
if (FPaths::FileExists(GitTestPath) || FPaths::DirectoryExists(GitTestPath))
|
|
{
|
|
FString RetNormalized = Ret;
|
|
FPaths::NormalizeDirectoryName(RetNormalized);
|
|
FString PathToRepositoryRootNormalized = PathToRepositoryRoot;
|
|
FPaths::NormalizeDirectoryName(PathToRepositoryRootNormalized);
|
|
if (!FPaths::IsSamePath(RetNormalized, PathToRepositoryRootNormalized) && Ret != GitTestPath)
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("Selected files belong to different submodules"));
|
|
return PathToRepositoryRoot;
|
|
}
|
|
Ret = TestPath;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return Ret;
|
|
}
|
|
|
|
FString ChangeRepositoryRootIfSubmodule(const FString& AbsoluteFilePath, const FString& PathToRepositoryRoot)
|
|
{
|
|
TArray<FString> AbsoluteFilePaths = { AbsoluteFilePath };
|
|
return ChangeRepositoryRootIfSubmodule(AbsoluteFilePaths, PathToRepositoryRoot);
|
|
}
|
|
|
|
// Launch the Git command line process and extract its results & errors
|
|
bool RunCommandInternalRaw(const FString& InCommand, const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& InParameters, const TArray<FString>& InFiles, FString& OutResults, FString& OutErrors, const int32 ExpectedReturnCode /* = 0 */)
|
|
{
|
|
int32 ReturnCode = 0;
|
|
FString FullCommand;
|
|
FString LogableCommand; // short version of the command for logging purpose
|
|
|
|
if (!InRepositoryRoot.IsEmpty())
|
|
{
|
|
FString RepositoryRoot = InRepositoryRoot;
|
|
|
|
// Detect a "migrate asset" scenario (a "git add" command is applied to files outside the current project)
|
|
if ((InFiles.Num() > 0) && !FPaths::IsRelative(InFiles[0]) && !InFiles[0].StartsWith(InRepositoryRoot))
|
|
{
|
|
// in this case, find the git repository (if any) of the destination Project
|
|
FString DestinationRepositoryRoot;
|
|
if (FindRootDirectory(FPaths::GetPath(InFiles[0]), DestinationRepositoryRoot))
|
|
{
|
|
RepositoryRoot = DestinationRepositoryRoot; // if found use it for the "add" command (else not, to avoid producing one more error in logs)
|
|
}
|
|
}
|
|
|
|
// Specify the working copy (the root) of the git repository (before the command itself)
|
|
FullCommand = TEXT("-C \"");
|
|
FullCommand += RepositoryRoot;
|
|
FullCommand += TEXT("\" ");
|
|
}
|
|
// then the git command itself ("status", "log", "commit"...)
|
|
LogableCommand += InCommand;
|
|
|
|
// Append to the command all parameters, and then finally the files
|
|
for (const auto& Parameter : InParameters)
|
|
{
|
|
LogableCommand += TEXT(" ");
|
|
LogableCommand += Parameter;
|
|
}
|
|
for (const auto& File : InFiles)
|
|
{
|
|
LogableCommand += TEXT(" \"");
|
|
LogableCommand += File;
|
|
LogableCommand += TEXT("\"");
|
|
}
|
|
// Also, Git does not have a "--non-interactive" option, as it auto-detects when there are no connected standard input/output streams
|
|
|
|
FullCommand += LogableCommand;
|
|
|
|
#if UE_BUILD_DEBUG
|
|
UE_LOG(LogSourceControl, Log, TEXT("RunCommand: 'git %s'"), *LogableCommand);
|
|
#endif
|
|
|
|
FString PathToGitOrEnvBinary = InPathToGitBinary;
|
|
#if PLATFORM_MAC
|
|
// The Cocoa application does not inherit shell environment variables, so add the path expected to have git-lfs to PATH
|
|
FString PathEnv = FPlatformMisc::GetEnvironmentVariable(TEXT("PATH"));
|
|
FString GitInstallPath = FPaths::GetPath(InPathToGitBinary);
|
|
|
|
TArray<FString> PathArray;
|
|
PathEnv.ParseIntoArray(PathArray, FPlatformMisc::GetPathVarDelimiter());
|
|
bool bHasGitInstallPath = false;
|
|
for (auto Path : PathArray)
|
|
{
|
|
if (GitInstallPath.Equals(Path, ESearchCase::CaseSensitive))
|
|
{
|
|
bHasGitInstallPath = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!bHasGitInstallPath)
|
|
{
|
|
PathToGitOrEnvBinary = FString("/usr/bin/env");
|
|
FullCommand = FString::Printf(TEXT("PATH=\"%s%s%s\" \"%s\" %s"), *GitInstallPath, FPlatformMisc::GetPathVarDelimiter(), *PathEnv, *InPathToGitBinary, *FullCommand);
|
|
}
|
|
#endif
|
|
|
|
FPlatformProcess::ExecProcess(*PathToGitOrEnvBinary, *FullCommand, &ReturnCode, &OutResults, &OutErrors);
|
|
|
|
#if UE_BUILD_DEBUG
|
|
// TODO: add a setting to easily enable Verbose logging
|
|
UE_LOG(LogSourceControl, Verbose, TEXT("RunCommand(%s):\n%s"), *InCommand, *OutResults);
|
|
if (ReturnCode != ExpectedReturnCode)
|
|
{
|
|
UE_LOG(LogSourceControl, Warning, TEXT("RunCommand(%s) ReturnCode=%d:\n%s"), *InCommand, ReturnCode, *OutErrors);
|
|
}
|
|
#endif
|
|
|
|
// Move push/pull progress information from the error stream to the info stream
|
|
if(ReturnCode == ExpectedReturnCode && OutErrors.Len() > 0)
|
|
{
|
|
OutResults.Append(OutErrors);
|
|
OutErrors.Empty();
|
|
}
|
|
|
|
return ReturnCode == ExpectedReturnCode;
|
|
}
|
|
|
|
// Basic parsing or results & errors from the Git command line process
|
|
static bool RunCommandInternal(const FString& InCommand, const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& InParameters,
|
|
const TArray<FString>& InFiles, TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
bool bResult;
|
|
FString Results;
|
|
FString Errors;
|
|
|
|
bResult = RunCommandInternalRaw(InCommand, InPathToGitBinary, InRepositoryRoot, InParameters, InFiles, Results, Errors);
|
|
Results.ParseIntoArray(OutResults, TEXT("\n"), true);
|
|
Errors.ParseIntoArray(OutErrorMessages, TEXT("\n"), true);
|
|
|
|
return bResult;
|
|
}
|
|
|
|
FString FindGitBinaryPath()
|
|
{
|
|
#if PLATFORM_WINDOWS
|
|
// 1) First of all, look into standard install directories
|
|
// NOTE using only "git" (or "git.exe") relying on the "PATH" envvar does not always work as expected, depending on the installation:
|
|
// If the PATH is set with "git/cmd" instead of "git/bin",
|
|
// "git.exe" launch "git/cmd/git.exe" that redirect to "git/bin/git.exe" and ExecProcess() is unable to catch its outputs streams.
|
|
// First check the 64-bit program files directory:
|
|
FString GitBinaryPath(TEXT("C:/Program Files/Git/bin/git.exe"));
|
|
bool bFound = CheckGitAvailability(GitBinaryPath);
|
|
if (!bFound)
|
|
{
|
|
// otherwise check the 32-bit program files directory.
|
|
GitBinaryPath = TEXT("C:/Program Files (x86)/Git/bin/git.exe");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
if (!bFound)
|
|
{
|
|
// else the install dir for the current user: C:\Users\UserName\AppData\Local\Programs\Git\cmd
|
|
const FString AppDataLocalPath = FPlatformMisc::GetEnvironmentVariable(TEXT("LOCALAPPDATA"));
|
|
GitBinaryPath = FString::Printf(TEXT("%s/Programs/Git/cmd/git.exe"), *AppDataLocalPath);
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
// 2) Else, look for the version of Git bundled with SmartGit "Installer with JRE"
|
|
if (!bFound)
|
|
{
|
|
GitBinaryPath = TEXT("C:/Program Files (x86)/SmartGit/git/bin/git.exe");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
if (!bFound)
|
|
{
|
|
// If git is not found in "git/bin/" subdirectory, try the "bin/" path that was in use before
|
|
GitBinaryPath = TEXT("C:/Program Files (x86)/SmartGit/bin/git.exe");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
|
|
// 3) Else, look for the local_git provided by SourceTree
|
|
if (!bFound)
|
|
{
|
|
// C:\Users\UserName\AppData\Local\Atlassian\SourceTree\git_local\bin
|
|
const FString AppDataLocalPath = FPlatformMisc::GetEnvironmentVariable(TEXT("LOCALAPPDATA"));
|
|
GitBinaryPath = FString::Printf(TEXT("%s/Atlassian/SourceTree/git_local/bin/git.exe"), *AppDataLocalPath);
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
// 4) Else, look for the PortableGit provided by GitHub Desktop
|
|
if (!bFound)
|
|
{
|
|
// The latest GitHub Desktop adds its binaries into the local appdata directory:
|
|
// C:\Users\UserName\AppData\Local\GitHub\PortableGit_c2ba306e536fdf878271f7fe636a147ff37326ad\cmd
|
|
const FString AppDataLocalPath = FPlatformMisc::GetEnvironmentVariable(TEXT("LOCALAPPDATA"));
|
|
const FString SearchPath = FString::Printf(TEXT("%s/GitHub/PortableGit_*"), *AppDataLocalPath);
|
|
TArray<FString> PortableGitFolders;
|
|
IFileManager::Get().FindFiles(PortableGitFolders, *SearchPath, false, true);
|
|
if (PortableGitFolders.Num() > 0)
|
|
{
|
|
// FindFiles just returns directory names, so we need to prepend the root path to get the full path.
|
|
GitBinaryPath = FString::Printf(TEXT("%s/GitHub/%s/cmd/git.exe"), *AppDataLocalPath, *(PortableGitFolders.Last())); // keep only the last PortableGit found
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
if (!bFound)
|
|
{
|
|
// If Portable git is not found in "cmd/" subdirectory, try the "bin/" path that was in use before
|
|
GitBinaryPath = FString::Printf(TEXT("%s/GitHub/%s/bin/git.exe"), *AppDataLocalPath, *(PortableGitFolders.Last())); // keep only the last
|
|
// PortableGit found
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5) Else, look for the version of Git bundled with Tower
|
|
if (!bFound)
|
|
{
|
|
GitBinaryPath = TEXT("C:/Program Files (x86)/fournova/Tower/vendor/Git/bin/git.exe");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
// 6) Else, look for the PortableGit provided by Fork
|
|
if (!bFound)
|
|
{
|
|
// The latest Fork adds its binaries into the local appdata directory:
|
|
// C:\Users\UserName\AppData\Local\Fork\gitInstance\2.39.1\cmd
|
|
const FString AppDataLocalPath = FPlatformMisc::GetEnvironmentVariable(TEXT("LOCALAPPDATA"));
|
|
const FString SearchPath = FString::Printf(TEXT("%s/Fork/gitInstance/*"), *AppDataLocalPath);
|
|
TArray<FString> PortableGitFolders;
|
|
IFileManager::Get().FindFiles(PortableGitFolders, *SearchPath, false, true);
|
|
if (PortableGitFolders.Num() > 0)
|
|
{
|
|
// FindFiles just returns directory names, so we need to prepend the root path to get the full path.
|
|
GitBinaryPath = FString::Printf(TEXT("%s/Fork/gitInstance/%s/cmd/git.exe"), *AppDataLocalPath, *(PortableGitFolders.Last())); // keep only the last PortableGit found
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
if (!bFound)
|
|
{
|
|
// If Portable git is not found in "cmd/" subdirectory, try the "bin/" path that was in use before
|
|
GitBinaryPath = FString::Printf(TEXT("%s/Fork/gitInstance/%s/bin/git.exe"), *AppDataLocalPath, *(PortableGitFolders.Last())); // keep only the last
|
|
// PortableGit found
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
#elif PLATFORM_MAC
|
|
// 1) First of all, look for the version of git provided by official git
|
|
FString GitBinaryPath = TEXT("/usr/local/git/bin/git");
|
|
bool bFound = CheckGitAvailability(GitBinaryPath);
|
|
|
|
// 2) Else, look for the version of git provided by Homebrew
|
|
if (!bFound)
|
|
{
|
|
GitBinaryPath = TEXT("/usr/local/bin/git");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
// 3) Else, look for the version of git provided by MacPorts
|
|
if (!bFound)
|
|
{
|
|
GitBinaryPath = TEXT("/opt/local/bin/git");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
// 4) Else, look for the version of git provided by Command Line Tools
|
|
if (!bFound)
|
|
{
|
|
GitBinaryPath = TEXT("/usr/bin/git");
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
|
|
{
|
|
SCOPED_AUTORELEASE_POOL;
|
|
NSWorkspace* SharedWorkspace = [NSWorkspace sharedWorkspace];
|
|
|
|
// 5) Else, look for the version of local_git provided by SmartGit
|
|
if (!bFound)
|
|
{
|
|
NSURL* AppURL = [SharedWorkspace URLForApplicationWithBundleIdentifier:@"com.syntevo.smartgit"];
|
|
if (AppURL != nullptr)
|
|
{
|
|
NSBundle* Bundle = [NSBundle bundleWithURL:AppURL];
|
|
GitBinaryPath = FString::Printf(TEXT("%s/git/bin/git"), *FString([Bundle resourcePath]));
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
|
|
// 6) Else, look for the version of local_git provided by SourceTree
|
|
if (!bFound)
|
|
{
|
|
NSURL* AppURL = [SharedWorkspace URLForApplicationWithBundleIdentifier:@"com.torusknot.SourceTreeNotMAS"];
|
|
if (AppURL != nullptr)
|
|
{
|
|
NSBundle* Bundle = [NSBundle bundleWithURL:AppURL];
|
|
GitBinaryPath = FString::Printf(TEXT("%s/git_local/bin/git"), *FString([Bundle resourcePath]));
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
|
|
// 7) Else, look for the version of local_git provided by GitHub Desktop
|
|
if (!bFound)
|
|
{
|
|
NSURL* AppURL = [SharedWorkspace URLForApplicationWithBundleIdentifier:@"com.github.GitHubClient"];
|
|
if (AppURL != nullptr)
|
|
{
|
|
NSBundle* Bundle = [NSBundle bundleWithURL:AppURL];
|
|
GitBinaryPath = FString::Printf(TEXT("%s/app/git/bin/git"), *FString([Bundle resourcePath]));
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
|
|
// 8) Else, look for the version of local_git provided by Tower2
|
|
if (!bFound)
|
|
{
|
|
NSURL* AppURL = [SharedWorkspace URLForApplicationWithBundleIdentifier:@"com.fournova.Tower2"];
|
|
if (AppURL != nullptr)
|
|
{
|
|
NSBundle* Bundle = [NSBundle bundleWithURL:AppURL];
|
|
GitBinaryPath = FString::Printf(TEXT("%s/git/bin/git"), *FString([Bundle resourcePath]));
|
|
bFound = CheckGitAvailability(GitBinaryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
#else
|
|
FString GitBinaryPath = TEXT("/usr/bin/git");
|
|
bool bFound = CheckGitAvailability(GitBinaryPath);
|
|
#endif
|
|
|
|
if (bFound)
|
|
{
|
|
FPaths::MakePlatformFilename(GitBinaryPath);
|
|
}
|
|
else
|
|
{
|
|
// If we did not find a path to Git, set it empty
|
|
GitBinaryPath.Empty();
|
|
}
|
|
|
|
return GitBinaryPath;
|
|
}
|
|
|
|
bool CheckGitAvailability(const FString& InPathToGitBinary, FGitVersion* OutVersion)
|
|
{
|
|
FString InfoMessages;
|
|
FString ErrorMessages;
|
|
bool bGitAvailable = RunCommandInternalRaw(TEXT("version"), InPathToGitBinary, FString(), FGitSourceControlModule::GetEmptyStringArray(), FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bGitAvailable)
|
|
{
|
|
if (!InfoMessages.StartsWith("git version"))
|
|
{
|
|
bGitAvailable = false;
|
|
}
|
|
else if (OutVersion)
|
|
{
|
|
ParseGitVersion(InfoMessages, OutVersion);
|
|
}
|
|
}
|
|
|
|
return bGitAvailable;
|
|
}
|
|
|
|
void ParseGitVersion(const FString& InVersionString, FGitVersion* OutVersion)
|
|
{
|
|
#if UE_BUILD_DEBUG
|
|
// Parse "git version 2.31.1.vfs.0.3" into the string "2.31.1.vfs.0.3"
|
|
const FString& TokenVersionStringPtr = InVersionString.RightChop(12);
|
|
if (!TokenVersionStringPtr.IsEmpty())
|
|
{
|
|
// Parse the version into its numerical components
|
|
TArray<FString> ParsedVersionString;
|
|
TokenVersionStringPtr.ParseIntoArray(ParsedVersionString, TEXT("."));
|
|
const int Num = ParsedVersionString.Num();
|
|
if (Num >= 3)
|
|
{
|
|
if (ParsedVersionString[0].IsNumeric() && ParsedVersionString[1].IsNumeric() && ParsedVersionString[2].IsNumeric())
|
|
{
|
|
OutVersion->Major = FCString::Atoi(*ParsedVersionString[0]);
|
|
OutVersion->Minor = FCString::Atoi(*ParsedVersionString[1]);
|
|
OutVersion->Patch = FCString::Atoi(*ParsedVersionString[2]);
|
|
if (Num >= 5)
|
|
{
|
|
// If labeled with fork
|
|
if (!ParsedVersionString[3].IsNumeric())
|
|
{
|
|
OutVersion->Fork = ParsedVersionString[3];
|
|
OutVersion->bIsFork = true;
|
|
OutVersion->ForkMajor = FCString::Atoi(*ParsedVersionString[4]);
|
|
if (Num >= 6)
|
|
{
|
|
OutVersion->ForkMinor = FCString::Atoi(*ParsedVersionString[5]);
|
|
if (Num >= 7)
|
|
{
|
|
OutVersion->ForkPatch = FCString::Atoi(*ParsedVersionString[6]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (OutVersion->bIsFork)
|
|
{
|
|
UE_LOG(LogSourceControl, Log, TEXT("Git version %d.%d.%d.%s.%d.%d.%d"), OutVersion->Major, OutVersion->Minor, OutVersion->Patch, *OutVersion->Fork, OutVersion->ForkMajor, OutVersion->ForkMinor, OutVersion->ForkPatch);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogSourceControl, Log, TEXT("Git version %d.%d.%d"), OutVersion->Major, OutVersion->Minor, OutVersion->Patch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Find the root of the Git repository, looking from the provided path and upward in its parent directories.
|
|
bool FindRootDirectory(const FString& InPath, FString& OutRepositoryRoot)
|
|
{
|
|
OutRepositoryRoot = InPath;
|
|
|
|
auto TrimTrailing = [](FString& Str, const TCHAR Char) {
|
|
int32 Len = Str.Len();
|
|
while (Len && Str[Len - 1] == Char)
|
|
{
|
|
Str = Str.LeftChop(1);
|
|
Len = Str.Len();
|
|
}
|
|
};
|
|
|
|
TrimTrailing(OutRepositoryRoot, '\\');
|
|
TrimTrailing(OutRepositoryRoot, '/');
|
|
|
|
bool bFound = false;
|
|
FString PathToGitSubdirectory;
|
|
while (!bFound && !OutRepositoryRoot.IsEmpty())
|
|
{
|
|
// Look for the ".git" subdirectory (or file) present at the root of every Git repository
|
|
PathToGitSubdirectory = OutRepositoryRoot / TEXT(".git");
|
|
bFound = IFileManager::Get().DirectoryExists(*PathToGitSubdirectory) || IFileManager::Get().FileExists(*PathToGitSubdirectory);
|
|
if (!bFound)
|
|
{
|
|
int32 LastSlashIndex;
|
|
if (OutRepositoryRoot.FindLastChar('/', LastSlashIndex))
|
|
{
|
|
OutRepositoryRoot = OutRepositoryRoot.Left(LastSlashIndex);
|
|
}
|
|
else
|
|
{
|
|
OutRepositoryRoot.Empty();
|
|
}
|
|
}
|
|
}
|
|
if (!bFound)
|
|
{
|
|
OutRepositoryRoot = InPath; // If not found, return the provided dir as best possible root.
|
|
}
|
|
return bFound;
|
|
}
|
|
|
|
void GetUserConfig(const FString& InPathToGitBinary, const FString& InRepositoryRoot, FString& OutUserName, FString& OutUserEmail)
|
|
{
|
|
bool bResults;
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("user.name"));
|
|
bResults = RunCommandInternal(TEXT("config"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutUserName = InfoMessages[0];
|
|
}
|
|
else
|
|
{
|
|
OutUserName = TEXT("");
|
|
}
|
|
|
|
Parameters.Reset(1);
|
|
Parameters.Add(TEXT("user.email"));
|
|
InfoMessages.Reset();
|
|
bResults &= RunCommandInternal(TEXT("config"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutUserEmail = InfoMessages[0];
|
|
}
|
|
else
|
|
{
|
|
OutUserEmail = TEXT("");
|
|
}
|
|
}
|
|
|
|
bool GetBranchName(const FString& InPathToGitBinary, const FString& InRepositoryRoot, FString& OutBranchName)
|
|
{
|
|
const FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
return false;
|
|
}
|
|
const FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
if (!Provider.GetBranchName().IsEmpty())
|
|
{
|
|
OutBranchName = Provider.GetBranchName();
|
|
return true;
|
|
}
|
|
|
|
bool bResults;
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--short"));
|
|
Parameters.Add(TEXT("--quiet")); // no error message while in detached HEAD
|
|
Parameters.Add(TEXT("HEAD"));
|
|
bResults = RunCommand(TEXT("symbolic-ref"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutBranchName = InfoMessages[0];
|
|
}
|
|
else
|
|
{
|
|
Parameters.Reset(2);
|
|
Parameters.Add(TEXT("-1"));
|
|
Parameters.Add(TEXT("--format=\"%h\"")); // no error message while in detached HEAD
|
|
bResults = RunCommand(TEXT("log"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutBranchName = "HEAD detached at ";
|
|
OutBranchName += InfoMessages[0];
|
|
}
|
|
else
|
|
{
|
|
bResults = false;
|
|
}
|
|
}
|
|
|
|
return bResults;
|
|
}
|
|
|
|
bool GetRemoteBranchName(const FString& InPathToGitBinary, const FString& InRepositoryRoot, FString& OutBranchName)
|
|
{
|
|
const FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
return false;
|
|
}
|
|
const FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
if (!Provider.GetRemoteBranchName().IsEmpty())
|
|
{
|
|
OutBranchName = Provider.GetRemoteBranchName();
|
|
return true;
|
|
}
|
|
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--abbrev-ref"));
|
|
Parameters.Add(TEXT("--symbolic-full-name"));
|
|
Parameters.Add(TEXT("@{u}"));
|
|
bool bResults = RunCommand(TEXT("rev-parse"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(),
|
|
InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutBranchName = InfoMessages[0];
|
|
}
|
|
if (!bResults)
|
|
{
|
|
static bool bRunOnce = true;
|
|
if (bRunOnce)
|
|
{
|
|
UE_LOG(LogSourceControl, Warning, TEXT("Upstream branch not found for the current branch, skipping current branch for remote check. Please push a remote branch."));
|
|
bRunOnce = false;
|
|
}
|
|
}
|
|
return bResults;
|
|
}
|
|
|
|
bool GetRemoteBranchesWildcard(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const FString& PatternMatch, TArray<FString>& OutBranchNames)
|
|
{
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--remotes"));
|
|
Parameters.Add(TEXT("--list"));
|
|
bool bResults = RunCommand(TEXT("branch"), InPathToGitBinary, InRepositoryRoot, Parameters, { PatternMatch },
|
|
InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutBranchNames = InfoMessages;
|
|
}
|
|
if (!bResults)
|
|
{
|
|
static bool bRunOnce = true;
|
|
if (bRunOnce)
|
|
{
|
|
UE_LOG(LogSourceControl, Warning, TEXT("No remote branches matching pattern \"%s\" were found."), *PatternMatch);
|
|
bRunOnce = false;
|
|
}
|
|
}
|
|
return bResults;
|
|
}
|
|
|
|
bool GetCommitInfo(const FString& InPathToGitBinary, const FString& InRepositoryRoot, FString& OutCommitId, FString& OutCommitSummary)
|
|
{
|
|
bool bResults;
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("-1"));
|
|
Parameters.Add(TEXT("--format=\"%H %s\""));
|
|
bResults = RunCommandInternal(TEXT("log"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutCommitId = InfoMessages[0].Left(40);
|
|
OutCommitSummary = InfoMessages[0].RightChop(41);
|
|
}
|
|
|
|
return bResults;
|
|
}
|
|
|
|
bool GetRemoteUrl(const FString& InPathToGitBinary, const FString& InRepositoryRoot, FString& OutRemoteUrl)
|
|
{
|
|
TArray<FString> InfoMessages;
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("get-url"));
|
|
Parameters.Add(TEXT("origin"));
|
|
const bool bResults = RunCommandInternal(TEXT("remote"), InPathToGitBinary, InRepositoryRoot, Parameters, FGitSourceControlModule::GetEmptyStringArray(), InfoMessages, ErrorMessages);
|
|
if (bResults && InfoMessages.Num() > 0)
|
|
{
|
|
OutRemoteUrl = InfoMessages[0];
|
|
}
|
|
|
|
return bResults;
|
|
}
|
|
|
|
bool RunCommand(const FString& InCommand, const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& InParameters,
|
|
const TArray<FString>& InFiles, TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
bool bResult = true;
|
|
|
|
if (InFiles.Num() > GitSourceControlConstants::MaxFilesPerBatch)
|
|
{
|
|
// Batch files up so we dont exceed command-line limits
|
|
int32 FileCount = 0;
|
|
while (FileCount < InFiles.Num())
|
|
{
|
|
TArray<FString> FilesInBatch;
|
|
for (int32 FileIndex = 0; FileCount < InFiles.Num() && FileIndex < GitSourceControlConstants::MaxFilesPerBatch; FileIndex++, FileCount++)
|
|
{
|
|
FilesInBatch.Add(InFiles[FileCount]);
|
|
}
|
|
|
|
TArray<FString> BatchResults;
|
|
TArray<FString> BatchErrors;
|
|
bResult &= RunCommandInternal(InCommand, InPathToGitBinary, InRepositoryRoot, InParameters, FilesInBatch, BatchResults, BatchErrors);
|
|
OutResults += BatchResults;
|
|
OutErrorMessages += BatchErrors;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
bResult = RunCommandInternal(InCommand, InPathToGitBinary, InRepositoryRoot, InParameters, InFiles, OutResults, OutErrorMessages);
|
|
}
|
|
|
|
return bResult;
|
|
}
|
|
|
|
#ifndef GIT_USE_CUSTOM_LFS
|
|
#define GIT_USE_CUSTOM_LFS 1
|
|
#endif
|
|
|
|
bool RunLFSCommand(const FString& InCommand, const FString& InRepositoryRoot, const FString& GitBinaryFallback, const TArray<FString>& InParameters, const TArray<FString>& InFiles,
|
|
TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
FString Command = InCommand;
|
|
#if GIT_USE_CUSTOM_LFS
|
|
FString BaseDir = IPluginManager::Get().FindPlugin("GitSourceControl")->GetBaseDir();
|
|
#if PLATFORM_WINDOWS
|
|
FString LFSLockBinary = FString::Printf(TEXT("%s/git-lfs.exe"), *BaseDir);
|
|
#elif PLATFORM_MAC
|
|
#if ENGINE_MAJOR_VERSION >= 5
|
|
#if PLATFORM_MAC_ARM64
|
|
FString LFSLockBinary = FString::Printf(TEXT("%s/git-lfs-mac-arm64"), *BaseDir);
|
|
#else
|
|
FString LFSLockBinary = FString::Printf(TEXT("%s/git-lfs-mac-amd64"), *BaseDir);
|
|
#endif
|
|
#else
|
|
FString LFSLockBinary = FString::Printf(TEXT("%s/git-lfs-mac-amd64"), *BaseDir);
|
|
#endif
|
|
#elif PLATFORM_LINUX
|
|
FString LFSLockBinary = FString::Printf(TEXT("%s/git-lfs"), *BaseDir);
|
|
#else
|
|
ensureMsgf(false, TEXT("Unhandled platform for LFS binary!"));
|
|
const FString& LFSLockBinary = GitBinaryFallback;
|
|
Command = TEXT("lfs ") + Command;
|
|
#endif
|
|
#else
|
|
const FString& LFSLockBinary = GitBinaryFallback;
|
|
Command = TEXT("lfs ") + Command;
|
|
#endif
|
|
|
|
return GitSourceControlUtils::RunCommand(Command, LFSLockBinary, InRepositoryRoot, InParameters, InFiles, OutResults, OutErrorMessages);
|
|
}
|
|
|
|
// Run a Git "commit" command by batches
|
|
bool RunCommit(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& InParameters, const TArray<FString>& InFiles,
|
|
TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
bool bResult = true;
|
|
|
|
TArray<FString> AddParameters{TEXT("-A")};
|
|
|
|
if (InFiles.Num() > GitSourceControlConstants::MaxFilesPerBatch)
|
|
{
|
|
// Batch files up so we dont exceed command-line limits
|
|
int32 FileCount = 0;
|
|
{
|
|
TArray<FString> FilesInBatch;
|
|
for (int32 FileIndex = 0; FileIndex < GitSourceControlConstants::MaxFilesPerBatch; FileIndex++, FileCount++)
|
|
{
|
|
FilesInBatch.Add(InFiles[FileCount]);
|
|
}
|
|
bResult &= RunCommandInternal(TEXT("add"), InPathToGitBinary, InRepositoryRoot, AddParameters, FilesInBatch, OutResults, OutErrorMessages);
|
|
// First batch is a simple "git commit" command with only the first files
|
|
bResult &= RunCommandInternal(TEXT("commit"), InPathToGitBinary, InRepositoryRoot, InParameters, FilesInBatch, OutResults, OutErrorMessages);
|
|
}
|
|
|
|
TArray<FString> Parameters;
|
|
for (const auto& Parameter : InParameters)
|
|
{
|
|
Parameters.Add(Parameter);
|
|
}
|
|
Parameters.Add(TEXT("--amend"));
|
|
|
|
while (FileCount < InFiles.Num())
|
|
{
|
|
TArray<FString> FilesInBatch;
|
|
for (int32 FileIndex = 0; FileCount < InFiles.Num() && FileIndex < GitSourceControlConstants::MaxFilesPerBatch; FileIndex++, FileCount++)
|
|
{
|
|
FilesInBatch.Add(InFiles[FileCount]);
|
|
}
|
|
// Next batches "amend" the commit with some more files
|
|
TArray<FString> BatchResults;
|
|
TArray<FString> BatchErrors;
|
|
bResult &= RunCommandInternal(TEXT("add"), InPathToGitBinary, InRepositoryRoot, AddParameters, FilesInBatch, OutResults, OutErrorMessages);
|
|
bResult &= RunCommandInternal(TEXT("commit"), InPathToGitBinary, InRepositoryRoot, Parameters, FilesInBatch, BatchResults, BatchErrors);
|
|
OutResults += BatchResults;
|
|
OutErrorMessages += BatchErrors;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
bResult &= RunCommandInternal(TEXT("add"), InPathToGitBinary, InRepositoryRoot, AddParameters, InFiles, OutResults, OutErrorMessages);
|
|
bResult = RunCommandInternal(TEXT("commit"), InPathToGitBinary, InRepositoryRoot, InParameters, InFiles, OutResults, OutErrorMessages);
|
|
}
|
|
|
|
return bResult;
|
|
}
|
|
|
|
/**
|
|
* Parse informations on a file locked with Git LFS
|
|
*
|
|
* Examples output of "git lfs locks":
|
|
Content\ThirdPersonBP\Blueprints\ThirdPersonCharacter.uasset SRombauts ID:891
|
|
Content\ThirdPersonBP\Blueprints\ThirdPersonCharacter.uasset ID:891
|
|
Content\ThirdPersonBP\Blueprints\ThirdPersonCharacter.uasset ID:891
|
|
*/
|
|
class FGitLfsLocksParser
|
|
{
|
|
public:
|
|
FGitLfsLocksParser(const FString& InRepositoryRoot, const FString& InStatus, const bool bAbsolutePaths = true)
|
|
{
|
|
TArray<FString> Informations;
|
|
InStatus.ParseIntoArray(Informations, TEXT("\t"), true);
|
|
|
|
if (Informations.Num() >= 2)
|
|
{
|
|
Informations[0].TrimEndInline(); // Trim whitespace from the end of the filename
|
|
Informations[1].TrimEndInline(); // Trim whitespace from the end of the username
|
|
if (bAbsolutePaths)
|
|
LocalFilename = FPaths::ConvertRelativePathToFull(InRepositoryRoot, Informations[0]);
|
|
else
|
|
LocalFilename = Informations[0];
|
|
// Filename ID (or we expect it to be the username, but it's empty, or is the ID, we have to assume it's the current user)
|
|
if (Informations.Num() == 2 || Informations[1].IsEmpty() || Informations[1].StartsWith(TEXT("ID:")))
|
|
{
|
|
// TODO: thread safety
|
|
LockUser = FGitSourceControlModule::Get().GetProvider().GetLockUser();
|
|
}
|
|
// Filename Username ID
|
|
else
|
|
{
|
|
LockUser = MoveTemp(Informations[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filename on disk
|
|
FString LocalFilename;
|
|
// Name of user who has file locked
|
|
FString LockUser;
|
|
};
|
|
|
|
/**
|
|
* @brief Extract the relative filename from a Git status result.
|
|
*
|
|
* Examples of status results:
|
|
M Content/Textures/T_Perlin_Noise_M.uasset
|
|
R Content/Textures/T_Perlin_Noise_M.uasset -> Content/Textures/T_Perlin_Noise_M2.uasset
|
|
?? Content/Materials/M_Basic_Wall.uasset
|
|
!! BasicCode.sln
|
|
*
|
|
* @param[in] InResult One line of status
|
|
* @return Relative filename extracted from the line of status
|
|
*
|
|
* @see FGitStatusFileMatcher and StateFromGitStatus()
|
|
*/
|
|
static FString FilenameFromGitStatus(const FString& InResult)
|
|
{
|
|
int32 RenameIndex;
|
|
if (InResult.FindLastChar('>', RenameIndex))
|
|
{
|
|
// Extract only the second part of a rename "from -> to"
|
|
return InResult.RightChop(RenameIndex + 2);
|
|
}
|
|
else
|
|
{
|
|
// Extract the relative filename from the Git status result (after the 2 letters status and 1 space)
|
|
return InResult.RightChop(3);
|
|
}
|
|
}
|
|
|
|
/** Match the relative filename of a Git status result with a provided absolute filename */
|
|
class FGitStatusFileMatcher
|
|
{
|
|
public:
|
|
FGitStatusFileMatcher(const FString& InAbsoluteFilename) : AbsoluteFilename(InAbsoluteFilename)
|
|
{}
|
|
|
|
bool operator()(const FString& InResult) const
|
|
{
|
|
return AbsoluteFilename.Contains(FilenameFromGitStatus(InResult));
|
|
}
|
|
|
|
private:
|
|
const FString& AbsoluteFilename;
|
|
};
|
|
|
|
/**
|
|
* Extract and interpret the file state from the given Git status result.
|
|
* @see http://git-scm.com/docs/git-status
|
|
* ' ' = unmodified
|
|
* 'M' = modified
|
|
* 'A' = added
|
|
* 'D' = deleted
|
|
* 'R' = renamed
|
|
* 'C' = copied
|
|
* 'U' = updated but unmerged
|
|
* '?' = unknown/untracked
|
|
* '!' = ignored
|
|
*/
|
|
class FGitStatusParser
|
|
{
|
|
public:
|
|
FGitStatusParser(const FString& InResult)
|
|
{
|
|
TCHAR IndexState = InResult[0];
|
|
TCHAR WCopyState = InResult[1];
|
|
if ((IndexState == 'U' || WCopyState == 'U') || (IndexState == 'A' && WCopyState == 'A') || (IndexState == 'D' && WCopyState == 'D'))
|
|
{
|
|
// "Unmerged" conflict cases are generally marked with a "U",
|
|
// but there are also the special cases of both "A"dded, or both "D"eleted
|
|
FileState = EFileState::Unmerged;
|
|
TreeState = ETreeState::Working;
|
|
return;
|
|
}
|
|
|
|
if (IndexState == ' ')
|
|
{
|
|
TreeState = ETreeState::Working;
|
|
}
|
|
else if (WCopyState == ' ')
|
|
{
|
|
TreeState = ETreeState::Staged;
|
|
}
|
|
|
|
if (IndexState == '?' || WCopyState == '?')
|
|
{
|
|
TreeState = ETreeState::Untracked;
|
|
FileState = EFileState::Unknown;
|
|
}
|
|
else if (IndexState == '!' || WCopyState == '!')
|
|
{
|
|
TreeState = ETreeState::Ignored;
|
|
FileState = EFileState::Unknown;
|
|
}
|
|
else if (IndexState == 'A')
|
|
{
|
|
FileState = EFileState::Added;
|
|
}
|
|
else if (IndexState == 'D')
|
|
{
|
|
FileState = EFileState::Deleted;
|
|
}
|
|
else if (WCopyState == 'D')
|
|
{
|
|
FileState = EFileState::Missing;
|
|
}
|
|
else if (IndexState == 'M' || WCopyState == 'M')
|
|
{
|
|
FileState = EFileState::Modified;
|
|
}
|
|
else if (IndexState == 'R')
|
|
{
|
|
FileState = EFileState::Renamed;
|
|
}
|
|
else if (IndexState == 'C')
|
|
{
|
|
FileState = EFileState::Copied;
|
|
}
|
|
else
|
|
{
|
|
// Unmodified never yield a status
|
|
FileState = EFileState::Unknown;
|
|
}
|
|
}
|
|
|
|
EFileState::Type FileState;
|
|
ETreeState::Type TreeState;
|
|
};
|
|
|
|
/**
|
|
* Extract the status of a unmerged (conflict) file
|
|
*
|
|
* Example output of git ls-files --unmerged Content/Blueprints/BP_Test.uasset
|
|
100644 d9b33098273547b57c0af314136f35b494e16dcb 1 Content/Blueprints/BP_Test.uasset
|
|
100644 a14347dc3b589b78fb19ba62a7e3982f343718bc 2 Content/Blueprints/BP_Test.uasset
|
|
100644 f3137a7167c840847cd7bd2bf07eefbfb2d9bcd2 3 Content/Blueprints/BP_Test.uasset
|
|
*
|
|
* 1: The "common ancestor" of the file (the version of the file that both the current and other branch originated from).
|
|
* 2: The version from the current branch (the master branch in this case).
|
|
* 3: The version from the other branch (the test branch)
|
|
*/
|
|
class FGitConflictStatusParser
|
|
{
|
|
public:
|
|
/** Parse the unmerge status: extract the base SHA1 identifier of the file */
|
|
FGitConflictStatusParser(const TArray<FString>& InResults)
|
|
{
|
|
const FString& CommonAncestor = InResults[0]; // 1: The common ancestor of merged branches
|
|
CommonAncestorFileId = CommonAncestor.Mid(7, 40);
|
|
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 3
|
|
CommonAncestorFileId = CommonAncestor.Mid(7, 40);
|
|
CommonAncestorFilename = CommonAncestor.Right(50);
|
|
|
|
if (ensure(InResults.IsValidIndex(2)))
|
|
{
|
|
const FString& RemoteBranch = InResults[2]; // 1: The common ancestor of merged branches
|
|
RemoteFileId = RemoteBranch.Mid(7, 40);
|
|
RemoteFilename = RemoteBranch.Right(50);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
FString CommonAncestorFileId; ///< SHA1 Id of the file (warning: not the commit Id)
|
|
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 3
|
|
FString RemoteFileId; ///< SHA1 Id of the file (warning: not the commit Id)
|
|
|
|
FString CommonAncestorFilename;
|
|
FString RemoteFilename;
|
|
#endif
|
|
};
|
|
|
|
/** Execute a command to get the details of a conflict */
|
|
static void RunGetConflictStatus(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const FString& InFile, FGitSourceControlState& InOutFileState)
|
|
{
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Results;
|
|
TArray<FString> Files;
|
|
Files.Add(InFile);
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--unmerged"));
|
|
bool bResult = RunCommandInternal(TEXT("ls-files"), InPathToGitBinary, InRepositoryRoot, Parameters, Files, Results, ErrorMessages);
|
|
if (bResult && Results.Num() == 3)
|
|
{
|
|
// Parse the unmerge status: extract the base revision (or the other branch?)
|
|
FGitConflictStatusParser ConflictStatus(Results);
|
|
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 3
|
|
InOutFileState.PendingResolveInfo.BaseFile = ConflictStatus.CommonAncestorFilename;
|
|
InOutFileState.PendingResolveInfo.BaseRevision = ConflictStatus.CommonAncestorFileId;
|
|
InOutFileState.PendingResolveInfo.RemoteFile = ConflictStatus.RemoteFilename;
|
|
InOutFileState.PendingResolveInfo.RemoteRevision = ConflictStatus.RemoteFileId;
|
|
#else
|
|
InOutFileState.PendingMergeBaseFileHash = ConflictStatus.CommonAncestorFileId;
|
|
#endif
|
|
}
|
|
}
|
|
|
|
TArray<UPackage*> UnlinkPackages(const TArray<FString>& InPackageNames)
|
|
{
|
|
TArray<UPackage*> LoadedPackages;
|
|
// UE-COPY: ContentBrowserUtils::SyncPathsFromSourceControl()
|
|
if (InPackageNames.Num() > 0)
|
|
{
|
|
TArray<FString> PackagesToUnlink;
|
|
for (const auto& Filename : InPackageNames)
|
|
{
|
|
FString PackageName;
|
|
if (FPackageName::TryConvertFilenameToLongPackageName(Filename, PackageName))
|
|
{
|
|
PackagesToUnlink.Add(*PackageName);
|
|
}
|
|
}
|
|
// Form a list of loaded packages to reload...
|
|
LoadedPackages.Reserve(PackagesToUnlink.Num());
|
|
for (const FString& PackageName : PackagesToUnlink)
|
|
{
|
|
UPackage* Package = FindPackage(nullptr, *PackageName);
|
|
if (Package)
|
|
{
|
|
LoadedPackages.Emplace(Package);
|
|
|
|
// Detach the linkers of any loaded packages so that SCC can overwrite the files...
|
|
if (!Package->IsFullyLoaded())
|
|
{
|
|
FlushAsyncLoading();
|
|
Package->FullyLoad();
|
|
}
|
|
ResetLoaders(Package);
|
|
}
|
|
}
|
|
}
|
|
return LoadedPackages;
|
|
}
|
|
|
|
void ReloadPackages(TArray<UPackage*>& InPackagesToReload)
|
|
{
|
|
// UE-COPY: ContentBrowserUtils::SyncPathsFromSourceControl()
|
|
// Syncing may have deleted some packages, so we need to unload those rather than re-load them...
|
|
TArray<UPackage*> PackagesToUnload;
|
|
InPackagesToReload.RemoveAll([&](UPackage* InPackage) -> bool {
|
|
const FString PackageExtension = InPackage->ContainsMap() ? FPackageName::GetMapPackageExtension() : FPackageName::GetAssetPackageExtension();
|
|
const FString PackageFilename = FPackageName::LongPackageNameToFilename(InPackage->GetName(), PackageExtension);
|
|
if (!FPaths::FileExists(PackageFilename))
|
|
{
|
|
PackagesToUnload.Emplace(InPackage);
|
|
return true; // remove package
|
|
}
|
|
return false; // keep package
|
|
});
|
|
|
|
// Hot-reload the new packages...
|
|
UPackageTools::ReloadPackages(InPackagesToReload);
|
|
|
|
// Unload any deleted packages...
|
|
UPackageTools::UnloadPackages(PackagesToUnload);
|
|
}
|
|
|
|
/// Convert filename relative to the repository root to absolute path (inplace)
|
|
void AbsoluteFilenames(const FString& InRepositoryRoot, TArray<FString>& InFileNames)
|
|
{
|
|
for (auto& FileName : InFileNames)
|
|
{
|
|
FileName = FPaths::ConvertRelativePathToFull(InRepositoryRoot, FileName);
|
|
}
|
|
}
|
|
|
|
/** Run a 'git ls-files' command to get all files tracked by Git recursively in a directory.
|
|
*
|
|
* Called in case of a "directory status" (no file listed in the command) when using the "Submit to Revision Control" menu.
|
|
*/
|
|
bool ListFilesInDirectoryRecurse(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const FString& InDirectory, TArray<FString>& OutFiles)
|
|
{
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Directory;
|
|
Directory.Add(InDirectory);
|
|
const bool bResult = RunCommandInternal(TEXT("ls-files"), InPathToGitBinary, InRepositoryRoot, FGitSourceControlModule::GetEmptyStringArray(), Directory, OutFiles, ErrorMessages);
|
|
AbsoluteFilenames(InRepositoryRoot, OutFiles);
|
|
return bResult;
|
|
}
|
|
|
|
/** Parse the array of strings results of a 'git status' command for a directory
|
|
*
|
|
* Called in case of a "directory status" (no file listed in the command) ONLY to detect Deleted/Missing/Untracked files
|
|
* since those files are not listed by the 'git ls-files' command.
|
|
*
|
|
* @see #ParseFileStatusResult() above for an example of a 'git status' results
|
|
*/
|
|
static void ParseDirectoryStatusResult(const bool InUsingLfsLocking, const TMap<FString, FString>& InResults, TMap<FString, FGitSourceControlState>& OutStates)
|
|
{
|
|
// Iterate on each line of result of the status command
|
|
for (const auto& Result : InResults)
|
|
{
|
|
FGitSourceControlState FileState(Result.Key);
|
|
if (!InUsingLfsLocking)
|
|
{
|
|
FileState.State.LockState = ELockState::Unlockable;
|
|
}
|
|
FGitStatusParser StatusParser(Result.Value);
|
|
if ((EFileState::Deleted == StatusParser.FileState) || (EFileState::Missing == StatusParser.FileState) || (ETreeState::Untracked == StatusParser.TreeState))
|
|
{
|
|
FileState.State.FileState = StatusParser.FileState;
|
|
FileState.State.TreeState = StatusParser.TreeState;
|
|
OutStates.Add(Result.Key, MoveTemp(FileState));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Parse the array of strings results of a 'git status' command for a provided list of files all in a common directory
|
|
*
|
|
* Called in case of a normal refresh of status on a list of assets in a the Content Browser (or user selected "Refresh" context menu).
|
|
*
|
|
* Example git status results:
|
|
M Content/Textures/T_Perlin_Noise_M.uasset
|
|
R Content/Textures/T_Perlin_Noise_M.uasset -> Content/Textures/T_Perlin_Noise_M2.uasset
|
|
?? Content/Materials/M_Basic_Wall.uasset
|
|
!! BasicCode.sln
|
|
*/
|
|
static void ParseFileStatusResult(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const bool InUsingLfsLocking, const TSet<FString>& InFiles,
|
|
const TMap<FString, FString>& InResults, TMap<FString, FGitSourceControlState>& OutStates)
|
|
{
|
|
FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
return;
|
|
}
|
|
FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
const FString& LfsUserName = Provider.GetLockUser();
|
|
|
|
TMap<FString, FString> LockedFiles;
|
|
TMap<FString, FString> Results = InResults;
|
|
bool bCheckedLockedFiles = false;
|
|
|
|
FString Result;
|
|
|
|
// Iterate on all files explicitly listed in the command
|
|
for (const auto& File : InFiles)
|
|
{
|
|
FGitSourceControlState FileState(File);
|
|
FileState.State.FileState = EFileState::Unset;
|
|
FileState.State.TreeState = ETreeState::Unset;
|
|
FileState.State.LockState = ELockState::Unset;
|
|
// Search the file in the list of status
|
|
bool bFound = Results.RemoveAndCopyValue(File, Result);
|
|
if (bFound)
|
|
{
|
|
// File found in status results; only the case for "changed" files
|
|
FGitStatusParser StatusParser(Result);
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("Status(%s) = '%s' => File:%d, Tree:%d"), *File, *Result, static_cast<int>(StatusParser.FileState), static_cast<int>(StatusParser.TreeState));
|
|
#endif
|
|
|
|
FileState.State.FileState = StatusParser.FileState;
|
|
FileState.State.TreeState = StatusParser.TreeState;
|
|
if (FileState.IsConflicted())
|
|
{
|
|
// In case of a conflict (unmerged file) get the base revision to merge
|
|
RunGetConflictStatus(InPathToGitBinary, InRepositoryRoot, File, FileState);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
FileState.State.FileState = EFileState::Unknown;
|
|
// File not found in status
|
|
if (FPaths::FileExists(File))
|
|
{
|
|
// usually means the file is unchanged,
|
|
FileState.State.TreeState = ETreeState::Unmodified;
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("Status(%s) not found but exists => unchanged"), *File);
|
|
#endif
|
|
}
|
|
else
|
|
{
|
|
// but also the case for newly created content: there is no file on disk until the content is saved for the first time
|
|
FileState.State.TreeState = ETreeState::NotInRepo;
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("Status(%s) not found and does not exists => new/not controled"), *File);
|
|
#endif
|
|
}
|
|
}
|
|
if (!InUsingLfsLocking)
|
|
{
|
|
FileState.State.LockState = ELockState::Unlockable;
|
|
}
|
|
else
|
|
{
|
|
if (IsFileLFSLockable(File))
|
|
{
|
|
if (!bCheckedLockedFiles)
|
|
{
|
|
bCheckedLockedFiles = true;
|
|
TArray<FString> ErrorMessages;
|
|
GetAllLocks(InRepositoryRoot, InPathToGitBinary, ErrorMessages, LockedFiles);
|
|
FTSMessageLog SourceControlLog("SourceControl");
|
|
for (int32 ErrorIndex = 0; ErrorIndex < ErrorMessages.Num(); ++ErrorIndex)
|
|
{
|
|
SourceControlLog.Error(FText::FromString(ErrorMessages[ErrorIndex]));
|
|
}
|
|
}
|
|
if (LockedFiles.Contains(File))
|
|
{
|
|
FileState.State.LockUser = LockedFiles[File];
|
|
if (LfsUserName == FileState.State.LockUser)
|
|
{
|
|
FileState.State.LockState = ELockState::Locked;
|
|
}
|
|
else
|
|
{
|
|
FileState.State.LockState = ELockState::LockedOther;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
FileState.State.LockState = ELockState::NotLocked;
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("Status(%s) Not Locked"), *File);
|
|
#endif
|
|
}
|
|
}
|
|
else
|
|
{
|
|
FileState.State.LockState = ELockState::Unlockable;
|
|
}
|
|
|
|
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("Status(%s) Locked by '%s'"), *File, *FileState.State.LockUser);
|
|
#endif
|
|
}
|
|
OutStates.Add(File, MoveTemp(FileState));
|
|
}
|
|
|
|
// The above cannot detect deleted assets since there is no file left to enumerate (either by the Content Browser or by git ls-files)
|
|
// => so we also parse the status results to explicitly look for Deleted/Missing assets
|
|
ParseDirectoryStatusResult(InUsingLfsLocking, Results, OutStates);
|
|
}
|
|
|
|
/**
|
|
* @brief Detects how to parse the result of a "status" command to get workspace file states
|
|
*
|
|
* It is either a command for a whole directory (ie. "Content/", in case of "Submit to Revision Control" menu),
|
|
* or for one or more files all on a same directory (by design, since we group files by directory in RunUpdateStatus())
|
|
*
|
|
* @param[in] InPathToGitBinary The path to the Git binary
|
|
* @param[in] InRepositoryRoot The Git repository from where to run the command - usually the Game directory (can be empty)
|
|
* @param[in] InUsingLfsLocking Tells if using the Git LFS file Locking workflow
|
|
* @param[in] InFiles List of files in a directory, or the path to the directory itself (never empty).
|
|
* @param[out] InResults Results from the "status" command
|
|
* @param[out] OutStates States of files for witch the status has been gathered (distinct than InFiles in case of a "directory status")
|
|
*/
|
|
static void ParseStatusResults(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const bool InUsingLfsLocking, const TArray<FString>& InFiles,
|
|
const TMap<FString, FString>& InResults, TMap<FString, FGitSourceControlState>& OutStates)
|
|
{
|
|
TSet<FString> Files;
|
|
for (const auto& File : InFiles)
|
|
{
|
|
if (FPaths::DirectoryExists(File))
|
|
{
|
|
TArray<FString> DirectoryFiles;
|
|
const bool bResult = ListFilesInDirectoryRecurse(InPathToGitBinary, InRepositoryRoot, File, DirectoryFiles);
|
|
if (bResult)
|
|
{
|
|
for (const auto& InnerFile : DirectoryFiles)
|
|
{
|
|
Files.Add(InnerFile);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Files.Add(File);
|
|
}
|
|
}
|
|
ParseFileStatusResult(InPathToGitBinary, InRepositoryRoot, InUsingLfsLocking, Files, InResults, OutStates);
|
|
}
|
|
|
|
void CheckRemote(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& Files,
|
|
TArray<FString>& OutErrorMessages, TMap<FString, FGitSourceControlState>& OutStates)
|
|
{
|
|
// We can obtain a list of files that were modified between our remote branches and HEAD. Assumes that fetch has been run to get accurate info.
|
|
|
|
// Gather valid remote branches
|
|
FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
return;
|
|
}
|
|
FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
const TArray<FString> StatusBranches = Provider.GetStatusBranchNames();
|
|
|
|
TSet<FString> BranchesToDiff{ StatusBranches };
|
|
|
|
bool bDiffAgainstRemoteCurrent = false;
|
|
|
|
// Get the current branch's remote.
|
|
FString CurrentBranchName;
|
|
if (GetRemoteBranchName(InPathToGitBinary, InRepositoryRoot, CurrentBranchName))
|
|
{
|
|
// We have a valid remote, so diff against it.
|
|
bDiffAgainstRemoteCurrent = true;
|
|
// Ensure that the remote branch is in there.
|
|
BranchesToDiff.Add(CurrentBranchName);
|
|
}
|
|
|
|
if (!BranchesToDiff.Num())
|
|
{
|
|
return;
|
|
}
|
|
|
|
TArray<FString> ErrorMessages;
|
|
|
|
TArray<FString> Results;
|
|
TMap<FString, FString> NewerFiles;
|
|
|
|
//const TArray<FString>& RelativeFiles = RelativeFilenames(Files, InRepositoryRoot);
|
|
// Get the full remote status of the Content folder, since it's the only lockable folder we track in editor.
|
|
// This shows any new files as well.
|
|
// Also update the status of `.checksum`.
|
|
TArray<FString> FilesToDiff{FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir()), ".checksum", "Binaries/", "Plugins/"};
|
|
TArray<FString> ParametersLog{TEXT("--pretty="), TEXT("--name-only"), TEXT(""), TEXT("--")};
|
|
for (auto& Branch : BranchesToDiff)
|
|
{
|
|
bool bCurrentBranch;
|
|
if (bDiffAgainstRemoteCurrent && Branch.Equals(CurrentBranchName))
|
|
{
|
|
bCurrentBranch = true;
|
|
}
|
|
else
|
|
{
|
|
bCurrentBranch = false;
|
|
}
|
|
// empty defaults to HEAD
|
|
// .. means commits in the right that are not in the left
|
|
ParametersLog[2] = FString::Printf(TEXT("..%s"), *Branch);
|
|
|
|
const bool bResultDiff = RunCommand(TEXT("log"), InPathToGitBinary, InRepositoryRoot, ParametersLog, FilesToDiff, Results, ErrorMessages);
|
|
if (bResultDiff)
|
|
{
|
|
for (const FString& NewerFileName : Results)
|
|
{
|
|
// Don't care about mergeable files (.collection, .ini, .uproject, etc)
|
|
if (!IsFileLFSLockable(NewerFileName))
|
|
{
|
|
// Check if there's newer binaries pending on this branch
|
|
if (bCurrentBranch && (NewerFileName == TEXT(".checksum") || NewerFileName.StartsWith("Binaries/", ESearchCase::IgnoreCase) ||
|
|
NewerFileName.StartsWith("Plugins/", ESearchCase::IgnoreCase)))
|
|
{
|
|
Provider.bPendingRestart = true;
|
|
}
|
|
continue;
|
|
}
|
|
const FString& NewerFilePath = FPaths::ConvertRelativePathToFull(InRepositoryRoot, NewerFileName);
|
|
if (bCurrentBranch || !NewerFiles.Contains(NewerFilePath))
|
|
{
|
|
NewerFiles.Add(NewerFilePath, Branch);
|
|
}
|
|
}
|
|
}
|
|
Results.Reset();
|
|
}
|
|
|
|
for (const auto& NewFile : NewerFiles)
|
|
{
|
|
if (FGitSourceControlState* FileState = OutStates.Find(NewFile.Key))
|
|
{
|
|
FileState->State.RemoteState = NewFile.Value.Equals(CurrentBranchName) ? ERemoteState::NotAtHead : ERemoteState::NotLatest;
|
|
FileState->State.HeadBranch = NewFile.Value;
|
|
}
|
|
}
|
|
|
|
OutErrorMessages.Append(ErrorMessages);
|
|
}
|
|
|
|
const FTimespan CacheLimit = FTimespan::FromSeconds(30);
|
|
|
|
bool GetAllLocks(const FString& InRepositoryRoot, const FString& GitBinaryFallback, TArray<FString>& OutErrorMessages, TMap<FString, FString>& OutLocks, bool bInvalidateCache)
|
|
{
|
|
// You may ask, why are we ignoring state cache, and instead maintaining our own lock cache?
|
|
// The answer is that state cache updating is another operation, and those that update status
|
|
// (and thus the state cache) are using GetAllLocks. However, querying remote locks are almost always
|
|
// irrelevant in most of those update status cases. So, we need to provide a fast way to provide
|
|
// an updated local lock state. We could do this through the relevant lfs lock command arguments, which
|
|
// as you will see below, we use only for offline cases, but the exec cost of doing this isn't worth it
|
|
// when we can easily maintain this cache here. So, we are really emulating an internal Git LFS locks cache
|
|
// call, which gets fed into the state cache, rather than reimplementing the state cache :)
|
|
const FDateTime CurrentTime = FDateTime::Now();
|
|
bool bCacheExpired = bInvalidateCache;
|
|
if (!bInvalidateCache)
|
|
{
|
|
const FTimespan CacheTimeElapsed = CurrentTime - FGitLockedFilesCache::LastUpdated;
|
|
bCacheExpired = CacheTimeElapsed > CacheLimit;
|
|
}
|
|
bool bResult = false;
|
|
if (bCacheExpired)
|
|
{
|
|
// Our cache expired, or they asked us to expire cache. Query locks directly from the remote server.
|
|
TArray<FString> ErrorMessages;
|
|
TArray<FString> Results;
|
|
bResult = RunLFSCommand(TEXT("locks"), InRepositoryRoot, GitBinaryFallback, FGitSourceControlModule::GetEmptyStringArray(), FGitSourceControlModule::GetEmptyStringArray(),
|
|
Results, OutErrorMessages);
|
|
if (bResult)
|
|
{
|
|
for (const FString& Result : Results)
|
|
{
|
|
FGitLfsLocksParser LockFile(InRepositoryRoot, Result);
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("LockedFile(%s, %s)"), *LockFile.LocalFilename, *LockFile.LockUser);
|
|
#endif
|
|
OutLocks.Add(MoveTemp(LockFile.LocalFilename), MoveTemp(LockFile.LockUser));
|
|
}
|
|
FGitLockedFilesCache::LastUpdated = CurrentTime;
|
|
FGitLockedFilesCache::SetLockedFiles(OutLocks);
|
|
return bResult;
|
|
}
|
|
// We tried to invalidate the UE cache, but we failed for some reason. Try updating lock state from LFS cache.
|
|
// Get the last known state of remote locks
|
|
TArray<FString> Params;
|
|
Params.Add(TEXT("--cached"));
|
|
|
|
FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
bResult = false;
|
|
}
|
|
else
|
|
{
|
|
FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
const FString& LockUser = Provider.GetLockUser();
|
|
|
|
Results.Reset();
|
|
bResult = RunLFSCommand(TEXT("locks"), InRepositoryRoot, GitBinaryFallback, Params, FGitSourceControlModule::GetEmptyStringArray(), Results, OutErrorMessages);
|
|
for (const FString& Result : Results)
|
|
{
|
|
FGitLfsLocksParser LockFile(InRepositoryRoot, Result);
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("LockedFile(%s, %s)"), *LockFile.LocalFilename, *LockFile.LockUser);
|
|
#endif
|
|
// Only update remote locks
|
|
if (LockFile.LockUser != LockUser)
|
|
{
|
|
OutLocks.Add(MoveTemp(LockFile.LocalFilename), MoveTemp(LockFile.LockUser));
|
|
}
|
|
}
|
|
// Get the latest local state of our own locks
|
|
Params.Reset(1);
|
|
Params.Add(TEXT("--local"));
|
|
|
|
Results.Reset();
|
|
bResult &= RunLFSCommand(TEXT("locks"), InRepositoryRoot, GitBinaryFallback, Params, FGitSourceControlModule::GetEmptyStringArray(), Results, OutErrorMessages);
|
|
for (const FString& Result : Results)
|
|
{
|
|
FGitLfsLocksParser LockFile(InRepositoryRoot, Result);
|
|
#if UE_BUILD_DEBUG && GIT_DEBUG_STATUS
|
|
UE_LOG(LogSourceControl, Log, TEXT("LockedFile(%s, %s)"), *LockFile.LocalFilename, *LockFile.LockUser);
|
|
#endif
|
|
// Only update local locks
|
|
if (LockFile.LockUser == LockUser)
|
|
{
|
|
OutLocks.Add(MoveTemp(LockFile.LocalFilename), MoveTemp(LockFile.LockUser));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!bResult)
|
|
{
|
|
// We can use our internally tracked local lock cache (an effective combination of --cached and --local)
|
|
OutLocks = FGitLockedFilesCache::GetLockedFiles();
|
|
bResult = true;
|
|
}
|
|
return bResult;
|
|
}
|
|
|
|
void GetLockedFiles(const TArray<FString>& InFiles, TArray<FString>& OutFiles)
|
|
{
|
|
FGitSourceControlModule& GitSourceControl = FGitSourceControlModule::Get();
|
|
FGitSourceControlProvider& Provider = GitSourceControl.GetProvider();
|
|
|
|
TArray<TSharedRef<ISourceControlState, ESPMode::ThreadSafe>> LocalStates;
|
|
Provider.GetState(InFiles, LocalStates, EStateCacheUsage::Use);
|
|
for (const auto& State : LocalStates)
|
|
{
|
|
const auto& GitState = StaticCastSharedRef<FGitSourceControlState>(State);
|
|
if (GitState->State.LockState == ELockState::Locked)
|
|
{
|
|
OutFiles.Add(GitState->GetFilename());
|
|
}
|
|
}
|
|
}
|
|
|
|
FString GetFullPathFromGitStatus(const FString& Result, const FString& InRepositoryRoot)
|
|
{
|
|
const FString& RelativeFilename = FilenameFromGitStatus(Result);
|
|
FString File = FPaths::ConvertRelativePathToFull(InRepositoryRoot, RelativeFilename);
|
|
return File;
|
|
}
|
|
|
|
bool UpdateChangelistStateByCommand()
|
|
{
|
|
FGitSourceControlModule& GitSourceControl = FModuleManager::GetModuleChecked<FGitSourceControlModule>("GitSourceControl");
|
|
FGitSourceControlProvider& Provider = GitSourceControl.GetProvider();
|
|
if (!Provider.IsGitAvailable())
|
|
{
|
|
return false;
|
|
}
|
|
TSharedRef<FGitSourceControlChangelistState, ESPMode::ThreadSafe> StagedChangelist = Provider.GetStateInternal(FGitSourceControlChangelist::StagedChangelist);
|
|
TSharedRef<FGitSourceControlChangelistState, ESPMode::ThreadSafe> WorkingChangelist = Provider.GetStateInternal(FGitSourceControlChangelist::WorkingChangelist);
|
|
StagedChangelist->Files.RemoveAll([](const FSourceControlStateRef& InState){ return true; });
|
|
WorkingChangelist->Files.RemoveAll([](const FSourceControlStateRef& InState){ return true; });
|
|
|
|
TArray<FString> Files;
|
|
Files.Add(TEXT("Content/"));
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--porcelain"));
|
|
TArray<FString> Results;
|
|
TArray<FString> ErrorMsg;
|
|
const bool bResult = RunCommand(TEXT("--no-optional-locks status"), Provider.GetGitBinaryPath(), Provider.GetPathToRepositoryRoot(), Parameters, Files, Results, ErrorMsg);
|
|
for (const auto& Result : Results)
|
|
{
|
|
FString File = GetFullPathFromGitStatus(Result, Provider.GetPathToRepositoryRoot());
|
|
TSharedRef<FGitSourceControlState, ESPMode::ThreadSafe> State = Provider.GetStateInternal(File);
|
|
// Staged check
|
|
if (!TChar<TCHAR>::IsWhitespace(Result[0]))
|
|
{
|
|
WorkingChangelist->Files.Remove(State);
|
|
UpdateFileStagingOnSavedInternal(Result);
|
|
State->Changelist = FGitSourceControlChangelist::StagedChangelist;
|
|
StagedChangelist->Files.AddUnique(State);
|
|
continue;
|
|
}
|
|
// Working check
|
|
if (!TChar<TCHAR>::IsWhitespace(Result[1]))
|
|
{
|
|
StagedChangelist->Files.Remove(State);
|
|
State->Changelist = FGitSourceControlChangelist::WorkingChangelist;
|
|
WorkingChangelist->Files.AddUnique(State);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Run a batch of Git "status" command to update status of given files and/or directories.
|
|
bool RunUpdateStatus(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const bool InUsingLfsLocking, const TArray<FString>& InFiles,
|
|
TArray<FString>& OutErrorMessages, TMap<FString, FGitSourceControlState>& OutStates)
|
|
{
|
|
// Remove files that aren't in the repository
|
|
const TArray<FString>& RepoFiles = InFiles.FilterByPredicate([InRepositoryRoot](const FString& File) { return File.StartsWith(InRepositoryRoot); });
|
|
|
|
if (!RepoFiles.Num())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--porcelain"));
|
|
Parameters.Add(TEXT("-uall")); // make sure we use -uall to list all files instead of directories
|
|
// We skip checking ignored since no one ignores files that Unreal would read in as revision controlled (Content/{*.uasset,*.umap},Config/*.ini).
|
|
TArray<FString> Results;
|
|
// avoid locking the index when not needed (useful for status updates)
|
|
const bool bResult = RunCommand(TEXT("--no-optional-locks status"), InPathToGitBinary, InRepositoryRoot, Parameters, RepoFiles, Results, OutErrorMessages);
|
|
TMap<FString, FString> ResultsMap;
|
|
for (const auto& Result : Results)
|
|
{
|
|
const FString& RelativeFilename = FilenameFromGitStatus(Result);
|
|
const FString& File = FPaths::ConvertRelativePathToFull(InRepositoryRoot, RelativeFilename);
|
|
ResultsMap.Add(File, Result);
|
|
}
|
|
if (bResult)
|
|
{
|
|
ParseStatusResults(InPathToGitBinary, InRepositoryRoot, InUsingLfsLocking, RepoFiles, ResultsMap, OutStates);
|
|
}
|
|
|
|
UpdateChangelistStateByCommand();
|
|
|
|
CheckRemote(InPathToGitBinary, InRepositoryRoot, RepoFiles, OutErrorMessages, OutStates);
|
|
|
|
return bResult;
|
|
}
|
|
|
|
void UpdateFileStagingOnSaved(const FString& Filename, UPackage* Pkg, FObjectPostSaveContext ObjectSaveContext)
|
|
{
|
|
UpdateFileStagingOnSavedInternal(Filename);
|
|
}
|
|
|
|
bool UpdateFileStagingOnSavedInternal(const FString& Filename)
|
|
{
|
|
bool bResult = false;
|
|
FGitSourceControlModule& GitSourceControl = FModuleManager::GetModuleChecked<FGitSourceControlModule>("GitSourceControl");
|
|
FGitSourceControlProvider& Provider = GitSourceControl.GetProvider();
|
|
if (!Provider.IsGitAvailable())
|
|
{
|
|
return bResult;
|
|
}
|
|
TSharedRef<FGitSourceControlState, ESPMode::ThreadSafe> State = Provider.GetStateInternal(Filename);
|
|
|
|
if (State->Changelist.GetName().Equals(TEXT("Staged")))
|
|
{
|
|
TArray<FString> File;
|
|
File.Add(Filename);
|
|
TArray<FString> DummyResults;
|
|
TArray<FString> DummyMsgs;
|
|
bResult = RunCommand(TEXT("add"), Provider.GetGitBinaryPath(), Provider.GetPathToRepositoryRoot(), FGitSourceControlModule::GetEmptyStringArray(), File, DummyResults, DummyMsgs);
|
|
}
|
|
|
|
return bResult;
|
|
}
|
|
|
|
void UpdateStateOnAssetRename(const FAssetData& InAssetData, const FString& InOldName)
|
|
{
|
|
FGitSourceControlModule& GitSourceControl = FModuleManager::GetModuleChecked<FGitSourceControlModule>("GitSourceControl");
|
|
FGitSourceControlProvider& Provider = GitSourceControl.GetProvider();
|
|
if (!Provider.IsGitAvailable())
|
|
{
|
|
return ;
|
|
}
|
|
TSharedRef<FGitSourceControlState, ESPMode::ThreadSafe> State = Provider.GetStateInternal(InOldName);
|
|
|
|
State->LocalFilename = InAssetData.GetObjectPathString();
|
|
}
|
|
|
|
// Run a Git `cat-file --filters` command to dump the binary content of a revision into a file.
|
|
bool RunDumpToFile(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const FString& InParameter, const FString& InDumpFileName)
|
|
{
|
|
int32 ReturnCode = -1;
|
|
FString FullCommand;
|
|
|
|
FGitSourceControlModule& GitSourceControl = FGitSourceControlModule::Get();
|
|
|
|
if (!InRepositoryRoot.IsEmpty())
|
|
{
|
|
// Specify the working copy (the root) of the git repository (before the command itself)
|
|
FullCommand = TEXT("-C \"");
|
|
FullCommand += InRepositoryRoot;
|
|
FullCommand += TEXT("\" ");
|
|
}
|
|
|
|
// then the git command itself
|
|
// Newer versions (2.9.3.windows.2) support smudge/clean filters used by Git LFS, git-fat, git-annex, etc
|
|
FullCommand += TEXT("cat-file --filters ");
|
|
|
|
// Append to the command the parameter
|
|
FullCommand += TEXT("\"") + InParameter + TEXT("\"");
|
|
|
|
const bool bLaunchDetached = false;
|
|
const bool bLaunchHidden = true;
|
|
const bool bLaunchReallyHidden = bLaunchHidden;
|
|
|
|
void* PipeRead = nullptr;
|
|
void* PipeWrite = nullptr;
|
|
|
|
verify(FPlatformProcess::CreatePipe(PipeRead, PipeWrite));
|
|
|
|
UE_LOG(LogSourceControl, Log, TEXT("RunDumpToFile: 'git %s'"), *FullCommand);
|
|
|
|
FString PathToGitOrEnvBinary = InPathToGitBinary;
|
|
#if PLATFORM_MAC
|
|
// The Cocoa application does not inherit shell environment variables, so add the path expected to have git-lfs to PATH
|
|
FString PathEnv = FPlatformMisc::GetEnvironmentVariable(TEXT("PATH"));
|
|
FString GitInstallPath = FPaths::GetPath(InPathToGitBinary);
|
|
|
|
TArray<FString> PathArray;
|
|
PathEnv.ParseIntoArray(PathArray, FPlatformMisc::GetPathVarDelimiter());
|
|
bool bHasGitInstallPath = false;
|
|
for (auto Path : PathArray)
|
|
{
|
|
if (GitInstallPath.Equals(Path, ESearchCase::CaseSensitive))
|
|
{
|
|
bHasGitInstallPath = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!bHasGitInstallPath)
|
|
{
|
|
PathToGitOrEnvBinary = FString("/usr/bin/env");
|
|
FullCommand = FString::Printf(TEXT("PATH=\"%s%s%s\" \"%s\" %s"), *GitInstallPath, FPlatformMisc::GetPathVarDelimiter(), *PathEnv, *InPathToGitBinary, *FullCommand);
|
|
}
|
|
#endif
|
|
|
|
#if ENGINE_MAJOR_VERSION == 5 && 0
|
|
FProcHandle ProcessHandle = FPlatformProcess::CreateProc(*PathToGitOrEnvBinary, *FullCommand, bLaunchDetached, bLaunchHidden, bLaunchReallyHidden, nullptr, 0, *InRepositoryRoot, PipeWrite, nullptr, nullptr);
|
|
#else
|
|
FProcHandle ProcessHandle = FPlatformProcess::CreateProc(*PathToGitOrEnvBinary, *FullCommand, bLaunchDetached, bLaunchHidden, bLaunchReallyHidden, nullptr, 0, *InRepositoryRoot, PipeWrite);
|
|
#endif
|
|
if(ProcessHandle.IsValid())
|
|
{
|
|
FPlatformProcess::Sleep(0.01f);
|
|
|
|
TArray<uint8> BinaryFileContent;
|
|
bool bRemovedLFSMessage = false;
|
|
while (FPlatformProcess::IsProcRunning(ProcessHandle))
|
|
{
|
|
TArray<uint8> BinaryData;
|
|
FPlatformProcess::ReadPipeToArray(PipeRead, BinaryData);
|
|
if (BinaryData.Num() > 0)
|
|
{
|
|
// @todo: this is hacky!
|
|
if (BinaryData[0] == 68) // Check for D in "Downloading"
|
|
{
|
|
if (BinaryData[BinaryData.Num() - 1] == 10) // Check for newline
|
|
{
|
|
BinaryData.Reset();
|
|
bRemovedLFSMessage = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
BinaryFileContent.Append(MoveTemp(BinaryData));
|
|
}
|
|
}
|
|
}
|
|
TArray<uint8> BinaryData;
|
|
FPlatformProcess::ReadPipeToArray(PipeRead, BinaryData);
|
|
if (BinaryData.Num() > 0)
|
|
{
|
|
// @todo: this is hacky!
|
|
if (!bRemovedLFSMessage && BinaryData[0] == 68) // Check for D in "Downloading"
|
|
{
|
|
int32 NewLineIndex = 0;
|
|
for (int32 Index = 0; Index < BinaryData.Num(); Index++)
|
|
{
|
|
if (BinaryData[Index] == 10) // Check for newline
|
|
{
|
|
NewLineIndex = Index;
|
|
break;
|
|
}
|
|
}
|
|
if (NewLineIndex > 0)
|
|
{
|
|
BinaryData.RemoveAt(0, NewLineIndex + 1);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
BinaryFileContent.Append(MoveTemp(BinaryData));
|
|
}
|
|
}
|
|
|
|
FPlatformProcess::GetProcReturnCode(ProcessHandle, &ReturnCode);
|
|
if (ReturnCode == 0)
|
|
{
|
|
// Save buffer into temp file
|
|
if (FFileHelper::SaveArrayToFile(BinaryFileContent, *InDumpFileName))
|
|
{
|
|
UE_LOG(LogSourceControl, Log, TEXT("Wrote '%s' (%do)"), *InDumpFileName, BinaryFileContent.Num());
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("Could not write %s"), *InDumpFileName);
|
|
ReturnCode = -1;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("DumpToFile: ReturnCode=%d"), ReturnCode);
|
|
}
|
|
|
|
FPlatformProcess::CloseProc(ProcessHandle);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogSourceControl, Error, TEXT("Failed to launch 'git cat-file'"));
|
|
}
|
|
|
|
FPlatformProcess::ClosePipe(PipeRead, PipeWrite);
|
|
|
|
return (ReturnCode == 0);
|
|
}
|
|
|
|
/**
|
|
* Translate file actions from the given Git log --name-status command to keywords used by the Editor UI.
|
|
*
|
|
* @see https://www.kernel.org/pub/software/scm/git/docs/git-log.html
|
|
* ' ' = unmodified
|
|
* 'M' = modified
|
|
* 'A' = added
|
|
* 'D' = deleted
|
|
* 'R' = renamed
|
|
* 'C' = copied
|
|
* 'T' = type changed
|
|
* 'U' = updated but unmerged
|
|
* 'X' = unknown
|
|
* 'B' = broken pairing
|
|
*
|
|
* @see SHistoryRevisionListRowContent::GenerateWidgetForColumn(): "add", "edit", "delete", "branch" and "integrate" (everything else is taken like "edit")
|
|
*/
|
|
static FString LogStatusToString(TCHAR InStatus)
|
|
{
|
|
switch (InStatus)
|
|
{
|
|
case TEXT(' '):
|
|
return FString("unmodified");
|
|
case TEXT('M'):
|
|
return FString("modified");
|
|
case TEXT('A'): // added: keyword "add" to display a specific icon instead of the default "edit" action one
|
|
return FString("add");
|
|
case TEXT('D'): // deleted: keyword "delete" to display a specific icon instead of the default "edit" action one
|
|
return FString("delete");
|
|
case TEXT('R'): // renamed keyword "branch" to display a specific icon instead of the default "edit" action one
|
|
return FString("branch");
|
|
case TEXT('C'): // copied keyword "branch" to display a specific icon instead of the default "edit" action one
|
|
return FString("branch");
|
|
case TEXT('T'):
|
|
return FString("type changed");
|
|
case TEXT('U'):
|
|
return FString("unmerged");
|
|
case TEXT('X'):
|
|
return FString("unknown");
|
|
case TEXT('B'):
|
|
return FString("broked pairing");
|
|
}
|
|
|
|
return FString();
|
|
}
|
|
|
|
/**
|
|
* Parse the array of strings results of a 'git log' command
|
|
*
|
|
* Example git log results:
|
|
commit 97a4e7626681895e073aaefd68b8ac087db81b0b
|
|
Author: Sébastien Rombauts <sebastien.rombauts@gmail.com>
|
|
Date: 2014-2015-05-15 21:32:27 +0200
|
|
|
|
Another commit used to test History
|
|
|
|
- with many lines
|
|
- some <xml>
|
|
- and strange characteres $*+
|
|
|
|
M Content/Blueprints/Blueprint_CeilingLight.uasset
|
|
R100 Content/Textures/T_Concrete_Poured_D.uasset Content/Textures/T_Concrete_Poured_D2.uasset
|
|
|
|
commit 355f0df26ebd3888adbb558fd42bb8bd3e565000
|
|
Author: Sébastien Rombauts <sebastien.rombauts@gmail.com>
|
|
Date: 2014-2015-05-12 11:28:14 +0200
|
|
|
|
Testing git status, edit, and revert
|
|
|
|
A Content/Blueprints/Blueprint_CeilingLight.uasset
|
|
C099 Content/Textures/T_Concrete_Poured_N.uasset Content/Textures/T_Concrete_Poured_N2.uasset
|
|
*/
|
|
static void ParseLogResults(const TArray<FString>& InResults, TGitSourceControlHistory& OutHistory)
|
|
{
|
|
TSharedRef<FGitSourceControlRevision, ESPMode::ThreadSafe> SourceControlRevision = MakeShareable(new FGitSourceControlRevision);
|
|
for (const auto& Result : InResults)
|
|
{
|
|
if (Result.StartsWith(TEXT("commit "))) // Start of a new commit
|
|
{
|
|
// End of the previous commit
|
|
if (SourceControlRevision->RevisionNumber != 0)
|
|
{
|
|
OutHistory.Add(MoveTemp(SourceControlRevision));
|
|
|
|
SourceControlRevision = MakeShareable(new FGitSourceControlRevision);
|
|
}
|
|
SourceControlRevision->CommitId = Result.RightChop(7); // Full commit SHA1 hexadecimal string
|
|
SourceControlRevision->ShortCommitId = SourceControlRevision->CommitId.Left(8); // Short revision ; first 8 hex characters (max that can hold a 32
|
|
// bit integer)
|
|
SourceControlRevision->CommitIdNumber = FParse::HexNumber(*SourceControlRevision->ShortCommitId);
|
|
SourceControlRevision->RevisionNumber = -1; // RevisionNumber will be set at the end, based off the index in the History
|
|
}
|
|
else if (Result.StartsWith(TEXT("Author: "))) // Author name & email
|
|
{
|
|
// Remove the 'email' part of the UserName
|
|
FString UserNameEmail = Result.RightChop(8);
|
|
int32 EmailIndex = 0;
|
|
if (UserNameEmail.FindLastChar('<', EmailIndex))
|
|
{
|
|
SourceControlRevision->UserName = UserNameEmail.Left(EmailIndex - 1);
|
|
}
|
|
}
|
|
else if (Result.StartsWith(TEXT("Date: "))) // Commit date
|
|
{
|
|
FString Date = Result.RightChop(8);
|
|
SourceControlRevision->Date = FDateTime::FromUnixTimestamp(FCString::Atoi(*Date));
|
|
}
|
|
// else if(Result.IsEmpty()) // empty line before/after commit message has already been taken care by FString::ParseIntoArray()
|
|
else if (Result.StartsWith(TEXT(" "))) // Multi-lines commit message
|
|
{
|
|
SourceControlRevision->Description += Result.RightChop(4);
|
|
SourceControlRevision->Description += TEXT("\n");
|
|
}
|
|
else // Name of the file, starting with an uppercase status letter ("A"/"M"...)
|
|
{
|
|
const TCHAR Status = Result[0];
|
|
SourceControlRevision->Action = LogStatusToString(Status); // Readable action string ("Added", Modified"...) instead of "A"/"M"...
|
|
// Take care of special case for Renamed/Copied file: extract the second filename after second tabulation
|
|
int32 IdxTab;
|
|
if (Result.FindLastChar('\t', IdxTab))
|
|
{
|
|
SourceControlRevision->Filename = Result.RightChop(IdxTab + 1); // relative filename
|
|
}
|
|
}
|
|
}
|
|
// End of the last commit
|
|
if (SourceControlRevision->RevisionNumber != 0)
|
|
{
|
|
OutHistory.Add(MoveTemp(SourceControlRevision));
|
|
}
|
|
|
|
// Then set the revision number of each Revision based on its index (reverse order since the log starts with the most recent change)
|
|
for (int32 RevisionIndex = 0; RevisionIndex < OutHistory.Num(); RevisionIndex++)
|
|
{
|
|
const auto& SourceControlRevisionItem = OutHistory[RevisionIndex];
|
|
SourceControlRevisionItem->RevisionNumber = OutHistory.Num() - RevisionIndex;
|
|
|
|
// Special case of a move ("branch" in Perforce term): point to the previous change (so the next one in the order of the log)
|
|
if ((SourceControlRevisionItem->Action == "branch") && (RevisionIndex < OutHistory.Num() - 1))
|
|
{
|
|
SourceControlRevisionItem->BranchSource = OutHistory[RevisionIndex + 1];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the SHA1 identifier and size of a blob (file) from a Git "ls-tree" command.
|
|
*
|
|
* Example output for the command git ls-tree --long 7fdaeb2 Content/Blueprints/BP_Test.uasset
|
|
100644 blob a14347dc3b589b78fb19ba62a7e3982f343718bc 70731 Content/Blueprints/BP_Test.uasset
|
|
*/
|
|
class FGitLsTreeParser
|
|
{
|
|
public:
|
|
/** Parse the unmerge status: extract the base SHA1 identifier of the file */
|
|
FGitLsTreeParser(const TArray<FString>& InResults)
|
|
{
|
|
const FString& FirstResult = InResults[0];
|
|
FileHash = FirstResult.Mid(12, 40);
|
|
int32 IdxTab;
|
|
if (FirstResult.FindChar('\t', IdxTab))
|
|
{
|
|
const FString SizeString = FirstResult.Mid(53, IdxTab - 53);
|
|
FileSize = FCString::Atoi(*SizeString);
|
|
}
|
|
}
|
|
|
|
FString FileHash; ///< SHA1 Id of the file (warning: not the commit Id)
|
|
int32 FileSize; ///< Size of the file (in bytes)
|
|
};
|
|
|
|
// Run a Git "log" command and parse it.
|
|
bool RunGetHistory(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const FString& InFile, bool bMergeConflict,
|
|
TArray<FString>& OutErrorMessages, TGitSourceControlHistory& OutHistory)
|
|
{
|
|
bool bResults;
|
|
{
|
|
TArray<FString> Results;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--follow")); // follow file renames
|
|
Parameters.Add(TEXT("--date=raw"));
|
|
Parameters.Add(TEXT("--name-status")); // relative filename at this revision, preceded by a status character
|
|
Parameters.Add(TEXT("--pretty=medium")); // make sure format matches expected in ParseLogResults
|
|
if (bMergeConflict)
|
|
{
|
|
// In case of a merge conflict, we also need to get the tip of the "remote branch" (MERGE_HEAD) before the log of the "current branch" (HEAD)
|
|
// @todo does not work for a cherry-pick! Test for a rebase.
|
|
Parameters.Add(TEXT("MERGE_HEAD"));
|
|
Parameters.Add(TEXT("--max-count 1"));
|
|
}
|
|
else
|
|
{
|
|
Parameters.Add(TEXT("--max-count 250")); // Increase default count to 250 from 100
|
|
}
|
|
TArray<FString> Files;
|
|
Files.Add(*InFile);
|
|
bResults = RunCommand(TEXT("log"), InPathToGitBinary, InRepositoryRoot, Parameters, Files, Results, OutErrorMessages);
|
|
if (bResults)
|
|
{
|
|
ParseLogResults(Results, OutHistory);
|
|
}
|
|
}
|
|
for (auto& Revision : OutHistory)
|
|
{
|
|
// Get file (blob) sha1 id and size
|
|
TArray<FString> Results;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("--long")); // Show object size of blob (file) entries.
|
|
Parameters.Add(Revision->GetRevision());
|
|
TArray<FString> Files;
|
|
Files.Add(*Revision->GetFilename());
|
|
bResults &= RunCommand(TEXT("ls-tree"), InPathToGitBinary, InRepositoryRoot, Parameters, Files, Results, OutErrorMessages);
|
|
if (bResults && Results.Num())
|
|
{
|
|
FGitLsTreeParser LsTree(Results);
|
|
Revision->FileHash = LsTree.FileHash;
|
|
Revision->FileSize = LsTree.FileSize;
|
|
}
|
|
Revision->PathToRepoRoot = InRepositoryRoot;
|
|
}
|
|
|
|
return bResults;
|
|
}
|
|
|
|
TArray<FString> RelativeFilenames(const TArray<FString>& InFileNames, const FString& InRelativeTo)
|
|
{
|
|
TArray<FString> RelativeFiles;
|
|
FString RelativeTo = InRelativeTo;
|
|
|
|
// Ensure that the path ends w/ '/'
|
|
if ((RelativeTo.Len() > 0) && (RelativeTo.EndsWith(TEXT("/"), ESearchCase::CaseSensitive) == false) &&
|
|
(RelativeTo.EndsWith(TEXT("\\"), ESearchCase::CaseSensitive) == false))
|
|
{
|
|
RelativeTo += TEXT("/");
|
|
}
|
|
for (FString FileName : InFileNames) // string copy to be able to convert it inplace
|
|
{
|
|
if (FPaths::MakePathRelativeTo(FileName, *RelativeTo))
|
|
{
|
|
RelativeFiles.Add(FileName);
|
|
}
|
|
}
|
|
|
|
return RelativeFiles;
|
|
}
|
|
|
|
TArray<FString> AbsoluteFilenames(const TArray<FString>& InFileNames, const FString& InRelativeTo)
|
|
{
|
|
TArray<FString> AbsFiles;
|
|
|
|
for(FString FileName : InFileNames) // string copy to be able to convert it inplace
|
|
{
|
|
AbsFiles.Add(FPaths::Combine(InRelativeTo, FileName));
|
|
}
|
|
|
|
return AbsFiles;
|
|
}
|
|
|
|
bool UpdateCachedStates(const TMap<const FString, FGitState>& InResults)
|
|
{
|
|
if (InResults.Num() == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FGitSourceControlModule* GitSourceControl = FGitSourceControlModule::GetThreadSafe();
|
|
if (!GitSourceControl)
|
|
{
|
|
return false;
|
|
}
|
|
FGitSourceControlProvider& Provider = GitSourceControl->GetProvider();
|
|
const bool bUsingGitLfsLocking = Provider.UsesCheckout();
|
|
|
|
// TODO without LFS : Workaround a bug with the Source Control Module not updating file state after a simple "Save" with no "Checkout" (when not using File Lock)
|
|
const FDateTime Now = bUsingGitLfsLocking ? FDateTime::Now() : FDateTime::MinValue();
|
|
|
|
for (const auto& Pair : InResults)
|
|
{
|
|
TSharedRef<FGitSourceControlState, ESPMode::ThreadSafe> State = Provider.GetStateInternal(Pair.Key);
|
|
const FGitState& NewState = Pair.Value;
|
|
if (NewState.FileState != EFileState::Unset)
|
|
{
|
|
// Invalid transition
|
|
if (NewState.FileState == EFileState::Added && !State->IsUnknown() && !State->CanAdd())
|
|
{
|
|
continue;
|
|
}
|
|
State->State.FileState = NewState.FileState;
|
|
}
|
|
if (NewState.TreeState != ETreeState::Unset)
|
|
{
|
|
State->State.TreeState = NewState.TreeState;
|
|
}
|
|
// If we're updating lock state, also update user
|
|
if (NewState.LockState != ELockState::Unset)
|
|
{
|
|
State->State.LockState = NewState.LockState;
|
|
State->State.LockUser = NewState.LockUser;
|
|
}
|
|
if (NewState.RemoteState != ERemoteState::Unset)
|
|
{
|
|
State->State.RemoteState = NewState.RemoteState;
|
|
if (NewState.RemoteState == ERemoteState::UpToDate)
|
|
{
|
|
State->State.HeadBranch = TEXT("");
|
|
}
|
|
else
|
|
{
|
|
State->State.HeadBranch = NewState.HeadBranch;
|
|
}
|
|
}
|
|
State->TimeStamp = Now;
|
|
|
|
// We've just updated the state, no need for UpdateStatus to be ran for this file again.
|
|
Provider.AddFileToIgnoreForceCache(State->LocalFilename);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool CollectNewStates(const TMap<FString, FGitSourceControlState>& InStates, TMap<const FString, FGitState>& OutResults)
|
|
{
|
|
if (InStates.Num() == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (const auto& InState : InStates)
|
|
{
|
|
OutResults.Add(InState.Key, InState.Value.State);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool CollectNewStates(const TArray<FString>& InFiles, TMap<const FString, FGitState>& OutResults, EFileState::Type FileState, ETreeState::Type TreeState, ELockState::Type LockState, ERemoteState::Type RemoteState)
|
|
{
|
|
if (InFiles.Num() == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FGitState NewState;
|
|
NewState.FileState = FileState;
|
|
NewState.TreeState = TreeState;
|
|
NewState.LockState = LockState;
|
|
NewState.RemoteState = RemoteState;
|
|
|
|
for (const auto& File : InFiles)
|
|
{
|
|
FGitState& State = OutResults.FindOrAdd(File, NewState);
|
|
if (NewState.FileState != EFileState::Unset)
|
|
{
|
|
State.FileState = NewState.FileState;
|
|
}
|
|
if (NewState.TreeState != ETreeState::Unset)
|
|
{
|
|
State.TreeState = NewState.TreeState;
|
|
}
|
|
if (NewState.LockState != ELockState::Unset)
|
|
{
|
|
State.LockState = NewState.LockState;
|
|
}
|
|
if (NewState.RemoteState != ERemoteState::Unset)
|
|
{
|
|
State.RemoteState = NewState.RemoteState;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Helper struct for RemoveRedundantErrors()
|
|
*/
|
|
struct FRemoveRedundantErrors
|
|
{
|
|
FRemoveRedundantErrors(const FString& InFilter) : Filter(InFilter)
|
|
{}
|
|
|
|
bool operator()(const FString& String) const
|
|
{
|
|
if (String.Contains(Filter))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** The filter string we try to identify in the reported error */
|
|
FString Filter;
|
|
};
|
|
|
|
void RemoveRedundantErrors(FGitSourceControlCommand& InCommand, const FString& InFilter)
|
|
{
|
|
bool bFoundRedundantError = false;
|
|
for (auto Iter(InCommand.ResultInfo.ErrorMessages.CreateConstIterator()); Iter; Iter++)
|
|
{
|
|
if (Iter->Contains(InFilter))
|
|
{
|
|
InCommand.ResultInfo.InfoMessages.Add(*Iter);
|
|
bFoundRedundantError = true;
|
|
}
|
|
}
|
|
|
|
InCommand.ResultInfo.ErrorMessages.RemoveAll(FRemoveRedundantErrors(InFilter));
|
|
|
|
// if we have no error messages now, assume success!
|
|
if (bFoundRedundantError && InCommand.ResultInfo.ErrorMessages.Num() == 0 && !InCommand.bCommandSuccessful)
|
|
{
|
|
InCommand.bCommandSuccessful = true;
|
|
}
|
|
}
|
|
|
|
static TArray<FString> LockableTypes;
|
|
|
|
bool IsFileLFSLockable(const FString& InFile)
|
|
{
|
|
for (const auto& Type : LockableTypes)
|
|
{
|
|
if (InFile.EndsWith(Type))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool CheckLFSLockable(const FString& InPathToGitBinary, const FString& InRepositoryRoot, const TArray<FString>& InFiles, TArray<FString>& OutErrorMessages)
|
|
{
|
|
TArray<FString> Results;
|
|
TArray<FString> Parameters;
|
|
Parameters.Add(TEXT("lockable")); // follow file renames
|
|
|
|
const bool bResults = RunCommand(TEXT("check-attr"), InPathToGitBinary, InRepositoryRoot, Parameters, InFiles, Results, OutErrorMessages);
|
|
if (!bResults)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < InFiles.Num(); i++)
|
|
{
|
|
const FString& Result = Results[i];
|
|
if (Result.EndsWith("set"))
|
|
{
|
|
const FString FileExt = InFiles[i].RightChop(1); // Remove wildcard (*)
|
|
LockableTypes.Add(FileExt);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool FetchRemote(const FString& InPathToGitBinary, const FString& InPathToRepositoryRoot, bool InUsingGitLfsLocking, TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
// Force refresh lock states
|
|
if (InUsingGitLfsLocking)
|
|
{
|
|
TMap<FString, FString> Locks;
|
|
GetAllLocks(InPathToRepositoryRoot, InPathToGitBinary, OutErrorMessages, Locks, true);
|
|
}
|
|
TArray<FString> Params{"--no-tags"};
|
|
// fetch latest repo
|
|
// TODO specify branches?
|
|
|
|
Params.Add(TEXT("--prune"));
|
|
return RunCommand(TEXT("fetch"), InPathToGitBinary, InPathToRepositoryRoot, Params,
|
|
FGitSourceControlModule::GetEmptyStringArray(), OutResults, OutErrorMessages);
|
|
}
|
|
|
|
bool PullOrigin(const FString& InPathToGitBinary, const FString& InPathToRepositoryRoot, const TArray<FString>& InFiles, TArray<FString>& OutFiles,
|
|
TArray<FString>& OutResults, TArray<FString>& OutErrorMessages)
|
|
{
|
|
if (FGitSourceControlModule::Get().GetProvider().bPendingRestart)
|
|
{
|
|
FText PullFailMessage(LOCTEXT("Git_NeedBinariesUpdate_Msg", "Refused to Git Pull because your editor binaries are out of date.\n\n"
|
|
"Without a binaries update, new assets can become corrupted or cause crashes due to format "
|
|
"differences.\n\n"
|
|
"Please exit the editor, and update the project."));
|
|
FText PullFailTitle(LOCTEXT("Git_NeedBinariesUpdate_Title", "Binaries Update Required"));
|
|
#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 3
|
|
FMessageDialog::Open(EAppMsgType::Ok, PullFailMessage, PullFailTitle);
|
|
#else
|
|
FMessageDialog::Open(EAppMsgType::Ok, PullFailMessage, &PullFailTitle);
|
|
#endif
|
|
UE_LOG(LogSourceControl, Log, TEXT("Pull failed because we need a binaries update"));
|
|
return false;
|
|
}
|
|
|
|
const TSet<FString> AlreadyReloaded {InFiles};
|
|
|
|
// Get remote branch
|
|
FString RemoteBranch;
|
|
if (!GetRemoteBranchName(InPathToGitBinary, InPathToRepositoryRoot, RemoteBranch))
|
|
{
|
|
// No remote to sync from
|
|
return false;
|
|
}
|
|
|
|
// Get the list of files which will be updated (either ones we changed locally, which will get potentially rebased or merged, or the remote ones that will update)
|
|
TArray<FString> DifferentFiles;
|
|
const bool bResultDiff = RunCommand(TEXT("diff"), InPathToGitBinary, InPathToRepositoryRoot, { TEXT("--name-only"), RemoteBranch }, FGitSourceControlModule::GetEmptyStringArray(), DifferentFiles, OutErrorMessages);
|
|
if (!bResultDiff)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Nothing to pull
|
|
if (!DifferentFiles.Num())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const TArray<FString>& AbsoluteDifferentFiles = AbsoluteFilenames(DifferentFiles, InPathToRepositoryRoot);
|
|
|
|
if (AlreadyReloaded.Num())
|
|
{
|
|
OutFiles.Reserve(AbsoluteDifferentFiles.Num() - AlreadyReloaded.Num());
|
|
for (const auto& File : AbsoluteDifferentFiles)
|
|
{
|
|
if (!AlreadyReloaded.Contains(File))
|
|
{
|
|
OutFiles.Add(File);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
OutFiles.Append(AbsoluteDifferentFiles);
|
|
}
|
|
|
|
TArray<FString> Files;
|
|
for (const auto& File : OutFiles)
|
|
{
|
|
if (IsFileLFSLockable(File))
|
|
{
|
|
Files.Add(File);
|
|
}
|
|
}
|
|
|
|
const bool bShouldReload = Files.Num() > 0;
|
|
TArray<UPackage*> PackagesToReload;
|
|
if (bShouldReload)
|
|
{
|
|
const auto PackagesToReloadResult = Async(EAsyncExecution::TaskGraphMainThread, [=] {
|
|
return UnlinkPackages(Files);
|
|
});
|
|
PackagesToReload = PackagesToReloadResult.Get();
|
|
}
|
|
|
|
// Reset HEAD and index to remote
|
|
TArray<FString> InfoMessages;
|
|
bool bSuccess = RunCommand(TEXT("pull"), InPathToGitBinary, InPathToRepositoryRoot, { "--rebase", "--autostash" }, FGitSourceControlModule::GetEmptyStringArray(),
|
|
InfoMessages, OutErrorMessages);
|
|
|
|
if (bShouldReload)
|
|
{
|
|
const auto ReloadPackagesResult = Async(EAsyncExecution::TaskGraphMainThread, [=] {
|
|
TArray<UPackage*> Packages = PackagesToReload;
|
|
ReloadPackages(Packages);
|
|
});
|
|
ReloadPackagesResult.Wait();
|
|
}
|
|
|
|
return bSuccess;
|
|
}
|
|
|
|
TSharedPtr<ISourceControlRevision, ESPMode::ThreadSafe> GetOriginRevisionOnBranch( const FString & InPathToGitBinary, const FString & InRepositoryRoot, const FString & InRelativeFileName, TArray<FString> & OutErrorMessages, const FString & BranchName )
|
|
{
|
|
TGitSourceControlHistory OutHistory;
|
|
|
|
TArray< FString > Results;
|
|
TArray< FString > Parameters;
|
|
Parameters.Add( BranchName );
|
|
Parameters.Add( TEXT( "--date=raw" ) );
|
|
Parameters.Add( TEXT( "--pretty=medium" ) ); // make sure format matches expected in ParseLogResults
|
|
|
|
TArray< FString > Files;
|
|
const auto bResults = RunCommand( TEXT( "show" ), InPathToGitBinary, InRepositoryRoot, Parameters, Files, Results, OutErrorMessages );
|
|
|
|
if ( bResults )
|
|
{
|
|
ParseLogResults( Results, OutHistory );
|
|
}
|
|
|
|
if ( OutHistory.Num() > 0 )
|
|
{
|
|
auto AbsoluteFileName = FPaths::ConvertRelativePathToFull( InRelativeFileName );
|
|
|
|
AbsoluteFileName.RemoveFromStart( InRepositoryRoot );
|
|
|
|
if ( AbsoluteFileName[ 0 ] == '/' )
|
|
{
|
|
AbsoluteFileName.RemoveAt( 0 );
|
|
}
|
|
|
|
OutHistory[ 0 ]->Filename = AbsoluteFileName;
|
|
|
|
return OutHistory[ 0 ];
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
} // namespace GitSourceControlUtils
|
|
|
|
#undef LOCTEXT_NAMESPACE
|