diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 6cb88ee80..aac9b3c16 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -52,7 +52,7 @@ parameters: variables: - name: 'GVFSMajorAndMinorVersion' - value: '2.0' + value: '2.1' - name: 'GVFSRevision' value: $(Build.BuildNumber) - name: 'GVFSVersion' diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs index 4eb360808..69edf16d7 100644 --- a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -286,6 +286,28 @@ public void Dehydrate_FullCommandLine_ParsesCorrectly() Assert.That(parseResult.Errors, Is.Empty, "Full dehydrate command with --confirm --folders should parse without errors"); } + [Test] + public void Dehydrate_DiscardBackup_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "dehydrate", "--confirm", "--full", "--discard-backup" }); + Assert.That(parseResult.Errors, Is.Empty, "dehydrate --confirm --full --discard-backup should parse without errors"); + } + + [Test] + public void Dehydrate_PruneBackups_IsSubcommand() + { + var dehydrate = FindSubcommand("dehydrate"); + var pruneBackups = dehydrate.Subcommands.FirstOrDefault(c => c.Name == "prune-backups"); + Assert.That(pruneBackups, Is.Not.Null, "dehydrate should have a 'prune-backups' subcommand"); + } + + [Test] + public void Dehydrate_PruneBackups_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "dehydrate", "prune-backups" }); + Assert.That(parseResult.Errors, Is.Empty, "dehydrate prune-backups should parse without errors"); + } + [Test] public void Service_FullCommandLine_ParsesCorrectly() { @@ -353,7 +375,7 @@ public void Clone_HasAllExpectedOptions() [Test] public void Dehydrate_HasAllExpectedOptions() { - var expected = new[] { "--confirm", "--no-status", "--folders" }; + var expected = new[] { "--confirm", "--no-status", "--folders", "--full", "--discard-backup" }; foreach (var optName in expected) { Assert.That(FindOptionOnCommand("dehydrate", optName), Is.Not.Null, diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 62447659e..94dc8248a 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -213,10 +213,11 @@ public Request() { } - public Request(string backupFolderPath, string folders) + public Request(string backupFolderPath, string folders, bool discardBackup = false) { this.Folders = folders; this.BackupFolderPath = backupFolderPath; + this.DiscardBackup = discardBackup; } public static Request FromMessage(Message message) @@ -228,6 +229,12 @@ public static Request FromMessage(Message message) public string BackupFolderPath { get; set; } + /// + /// When true, the mount deletes the backup folder during its unmounted window + /// (the moved ProjFS placeholders can only be deleted while the repo is unmounted). + /// + public bool DiscardBackup { get; set; } + public Message CreateMessage() { return new Message(Dehydrate, GVFSJsonOptions.Serialize(this)); @@ -253,6 +260,12 @@ public Response(string result) public List SuccessfulFolders { get; set; } public List FailedFolders { get; set; } + /// + /// True if the backup folder was requested to be discarded and was successfully + /// deleted during the unmounted window. + /// + public bool BackupDiscarded { get; set; } + public static Response FromMessage(Message message) { return GVFSJsonOptions.Deserialize(message.Body); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 09892fa6d..7659fc3ad 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -14,7 +14,6 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { [TestFixture] - [SkipInCI("Atrophied: folder dehydrate behavior changed, expectations need updating")] public class DehydrateTests : TestsWithEnlistmentPerFixture { private const string FolderDehydrateSuccessfulMessage = "folder dehydrate successful."; @@ -58,12 +57,14 @@ public void DehydrateShouldSucceedInCommonCase() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FullDehydrateShouldExitWithoutConfirm() { this.DehydrateShouldSucceed(new[] { "To actually execute the dehydrate, run 'gvfs dehydrate --confirm --full'" }, confirm: false, noStatus: false, full: true); } [TestCase] + [SkipInCI("Shared EnlistmentPerFixture cannot run consecutive full dehydrates reliably (only the first succeeds; later backups fail). Needs per-test-case enlistment; triage in follow-up.")] public void FullDehydrateShouldSucceedInCommonCase() { this.DehydrateShouldSucceed(new[] { "The repo was successfully dehydrated and remounted" }, confirm: true, noStatus: false, full: true); @@ -77,6 +78,7 @@ public void DehydrateShouldFailOnUnmountedRepoWithStatus() } [TestCase] + [SkipInCI("Shared EnlistmentPerFixture cannot run consecutive full dehydrates reliably (only the first succeeds; later backups fail). Needs per-test-case enlistment; triage in follow-up.")] public void DehydrateShouldSucceedEvenIfObjectCacheIsDeleted() { this.Enlistment.UnmountGVFS(); @@ -85,6 +87,7 @@ public void DehydrateShouldSucceedEvenIfObjectCacheIsDeleted() } [TestCase] + [SkipInCI("Atrophied: backup src now includes an extra 'databases' folder; expected layout is out of date. Fix in follow-up.")] public void DehydrateShouldBackupFiles() { this.DehydrateShouldSucceed(new[] { "The repo was successfully dehydrated and remounted" }, confirm: true, noStatus: false, full: true); @@ -106,6 +109,72 @@ public void DehydrateShouldBackupFiles() this.DirectoryShouldContain(gvfsDatabasesFolder, "BackgroundGitOperations.dat", "ModifiedPaths.dat", "VFSForGit.sqlite"); } + [TestCase] + public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() + { + // Hydrate files under the folder first so the dehydrate moves real ProjFS placeholders + // into the backup. Those placeholders can only be deleted while the repo is unmounted, + // which is exactly what --discard-backup does (it deletes during the dehydrate's + // unmounted window). Without hydration the backup has no placeholders and would not + // exercise this path. + this.HydrateFolder("GVFS"); + + this.DehydrateShouldSucceed( + new[] { "folder dehydrate successful", "(--discard-backup)" }, + confirm: true, + noStatus: false, + full: false, + discardBackup: true, + foldersToDehydrate: "GVFS"); + + string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + backupFolder.ShouldNotExistOnDisk(this.fileSystem); + } + + [TestCase] + public void DehydratePruneBackupsShouldDeleteExistingBackupsWhenUnmounted() + { + this.HydrateFolder("GVFS"); + + this.DehydrateShouldSucceed(new[] { "folder dehydrate successful" }, confirm: true, noStatus: false, full: false, foldersToDehydrate: "GVFS"); + + string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + backupFolder.ShouldBeADirectory(this.fileSystem); + + // A backup with moved placeholders can only be pruned while unmounted. + this.Enlistment.UnmountGVFS(); + + ProcessResult result = this.RunDehydratePruneBackupsProcess(); + result.ExitCode.ShouldEqual(0, $"prune-backups exit code was {result.ExitCode}. Output: {result.Output}"); + result.Output.ShouldContain(new[] { "Pruned" }); + + backupFolder.ShouldNotExistOnDisk(this.fileSystem); + + this.Enlistment.MountGVFS(); + } + + [TestCase] + public void DehydratePruneBackupsWhileMountedReportsUnmountGuidance() + { + this.HydrateFolder("GVFS"); + + this.DehydrateShouldSucceed(new[] { "folder dehydrate successful" }, confirm: true, noStatus: false, full: false, foldersToDehydrate: "GVFS"); + + string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + backupFolder.ShouldBeADirectory(this.fileSystem); + + // Pruning while mounted cannot delete the placeholder backup; the verb should fail and + // tell the user to unmount first. + ProcessResult result = this.RunDehydratePruneBackupsProcess(); + result.ExitCode.ShouldEqual(GVFSGenericError, $"prune-backups should fail while mounted. Output: {result.Output}"); + result.Output.ShouldContain(new[] { "Run 'gvfs unmount'" }); + + // Clean up the leftover backup while unmounted so it does not leak into later tests. + this.Enlistment.UnmountGVFS(); + RepositoryHelpers.DeleteTestDirectory(backupFolder); + this.Enlistment.MountGVFS(); + } + [TestCase] public void DehydrateShouldFailIfLocalCacheNotInMetadata() { @@ -182,6 +251,7 @@ public void DehydrateShouldFailOnWrongDiskLayoutVersion() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateFolderThatWasEnumerated() { string folderToDehydrate = "GVFS"; @@ -200,6 +270,7 @@ public void FolderDehydrateFolderThatWasEnumerated() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateFolderWithFilesThatWerePlaceholders() { string folderToDehydrate = "GVFS"; @@ -220,6 +291,7 @@ public void FolderDehydrateFolderWithFilesThatWerePlaceholders() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateFolderWithFilesThatWereRead() { string folderToDehydrate = "GVFS"; @@ -238,6 +310,7 @@ public void FolderDehydrateFolderWithFilesThatWereRead() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateFolderWithFilesThatWereWrittenTo() { string folderToDehydrate = "GVFS"; @@ -257,6 +330,7 @@ public void FolderDehydrateFolderWithFilesThatWereWrittenTo() } [TestCase] + [SkipInCI("Hangs locally (ProjFS 'provider temporarily unavailable'). Triage in follow-up.")] public void FolderDehydrateFolderThatWasDeleted() { string folderToDehydrate = "Scripts"; @@ -274,6 +348,7 @@ public void FolderDehydrateFolderThatWasDeleted() } [TestCase] + [SkipInCI("Atrophied/flaky: locked-folder dehydrate returns exit 7 with ProjFS teardown errors. Fix in follow-up.")] public void FolderDehydrateFolderThatIsLocked() { const string folderToDehydrate = "GVFS"; @@ -330,6 +405,7 @@ public void FolderDehydrateFolderThatIsSubstringOfExistingFolder() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateNestedFoldersChildBeforeParent() { string parentFolderToDehydrate = "GVFS"; @@ -354,6 +430,7 @@ public void FolderDehydrateNestedFoldersChildBeforeParent() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateNestedFoldersParentBeforeChild() { string parentFolderToDehydrate = "GVFS"; @@ -378,6 +455,7 @@ public void FolderDehydrateNestedFoldersParentBeforeChild() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateParentFolderInModifiedPathsShouldOutputMessage() { string folderToDehydrateParentFolder = "GitCommandsTests"; @@ -404,6 +482,7 @@ public void FolderDehydrateDirtyStatusShouldFail() } [TestCase] + [SkipInCI("Atrophied: dehydrate with --no-status now succeeds (exit 0) instead of failing (exit 3). Fix in follow-up.")] public void FolderDehydrateDirtyStatusWithNoStatusShouldFail() { string folderToDehydrate = "GVFS"; @@ -423,6 +502,7 @@ public void FolderDehydrateCannotDehydrateDotGitFolder() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydratePreviouslyDeletedFolders() { string folderToDehydrate = "TrailingSlashTests"; @@ -457,6 +537,7 @@ public void FolderDehydratePreviouslyDeletedFolders() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateTombstone() { string folderToDehydrate = "TrailingSlashTests"; @@ -472,6 +553,7 @@ public void FolderDehydrateTombstone() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateRelativePaths() { string[] foldersToDehydrate = new[] @@ -496,6 +578,7 @@ public void FolderDehydrateRelativePaths() } [TestCase] + [SkipInCI("Atrophied: dehydrating a nonexistent folder now reports success instead of an error. Fix in follow-up.")] public void FolderDehydrateFolderThatDoesNotExist() { string folderToDehydrate = "DoesNotExist"; @@ -503,6 +586,7 @@ public void FolderDehydrateFolderThatDoesNotExist() } [TestCase] + [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateNewlyCreatedFolderAndFile() { string folderToDehydrate = "NewFolder"; @@ -570,9 +654,9 @@ private void CheckDehydratedFolderAfterUnmount(string path) } } - private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, bool noStatus, bool full = false, params string[] foldersToDehydrate) + private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, bool noStatus, bool full = false, bool discardBackup = false, params string[] foldersToDehydrate) { - ProcessResult result = this.RunDehydrateProcess(confirm, noStatus, full, foldersToDehydrate); + ProcessResult result = this.RunDehydrateProcess(confirm, noStatus, full, discardBackup, foldersToDehydrate); result.ExitCode.ShouldEqual(0, $"mount exit code was {result.ExitCode}. Output: {result.Output}"); if (result.Output.Contains("Failed to move the src folder: Access to the path")) @@ -586,12 +670,12 @@ private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, boo private void DehydrateShouldFail(string[] expectedErrorMessages, bool noStatus, bool full = false, params string[] foldersToDehydrate) { - ProcessResult result = this.RunDehydrateProcess(confirm: true, noStatus: noStatus, full: full, foldersToDehydrate: foldersToDehydrate); + ProcessResult result = this.RunDehydrateProcess(confirm: true, noStatus: noStatus, full: full, discardBackup: false, foldersToDehydrate: foldersToDehydrate); result.ExitCode.ShouldEqual(GVFSGenericError, $"mount exit code was not {GVFSGenericError}"); result.Output.ShouldContain(expectedErrorMessages); } - private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full = false, params string[] foldersToDehydrate) + private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full = false, bool discardBackup = false, params string[] foldersToDehydrate) { string dehydrateFlags = string.Empty; if (confirm) @@ -609,6 +693,11 @@ private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full dehydrateFlags += " --full "; } + if (discardBackup) + { + dehydrateFlags += " --discard-backup "; + } + if (foldersToDehydrate.Length > 0) { dehydrateFlags += $" --folders {string.Join(";", foldersToDehydrate)}"; @@ -626,6 +715,42 @@ private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full return ProcessHelper.Run(processInfo); } + private ProcessResult RunDehydratePruneBackupsProcess() + { + ProcessStartInfo processInfo = new ProcessStartInfo(GVFSTestConfig.PathToGVFS); + processInfo.Arguments = "dehydrate prune-backups " + TestConstants.InternalUseOnlyFlag + " " + GVFSHelpers.GetInternalParameter(); + processInfo.WindowStyle = ProcessWindowStyle.Hidden; + processInfo.WorkingDirectory = this.Enlistment.EnlistmentRoot; + processInfo.UseShellExecute = false; + processInfo.RedirectStandardOutput = true; + + return ProcessHelper.Run(processInfo); + } + + // Hydrate some files under the given root-level folder so that a subsequent folder + // dehydrate moves real ProjFS placeholders into the backup. + private void HydrateFolder(string folder) + { + string folderPath = Path.Combine(this.Enlistment.RepoRoot, folder); + int hydrated = 0; + foreach (string file in Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories)) + { + try + { + File.ReadAllBytes(file); + hydrated++; + } + catch + { + } + + if (hydrated >= 25) + { + break; + } + } + } + private SafeFileHandle OpenFolderHandle(string path) { return NativeMethods.CreateFile( diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index b88af37fc..b98ce1b46 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -838,6 +838,27 @@ private List BackupFoldersWhileUnmounted(NamedPipeMessages.DehydrateFold continue; } } + + // The moved folders are ProjFS placeholders now outside the virtualization root. + // They can only be deleted while the repo is unmounted, so if the caller asked to + // discard the backup, delete it now (before we remount below). BackupFolderPath is + // the backup's 'src' folder; its parent is the backup root that holds everything. + if (request.DiscardBackup) + { + string backupRoot = Path.GetDirectoryName(request.BackupFolderPath); + try + { + this.context.FileSystem.DeleteDirectory(backupRoot); + response.BackupDiscarded = true; + } + catch (Exception ex) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("Exception", ex.ToString()); + metadata.Add("backupRoot", backupRoot); + this.tracer.RelatedWarning(metadata, $"{nameof(this.BackupFoldersWhileUnmounted)}: Failed to discard backup folder."); + } + } } finally { diff --git a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs new file mode 100644 index 000000000..1bc8f6952 --- /dev/null +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -0,0 +1,138 @@ +using GVFS.Common; +using GVFS.Common.FileSystem; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using System; +using System.IO; +using System.Linq; + +namespace GVFS.CommandLine +{ + public class DehydratePruneBackupsVerb : GVFSVerb.ForExistingEnlistment + { + private const string PruneBackupsVerbName = "prune-backups"; + + private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); + + public DehydratePruneBackupsVerb() + : base(validateOrigin: false) + { + } + + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command( + PruneBackupsVerbName, + "Delete backup folders left by previous 'gvfs dehydrate' runs. This does not perform a dehydrate. The repo must be unmounted, because the backups contain virtualization placeholders that can only be deleted while unmounted."); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + + protected override string VerbName + { + get { return PruneBackupsVerbName; } + } + + protected override void Execute(GVFSEnlistment enlistment) + { + using (JsonTracer tracer = new JsonTracer(GVFSConstants.GVFSEtwProviderName, "PruneBackups")) + { + tracer.AddLogFileEventListener( + GVFSEnlistment.GetNewGVFSLogFileName(enlistment.GVFSLogsRoot, GVFSConstants.LogFileTypes.Dehydrate), + EventLevel.Informational, + Keywords.Any); + + string backupParent = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, DehydrateVerb.BackupFolderName)); + + if (!this.fileSystem.DirectoryExists(backupParent)) + { + this.Output.WriteLine($"No backups to prune. No backup folder was found at {backupParent}."); + return; + } + + string[] backups = Directory.GetDirectories(backupParent); + if (backups.Length == 0) + { + this.Output.WriteLine($"No backups to prune under {backupParent}."); + this.TryDeleteDirectory(backupParent, out _); + return; + } + + int deleted = 0; + bool reportedMountedFailure = false; + foreach (string backup in backups) + { + if (this.TryDeleteDirectory(backup, out Exception exception)) + { + this.WriteMessage(tracer, $"Deleted backup folder {backup}."); + deleted++; + } + else + { + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backup}: {exception?.Message}"); + + // A backup can contain ProjFS placeholders that can only be deleted while + // the repo is unmounted. If we're still mounted, that is the likely cause; + // tell the user rather than leaving them guessing. + if (!reportedMountedFailure && this.IsRepoMounted(enlistment)) + { + reportedMountedFailure = true; + this.WriteMessage(tracer, "The repo is currently mounted. Backups contain virtualization placeholders that can only be deleted while the repo is unmounted. Run 'gvfs unmount', then re-run 'gvfs dehydrate prune-backups'."); + } + } + } + + this.TryRemoveEmptyParent(backupParent); + + this.WriteMessage(tracer, $"Pruned {deleted} of {backups.Length} backup folder(s)."); + + if (deleted != backups.Length) + { + this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Failed to delete {backups.Length - deleted} backup folder(s)."); + } + } + } + + private bool IsRepoMounted(GVFSEnlistment enlistment) + { + using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + { + return pipeClient.Connect(); + } + } + + private void TryRemoveEmptyParent(string backupParent) + { + if (this.fileSystem.DirectoryExists(backupParent) && + !Directory.EnumerateFileSystemEntries(backupParent).Any()) + { + this.TryDeleteDirectory(backupParent, out _); + } + } + + private bool TryDeleteDirectory(string path, out Exception exception) + { + return this.fileSystem.TryDeleteDirectory(path, out exception); + } + + private void WriteMessage(ITracer tracer, string message) + { + this.Output.WriteLine(message); + tracer.RelatedEvent( + EventLevel.Informational, + "PruneBackups", + new EventMetadata + { + { TracingConstants.MessageKey.InfoMessage, message } + }); + } + } +} diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce30357..6950404d2 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -21,6 +21,8 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment private const string DehydrateVerbName = "dehydrate"; private const string FolderListSeparator = ";"; + internal const string BackupFolderName = "dehydrate_backup"; + private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); public bool Confirmed { get; set; } @@ -31,6 +33,8 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment public bool Full { get; set; } + public bool DiscardBackup { get; set; } + public string RunningVerbName { get; set; } = DehydrateVerbName; public string ActionName { get; set; } = DehydrateVerbName; @@ -57,6 +61,9 @@ public static System.CommandLine.Command CreateCommand() System.CommandLine.Option fullOption = new System.CommandLine.Option("--full") { Description = "Perform a full dehydration that unmounts, backs up the entire src folder, and re-creates the virtualization root from scratch." }; cmd.Add(fullOption); + System.CommandLine.Option discardBackupOption = new System.CommandLine.Option("--discard-backup") { Description = "Delete the backup folder after a successful dehydrate instead of keeping it. The backup is still created during the operation for safety and is only removed once the dehydrate succeeds." }; + cmd.Add(discardBackupOption); + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); @@ -67,8 +74,13 @@ public static System.CommandLine.Command CreateCommand() verb.NoStatus = result.GetValue(noStatusOption); verb.Folders = result.GetValue(foldersOption) ?? ""; verb.Full = result.GetValue(fullOption); + verb.DiscardBackup = result.GetValue(discardBackupOption); }); + // 'prune-backups' is a sub-verb of 'dehydrate' so it clearly only deletes backups + // left by previous dehydrate runs and never performs a dehydrate itself. + cmd.Add(DehydratePruneBackupsVerb.CreateCommand()); + return cmd; } @@ -99,6 +111,7 @@ protected override void Execute(GVFSEnlistment enlistment) { "Confirmed", this.Confirmed }, { "NoStatus", this.NoStatus }, { "Full", this.Full }, + { "DiscardBackup", this.DiscardBackup }, { "NamedPipeName", enlistment.NamedPipeName }, { "Folders", this.Folders }, { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, @@ -202,7 +215,7 @@ from a parent of the folders list. bool cleanStatus = this.StatusChecked || this.CheckGitStatus(tracer, enlistment, fullDehydrate); - string backupRoot = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, "dehydrate_backup", DateTime.Now.ToString("yyyyMMdd_HHmmss"))); + string backupRoot = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, BackupFolderName, DateTime.Now.ToString("yyyyMMdd_HHmmss"))); this.Output.WriteLine(); if (fullDehydrate) @@ -333,10 +346,11 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri this.ReportErrorAndExit(tracer, $"{this.ActionName} for folders failed."); } + NamedPipeMessages.DehydrateFolders.Response response = null; if (foldersToDehydrate.Count > 0) { string backupSrc = GetBackupSrcPath(backupRoot); - this.SendDehydrateMessage(tracer, enlistment, folderErrors, foldersToDehydrate, backupSrc); + response = this.SendDehydrateMessage(tracer, enlistment, folderErrors, foldersToDehydrate, backupSrc); } if (folderErrors.Count > 0) @@ -348,6 +362,26 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri this.ReportErrorAndExit(tracer, ReturnCode.DehydrateFolderFailures, $"Failed to dehydrate {folderErrors.Count} folder(s)."); } + + if (foldersToDehydrate.Count > 0) + { + // For a folder dehydrate the mount deletes the backup during its unmounted window + // (see BackupFoldersWhileUnmounted). Report the outcome here. + if (this.DiscardBackup) + { + bool discarded = response != null && response.BackupDiscarded; + if (discarded) + { + this.RemoveEmptyBackupParent(tracer, backupRoot); + } + + this.ReportDiscardResult(tracer, backupRoot, discarded); + } + else + { + this.ReportRetainedBackup(tracer, backupRoot); + } + } } private static string GetBackupSrcPath(string backupRoot) @@ -401,7 +435,7 @@ private bool IsFolderValid(string folderPath) return true; } - private void SendDehydrateMessage( + private NamedPipeMessages.DehydrateFolders.Response SendDehydrateMessage( ITracer tracer, GVFSEnlistment enlistment, List folderErrors, @@ -426,7 +460,8 @@ private void SendDehydrateMessage( NamedPipeMessages.DehydrateFolders.Request request = new NamedPipeMessages.DehydrateFolders.Request( folders: string.Join(";", folders), - backupFolderPath: backupFolder); + backupFolderPath: backupFolder, + discardBackup: this.DiscardBackup); pipeClient.SendRequest(request.CreateMessage()); response = NamedPipeMessages.DehydrateFolders.Response.FromMessage(NamedPipeMessages.Message.FromString(pipeClient.ReadRawResponse())); } @@ -449,6 +484,8 @@ private void SendDehydrateMessage( folderErrors.Add(folder); } } + + return response; } private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, string backupRoot, RetryConfig retryConfig) @@ -461,6 +498,15 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri // Converting the src folder to partial must be the final step before mount this.PrepareSrcFolder(tracer, enlistment); + // The backup holds the old src folder's ProjFS placeholders, which can only be + // deleted while the repo is unmounted. We are still unmounted here (Mount is + // called below), so discard the backup now if requested. + bool backupDiscarded = false; + if (this.DiscardBackup) + { + backupDiscarded = this.TryDeleteBackup(tracer, backupRoot); + } + // We can skip the version check if git status was run because git status requires // that the repo already be mounted (meaning we don't need to perform another version check again) this.Mount( @@ -469,6 +515,20 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri this.Output.WriteLine(); this.WriteMessage(tracer, "The repo was successfully dehydrated and remounted"); + + if (this.DiscardBackup) + { + if (backupDiscarded) + { + this.RemoveEmptyBackupParent(tracer, backupRoot); + } + + this.ReportDiscardResult(tracer, backupRoot, backupDiscarded); + } + else + { + this.ReportRetainedBackup(tracer, backupRoot); + } } } else @@ -871,6 +931,60 @@ private bool TryRecreateIndex(ITracer tracer, GVFSEnlistment enlistment) return true; } + private void ReportRetainedBackup(ITracer tracer, string backupRoot) + { + // Backup management is only exposed by the 'dehydrate' verb. When this code runs + // on behalf of 'gvfs sparse --prune' (RunningVerbName != dehydrate), stay silent so we + // never reference options that verb does not have. + if (this.RunningVerbName != DehydrateVerbName) + { + return; + } + + this.WriteMessage(tracer, $"A backup was saved to {backupRoot}."); + this.WriteMessage(tracer, "To reclaim this space, delete it manually, or re-run 'gvfs dehydrate' with --discard-backup to delete the backup automatically after a successful dehydrate."); + this.WriteMessage(tracer, "To remove backups left by earlier dehydrate runs, run 'gvfs dehydrate prune-backups'."); + } + + private void ReportDiscardResult(ITracer tracer, string backupRoot, bool deleted) + { + if (this.RunningVerbName != DehydrateVerbName) + { + return; + } + + if (deleted) + { + this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); + } + else + { + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}. Run 'gvfs dehydrate prune-backups' after unmounting the repo to remove it."); + } + } + + private bool TryDeleteBackup(ITracer tracer, string backupRoot) + { + return this.TryIO(tracer, () => this.fileSystem.DeleteDirectory(backupRoot), $"Discard backup folder {backupRoot}", out _); + } + + private void RemoveEmptyBackupParent(ITracer tracer, string backupRoot) + { + string backupParent = Path.GetDirectoryName(backupRoot); + this.TryIO( + tracer, + () => + { + if (this.fileSystem.DirectoryExists(backupParent) && + !Directory.EnumerateFileSystemEntries(backupParent).Any()) + { + this.fileSystem.DeleteDirectory(backupParent); + } + }, + $"Remove empty backup folder {backupParent}", + out _); + } + private void WriteMessage(ITracer tracer, string message) { this.Output.WriteLine(message);