From f0243fea5d4c7143456a1fb11fbfb004496809df Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 18 Dec 2025 15:11:40 -0800 Subject: [PATCH] Streamline mount/unmount for dehydrating folders In #1890 I demonstrated that it's possible to dehydrate folders without unmounting at all. Unfortunately that requires deleting all the placeholders and hydrated files which is too slow for dehydrating anything substantial. The current implementation avoids this by moving instead of deleting (which has the additional benefit of providing a backup) but ProjFS doesn't support moving or renaming folders, so we can't do that while mounted. This pull request takes a different approach to reducing the overhead of unmounting for dehydration. Instead of unmounting, moving, and remounting from the dehydrate verb, those steps are moved into the mount process under its dehydrate message handler. The mount process only disposes and recreates the components required for virtualization, avoiding several costly steps (eg authentication with Azure DevOps, verification of the cache server, verification of ProjFS installation). For the repo I work in, dehydrating a top-level directory is reduced from 33 seconds to 11 seconds with this change. Specific changes: * Backup of non-src folders (.git, .gvfs) is added to dehydrate folders. Previously it was only done for full dehydrate. * Unmount, move/backup of folders, and mount are moved from DehydrateVerb to InProcessMount. To support this, the DehydrateFolders message has the backup folder added to its fields. * The core methods of Mount and Unmount have a parameter added to skip disposing (on unmount) and initialization (on mount) of certain components which are ok to leave alive during the temporary unmount. * Ownership of GVFSContext disposal fixed - FileSystemCallbacks was disposing it despite not owning it. * Missing disposal of a file stream in BackgroundFileSystemTaskRunner is fixed. * WindowsFileSystemVirtualizer.DehydrateFolder will now delete a tombstone file for the directory if present. This allows us to support fixing a directory that the user manually deleted while mounted (perhaps in a misguided attempt to dehydrate it), though that would typically require running 'gvfs dehydrate --no-status' to skip verifying that the working directory matches the index. * '--no-status' is now supported with '--folders' --- .../FileSystem/PhysicalFileSystem.cs | 5 + .../NamedPipes/NamedPipeMessages.cs | 11 +- GVFS/GVFS.Mount/InProcessMount.cs | 98 ++++-- .../BackgroundFileSystemTaskRunner.cs | 5 + .../FileSystemCallbacks.cs | 6 - GVFS/GVFS/CommandLine/DehydrateVerb.cs | 325 ++++++++++++------ 6 files changed, 306 insertions(+), 144 deletions(-) diff --git a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs index 90bfd89137..3b1ebe2675 100644 --- a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs +++ b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs @@ -49,6 +49,11 @@ public virtual void DeleteDirectory(string path, bool recursive = true, bool ign } } + public virtual void MoveDirectory(string sourceDirName, string destDirName) + { + Directory.Move(sourceDirName, destDirName); + } + public virtual void CopyDirectoryRecursive( string srcDirectoryPath, string dstDirectoryPath, diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 489ee05d74..fafb4e7d19 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -203,21 +203,24 @@ public static class DehydrateFolders public class Request { - public Request(string folders) + public Request(string backupFolderPath, string folders) { this.Folders = folders; + this.BackupFolderPath = backupFolderPath; } - public Request(Message message) + public static Request FromMessage(Message message) { - this.Folders = message.Body; + return JsonConvert.DeserializeObject(message.Body); } public string Folders { get; } + public string BackupFolderPath { get; } + public Message CreateMessage() { - return new Message(Dehydrate, this.Folders); + return new Message(Dehydrate, JsonConvert.SerializeObject(this)); } } diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 52426075bb..e6d43a842c 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -295,7 +295,7 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne private void HandleDehydrateFolders(NamedPipeMessages.Message message, NamedPipeServer.Connection connection) { - NamedPipeMessages.DehydrateFolders.Request request = new NamedPipeMessages.DehydrateFolders.Request(message); + NamedPipeMessages.DehydrateFolders.Request request = NamedPipeMessages.DehydrateFolders.Request.FromMessage(message); EventMetadata metadata = new EventMetadata(); metadata.Add(nameof(request.Folders), request.Folders); @@ -308,7 +308,9 @@ private void HandleDehydrateFolders(NamedPipeMessages.Message message, NamedPipe response = new NamedPipeMessages.DehydrateFolders.Response(NamedPipeMessages.DehydrateFolders.DehydratedResult); string[] folders = request.Folders.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); StringBuilder resetFolderPaths = new StringBuilder(); - foreach (string folder in folders) + List movedFolders = BackupFoldersWhileUnmounted(request, response, folders); + + foreach (string folder in movedFolders) { if (this.fileSystemCallbacks.TryDehydrateFolder(folder, out string errorMessage)) { @@ -357,6 +359,50 @@ private void HandleDehydrateFolders(NamedPipeMessages.Message message, NamedPipe connection.TrySendResponse(response.CreateMessage()); } + private List BackupFoldersWhileUnmounted(NamedPipeMessages.DehydrateFolders.Request request, NamedPipeMessages.DehydrateFolders.Response response, string[] folders) + { + /* We can't move folders while the virtual file system is mounted, so unmount it first. + * After moving the folders, remount the virtual file system. + */ + + var movedFolders = new List(); + try + { + /* Set to "Mounting" instead of "Unmounting" so that incoming requests + * that are rejected will know they can try again soon. + */ + this.currentState = MountState.Mounting; + this.UnmountAndStopWorkingDirectoryCallbacks(willRemountInSameProcess: true); + foreach (string folder in folders) + { + try + { + var source = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, folder); + var destination = Path.Combine(request.BackupFolderPath, folder); + var destinationParent = Path.GetDirectoryName(destination); + this.context.FileSystem.CreateDirectory(destinationParent); + if (this.context.FileSystem.DirectoryExists(source)) + { + this.context.FileSystem.MoveDirectory(source, destination); + } + movedFolders.Add(folder); + } + catch (Exception ex) + { + response.FailedFolders.Add($"{folder}\0{ex.Message}"); + continue; + } + } + } + finally + { + this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer, alreadyInitialized: true); + this.currentState = MountState.Ready; + } + + return movedFolders; + } + private void HandleLockRequest(string messageBody, NamedPipeServer.Connection connection) { NamedPipeMessages.AcquireLock.Response response; @@ -551,9 +597,9 @@ private void HandleDownloadObjectRequest(NamedPipeMessages.Message message, Name /* If a commit is downloaded, it wasn't prefetched. * If any prefetch has been done, there is probably a commit in the prefetch packs that is close enough that * loose object download of missing trees will be faster than downloading a pack of all the trees for the commit. - * Otherwise, the trees for the commit may be needed soon depending on the context. + * Otherwise, the trees for the commit may be needed soon depending on the context. * e.g. git log (without a pathspec) doesn't need trees, but git checkout does. - * + * * Save the tree/commit so if more trees are requested we can download all the trees for the commit in a batch. */ this.treesWithDownloadedCommits[treeSha] = objectSha; @@ -596,7 +642,7 @@ private bool ShouldDownloadCommitPack(string objectSha, out string commitSha) private void UpdateTreesForDownloadedCommits(string objectSha) { /* If we are downloading missing trees, we probably are missing more trees for the commit. - * Update our list of trees associated with the commit so we can use the # of missing trees + * Update our list of trees associated with the commit so we can use the # of missing trees * as a heuristic to decide whether to batch download all the trees for the commit the * next time a missing one is requested. */ @@ -723,12 +769,15 @@ private void HandleUnmountRequest(NamedPipeServer.Connection connection) } } - private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache) + private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache, bool alreadyInitialized = false) { string error; - if (!this.context.Enlistment.Authentication.TryInitialize(this.context.Tracer, this.context.Enlistment, out error)) + if (!alreadyInitialized) { - this.FailMountAndExit("Failed to obtain git credentials: " + error); + if (!this.context.Enlistment.Authentication.TryInitialize(this.context.Tracer, this.context.Enlistment, out error)) + { + this.FailMountAndExit("Failed to obtain git credentials: " + error); + } } GitObjectsHttpRequestor objectRequestor = new GitObjectsHttpRequestor(this.context.Tracer, this.context.Enlistment, cache, this.retryConfig); @@ -763,19 +812,22 @@ private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache) }, "Failed to create src folder callback listener"); this.maintenanceScheduler = this.CreateOrReportAndExit(() => new GitMaintenanceScheduler(this.context, this.gitObjects), "Failed to start maintenance scheduler"); - int majorVersion; - int minorVersion; - if (!RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error)) + if (!alreadyInitialized) { - this.FailMountAndExit("Error: {0}", error); - } + int majorVersion; + int minorVersion; + if (!RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error)) + { + this.FailMountAndExit("Error: {0}", error); + } - if (majorVersion != GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion) - { - this.FailMountAndExit( - "Error: On disk version ({0}) does not match current version ({1})", - majorVersion, - GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion); + if (majorVersion != GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion) + { + this.FailMountAndExit( + "Error: On disk version ({0}) does not match current version ({1})", + majorVersion, + GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion); + } } try @@ -794,7 +846,7 @@ private void MountAndStartWorkingDirectoryCallbacks(CacheServerInfo cache) this.heartbeat.Start(); } - private void UnmountAndStopWorkingDirectoryCallbacks() + private void UnmountAndStopWorkingDirectoryCallbacks(bool willRemountInSameProcess = false) { if (this.maintenanceScheduler != null) { @@ -817,6 +869,12 @@ private void UnmountAndStopWorkingDirectoryCallbacks() this.gvfsDatabase?.Dispose(); this.gvfsDatabase = null; + + if (!willRemountInSameProcess) + { + this.context?.Dispose(); + this.context = null; + } } } } \ No newline at end of file diff --git a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs index 0b6be89940..88ea897098 100644 --- a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs +++ b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs @@ -131,6 +131,11 @@ protected void Dispose(bool disposing) this.backgroundThread.Dispose(); this.backgroundThread = null; } + if (this.backgroundTasks != null) + { + this.backgroundTasks.Dispose(); + this.backgroundTasks = null; + } } } diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index e7737ccba8..8a50f030aa 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -227,12 +227,6 @@ public void Dispose() this.backgroundFileSystemTaskRunner.Dispose(); this.backgroundFileSystemTaskRunner = null; } - - if (this.context != null) - { - this.context.Dispose(); - this.context = null; - } } public bool IsReadyForExternalAcquireLockRequests(NamedPipeMessages.LockData requester, out string denyMessage) diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index b739b9b2df..01a7dec5ea 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -119,15 +119,15 @@ protected override void Execute(GVFSEnlistment enlistment) this.Output.WriteLine( $@"WARNING: THIS IS AN EXPERIMENTAL FEATURE -Dehydrate will back up your src folder, and then create a new, empty src folder -with a fresh virtualization of the repo. All of your downloaded objects, branches, -and siblings of the src folder will be preserved. Your modified working directory -files will be moved to the backup, and your new working directory will not have +Dehydrate will back up your src folder, and then create a new, empty src folder +with a fresh virtualization of the repo. All of your downloaded objects, branches, +and siblings of the src folder will be preserved. Your modified working directory +files will be moved to the backup, and your new working directory will not have any of your uncommitted changes. -Before you dehydrate, make sure you have committed any working directory changes -you want to keep. If you choose not to, you can still find your uncommitted changes -in the backup folder, but it will be harder to find them because 'git status' +Before you dehydrate, make sure you have committed any working directory changes +you want to keep. If you choose not to, you can still find your uncommitted changes +in the backup folder, but it will be harder to find them because 'git status' will not work in the backup. To actually execute the dehydrate, run 'gvfs dehydrate --confirm' from {enlistment.EnlistmentRoot}. @@ -144,8 +144,9 @@ will not work in the backup. will be preserved. This will remove the folders specified and any working directory files and folders even if ignored by git similar to 'git clean -xdf '. -Before you dehydrate, you will have to commit any working directory changes -you want to keep and have a clean 'git status'. +Before you dehydrate, you will have to commit any working directory changes +you want to keep and have a clean 'git status', or run with --no-status to +undo any uncommitted changes. To actually execute the dehydrate, run 'gvfs dehydrate --confirm --folders ' from a parent of the folders list. @@ -161,12 +162,6 @@ from a parent of the folders list. return; } - if (this.NoStatus && !fullDehydrate) - { - this.ReportErrorAndExit(tracer, "Dehydrate --no-status not valid with --folders"); - return; - } - bool cleanStatus = this.StatusChecked || this.CheckGitStatus(tracer, enlistment, fullDehydrate); string backupRoot = Path.GetFullPath(Path.Combine(enlistment.EnlistmentRoot, "dehydrate_backup", DateTime.Now.ToString("yyyyMMdd_HHmmss"))); @@ -176,21 +171,25 @@ from a parent of the folders list. { this.WriteMessage(tracer, $"Starting {this.RunningVerbName}. All of your existing files will be backed up in " + backupRoot); } + else + { + this.WriteMessage(tracer, $"Starting {this.RunningVerbName}. Selected folders will be backed up in " + backupRoot); + } this.WriteMessage(tracer, $"WARNING: If you abort the {this.RunningVerbName} after this point, the repo may become corrupt"); this.Output.WriteLine(); - this.Unmount(tracer); - - string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.EnlistmentRoot, out error)) - { - this.ReportErrorAndExit(tracer, error); - } - if (fullDehydrate) { + this.Unmount(tracer); + + string error; + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.EnlistmentRoot, out error)) + { + this.ReportErrorAndExit(tracer, error); + } + RetryConfig retryConfig; if (!RetryConfig.TryLoadFromGitConfig(tracer, enlistment, out retryConfig, out error)) { @@ -216,7 +215,7 @@ from a parent of the folders list. { if (cleanStatus) { - this.DehydrateFolders(tracer, enlistment, folders); + this.DehydrateFolders(tracer, enlistment, folders, backupRoot); } else { @@ -231,8 +230,15 @@ from a parent of the folders list. } } - private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, string[] folders) + private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, string[] folders, string backupRoot) { + if (!this.TryBackupNonSrcFiles(tracer, enlistment, backupRoot)) + { + this.Output.WriteLine(); + this.WriteMessage(tracer, "ERROR: Backup failed. "); + return; + } + List foldersToDehydrate = new List(); List folderErrors = new List(); @@ -241,7 +247,7 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri { if (!ModifiedPathsDatabase.TryLoadOrCreate( tracer, - Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.ModifiedPaths), + Path.Combine(GetBackupDatabasesPath(backupRoot), GVFSConstants.DotGVFS.Databases.ModifiedPaths), this.fileSystem, out ModifiedPathsDatabase modifiedPaths, out string error)) @@ -271,26 +277,13 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri else { string fullPath = Path.Combine(enlistment.WorkingDirectoryBackingRoot, folder); - if (this.fileSystem.DirectoryExists(fullPath)) + if (!this.fileSystem.DirectoryExists(fullPath)) { - // Since directories are deleted last and will be empty at that point we can skip errors - // while trying to delete it and leave the empty directory and continue to dehydrate - if (!this.TryIO(tracer, () => this.fileSystem.DeleteDirectory(fullPath, ignoreDirectoryDeleteExceptions: true), $"Deleting '{fullPath}'", out ioError)) - { - this.WriteMessage(tracer, $"Cannot {this.ActionName} folder '{folder}': removing '{folder}' failed."); - this.WriteMessage(tracer, "Ensure no applications are accessing the folder and retry."); - this.WriteMessage(tracer, $"More details: {ioError}"); - folderErrors.Add($"{folder}\0{ioError}"); - } - else - { - foldersToDehydrate.Add(folder); - } + this.WriteMessage(tracer, $"Cannot {this.ActionName} folder '{folder}': '{folder}' does not exist."); + foldersToDehydrate.Add(folder); } else { - this.WriteMessage(tracer, $"Cannot {this.ActionName} folder '{folder}': '{folder}' does not exist."); - // Still add to foldersToDehydrate so that any placeholders or modified paths get cleaned up foldersToDehydrate.Add(folder); } @@ -306,16 +299,10 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri this.ReportErrorAndExit(tracer, $"{this.ActionName} for folders failed."); } - // We can skip the version check because dehydrating folders requires that a git status - // be run first, and running git status requires that the repo already be mounted (meaning - // we don't need to perform another version check again) - this.Mount( - tracer, - skipVersionCheck: true); - if (foldersToDehydrate.Count > 0) { - this.SendDehydrateMessage(tracer, enlistment, folderErrors, foldersToDehydrate); + string backupSrc = GetBackupSrcPath(backupRoot); + this.SendDehydrateMessage(tracer, enlistment, folderErrors, foldersToDehydrate, backupSrc); } if (folderErrors.Count > 0) @@ -329,6 +316,11 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri } } + private static string GetBackupSrcPath(string backupRoot) + { + return Path.Combine(backupRoot, "src"); + } + private bool IsFolderValid(string folderPath) { if (folderPath == GVFSConstants.DotGit.Root || @@ -343,7 +335,12 @@ private bool IsFolderValid(string folderPath) return true; } - private void SendDehydrateMessage(ITracer tracer, GVFSEnlistment enlistment, List folderErrors, List folders) + private void SendDehydrateMessage( + ITracer tracer, + GVFSEnlistment enlistment, + List folderErrors, + List folders, + string backupFolder) { NamedPipeMessages.DehydrateFolders.Response response = null; @@ -353,10 +350,17 @@ private void SendDehydrateMessage(ITracer tracer, GVFSEnlistment enlistment, Lis { if (!pipeClient.Connect()) { - this.ReportErrorAndExit("Unable to connect to GVFS. Try running 'gvfs mount'"); + this.Output.WriteLine("Mounting..."); + this.Mount(tracer, skipVersionCheck: false); + if (!pipeClient.Connect()) + { + this.ReportErrorAndExit("Unable to connect to GVFS. Try running 'gvfs mount'"); + } } - NamedPipeMessages.DehydrateFolders.Request request = new NamedPipeMessages.DehydrateFolders.Request(string.Join(FolderListSeparator, folders)); + NamedPipeMessages.DehydrateFolders.Request request = new NamedPipeMessages.DehydrateFolders.Request( + folders: string.Join(";", folders), + backupFolderPath: backupFolder); pipeClient.SendRequest(request.CreateMessage()); response = NamedPipeMessages.DehydrateFolders.Response.FromMessage(NamedPipeMessages.Message.FromString(pipeClient.ReadRawResponse())); } @@ -438,83 +442,83 @@ private void Mount(ITracer tracer, bool skipVersionCheck) private bool CheckGitStatus(ITracer tracer, GVFSEnlistment enlistment, bool fullDehydrate) { - if (!this.NoStatus) + if (NoStatus) { - this.WriteMessage(tracer, $"Running git status before {this.ActionName} to make sure you don't have any pending changes."); - if (fullDehydrate) - { - this.WriteMessage(tracer, $"If this takes too long, you can abort and run {this.RunningVerbName} with --no-status to skip this safety check."); - } + return true; + } - this.Output.WriteLine(); + this.WriteMessage(tracer, $"Running git status before {this.ActionName} to make sure you don't have any pending changes."); + if (fullDehydrate) + { + this.WriteMessage(tracer, $"If this takes too long, you can abort and run {this.RunningVerbName} with --no-status to skip this safety check."); + } + + this.Output.WriteLine(); - bool isMounted = false; - GitProcess.Result statusResult = null; - if (!this.ShowStatusWhileRunning( - () => + bool isMounted = false; + GitProcess.Result statusResult = null; + if (!this.ShowStatusWhileRunning( + () => + { + if (this.ExecuteGVFSVerb(tracer) != ReturnCode.Success) { - if (this.ExecuteGVFSVerb(tracer) != ReturnCode.Success) - { - return false; - } + return false; + } - isMounted = true; + isMounted = true; - GitProcess git = new GitProcess(enlistment); - statusResult = git.Status(allowObjectDownloads: false, useStatusCache: false, showUntracked: true); - if (statusResult.ExitCodeIsFailure) - { - return false; - } + GitProcess git = new GitProcess(enlistment); + statusResult = git.Status(allowObjectDownloads: false, useStatusCache: false, showUntracked: true); + if (statusResult.ExitCodeIsFailure) + { + return false; + } - if (!statusResult.Output.Contains("nothing to commit, working tree clean")) - { - return false; - } + if (!statusResult.Output.Contains("nothing to commit, working tree clean")) + { + return false; + } - return true; - }, - "Running git status", - suppressGvfsLogMessage: true)) - { - this.Output.WriteLine(); + return true; + }, + "Running git status", + suppressGvfsLogMessage: true)) + { + this.Output.WriteLine(); - if (!isMounted) + if (!isMounted) + { + this.WriteMessage(tracer, "Failed to run git status because the repo is not mounted"); + if (fullDehydrate) { - this.WriteMessage(tracer, "Failed to run git status because the repo is not mounted"); - if (fullDehydrate) - { - this.WriteMessage(tracer, "Either mount first, or run with --no-status"); - } + this.WriteMessage(tracer, "Either mount first, or run with --no-status"); } - else if (statusResult.ExitCodeIsFailure) + } + else if (statusResult.ExitCodeIsFailure) + { + this.WriteMessage(tracer, "Failed to run git status: " + statusResult.Errors); + } + else + { + this.WriteMessage(tracer, statusResult.Output); + this.WriteMessage(tracer, "git status reported that you have dirty files"); + if (fullDehydrate) { - this.WriteMessage(tracer, "Failed to run git status: " + statusResult.Errors); + this.WriteMessage(tracer, $"Either commit your changes or run {this.RunningVerbName} with --no-status"); } else { - this.WriteMessage(tracer, statusResult.Output); - this.WriteMessage(tracer, "git status reported that you have dirty files"); - if (fullDehydrate) - { - this.WriteMessage(tracer, $"Either commit your changes or run {this.RunningVerbName} with --no-status"); - } - else - { - this.WriteMessage(tracer, "Either commit your changes or reset and clean your working directory."); - } + this.WriteMessage(tracer, "Either commit your changes or reset and clean your working directory."); } - - this.ReportErrorAndExit(tracer, $"Aborted {this.ActionName}"); - return false; } - else - { - return true; - } - } - return false; + this.ReportErrorAndExit(tracer, $"Aborted {this.ActionName}"); + return false; + } + else + { + return true; + } } private void PrepareSrcFolder(ITracer tracer, GVFSEnlistment enlistment) @@ -535,12 +539,83 @@ private void PrepareSrcFolder(ITracer tracer, GVFSEnlistment enlistment) } } + private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, string backupRoot) + { + string backupSrc = GetBackupSrcPath(backupRoot); + string backupGit = Path.Combine(backupRoot, ".git"); + string backupGvfs = Path.Combine(backupRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + string backupDatabases = GetBackupDatabasesPath(backupGvfs); + + string errorMessage = string.Empty; + if (!this.ShowStatusWhileRunning( + () => + { + string ioError; + if (!this.TryIO(tracer, () => Directory.CreateDirectory(backupRoot), "Create backup directory", out ioError) || + !this.TryIO(tracer, () => Directory.CreateDirectory(backupGit), "Create backup .git directory", out ioError) || + !this.TryIO(tracer, () => Directory.CreateDirectory(backupGvfs), "Create backup .gvfs directory", out ioError) || + !this.TryIO(tracer, () => Directory.CreateDirectory(backupDatabases), "Create backup .gvfs databases directory", out ioError)) + { + errorMessage = "Failed to create backup folders at " + backupRoot + ": " + ioError; + return false; + } + + // ... backup the .gvfs hydration-related data structures... + string databasesFolder = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name); + if (!this.TryCopyFilesInFolder(tracer, databasesFolder, backupDatabases, searchPattern: "*", filenamesToSkip: "RepoMetadata.dat")) + { + return false; + } + + // ... backup everything related to the .git\index... + if (!this.TryIO( + tracer, + () => File.Copy( + Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), + Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)), + "Backup the git index", + out errorMessage) || + !this.TryIO( + tracer, + () => File.Copy( + Path.Combine(enlistment.DotGVFSRoot, GitIndexProjection.ProjectionIndexBackupName), + Path.Combine(backupGvfs, GitIndexProjection.ProjectionIndexBackupName)), + "Backup GVFS_projection", + out errorMessage)) + { + return false; + } + + // ... backup all .git\*.lock files + if (!this.TryCopyFilesInFolder(tracer, enlistment.DotGitRoot, backupGit, searchPattern: "*.lock")) + { + return false; + } + + return true; + }, + "Backing up your files")) + { + this.Output.WriteLine(); + this.WriteMessage(tracer, "ERROR: " + errorMessage); + + return false; + } + + return true; + } + + private static string GetBackupDatabasesPath(string backupGvfs) + { + return Path.Combine(backupGvfs, GVFSConstants.DotGVFS.Databases.Name); + } + private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string backupRoot) { - string backupSrc = Path.Combine(backupRoot, "src"); + string backupSrc = GetBackupSrcPath(backupRoot); string backupGit = Path.Combine(backupRoot, ".git"); string backupGvfs = Path.Combine(backupRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string backupDatabases = Path.Combine(backupGvfs, GVFSConstants.DotGVFS.Databases.Name); + string backupDatabases = GetBackupDatabasesPath(backupRoot); string errorMessage = string.Empty; if (!this.ShowStatusWhileRunning( @@ -638,6 +713,28 @@ private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string ba return true; } + private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + { + string errorMessage; + foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + { + string fileName = Path.GetFileName(file); + if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) + { + if (!this.TryIO( + tracer, + () => File.Copy(file, file.Replace(folderPath, backupPath)), + $"Backing up {Path.GetFileName(file)}", + out errorMessage)) + { + return false; + } + } + } + + return true; + } + private bool TryDownloadGitObjects(ITracer tracer, GVFSEnlistment enlistment, RetryConfig retryConfig) { string errorMessage = null;