From cdb9b81fb509022ddae9ded01bd75eb7e844dc24 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 11:00:21 -0700 Subject: [PATCH 01/12] dehydrate: add --discard-backup and prune-backups sub-verb Backups from 'gvfs dehydrate' accumulate under \dehydrate_backup\ and are never cleaned up, so disk space isn't reclaimed after a successful dehydrate. Add opt-in cleanup: - '--discard-backup' deletes the current run's backup folder after a successful dehydrate (the backup is still created transiently for safety and only removed on success). - 'gvfs dehydrate prune-backups' is a sub-verb that only deletes backups left by previous runs and never performs a dehydrate. When neither is used, the success message now points users at both options. Backup management is scoped to the dehydrate verb, so it does not affect 'gvfs sparse --prune', which reuses DehydrateVerb internally. Adds CLI parse tests (GvfsMainCliTests) and functional tests (DehydrateTests). Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../GvfsMainCliTests.cs | 24 +++- .../EnlistmentPerFixture/DehydrateTests.cs | 54 +++++++- .../CommandLine/DehydratePruneBackupsVerb.cs | 118 ++++++++++++++++++ GVFS/GVFS/CommandLine/DehydrateVerb.cs | 68 +++++++++- 4 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs index 4eb360808e..69edf16d7a 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.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 09892fa6dc..0f60bf24a9 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -106,6 +106,35 @@ public void DehydrateShouldBackupFiles() this.DirectoryShouldContain(gvfsDatabasesFolder, "BackgroundGitOperations.dat", "ModifiedPaths.dat", "VFSForGit.sqlite"); } + [TestCase] + public void FullDehydrateWithDiscardBackupShouldDeleteBackup() + { + this.DehydrateShouldSucceed( + new[] { "The repo was successfully dehydrated and remounted", "(--discard-backup)" }, + confirm: true, + noStatus: false, + full: true, + discardBackup: true); + + string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + backupFolder.ShouldNotExistOnDisk(this.fileSystem); + } + + [TestCase] + public void DehydratePruneBackupsShouldDeleteExistingBackups() + { + this.DehydrateShouldSucceed(new[] { "The repo was successfully dehydrated and remounted" }, confirm: true, noStatus: false, full: true); + + string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + backupFolder.ShouldBeADirectory(this.fileSystem); + + 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); + } + [TestCase] public void DehydrateShouldFailIfLocalCacheNotInMetadata() { @@ -570,9 +599,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 +615,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 +638,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 +660,18 @@ 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); + } + private SafeFileHandle OpenFolderHandle(string path) { return NativeMethods.CreateFile( diff --git a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs new file mode 100644 index 0000000000..52ed524bb5 --- /dev/null +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -0,0 +1,118 @@ +using GVFS.Common; +using GVFS.Common.FileSystem; +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."); + + 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.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(tracer, backupParent); + return; + } + + int deleted = 0; + foreach (string backup in backups) + { + if (this.TryDeleteDirectory(tracer, backup)) + { + this.WriteMessage(tracer, $"Deleted backup folder {backup}."); + deleted++; + } + else + { + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backup}."); + } + } + + // Remove the now-empty parent folder if everything was pruned. + if (!Directory.EnumerateFileSystemEntries(backupParent).Any()) + { + this.TryDeleteDirectory(tracer, backupParent); + } + + this.WriteMessage(tracer, $"Pruned {deleted} of {backups.Length} backup folder(s)."); + } + } + + private bool TryDeleteDirectory(ITracer tracer, string path) + { + try + { + this.fileSystem.DeleteDirectory(path); + return true; + } + catch (Exception e) + { + tracer.RelatedError($"Failed to delete '{path}': {e.Message}"); + return false; + } + } + + 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 28ce303572..ff4ae466b9 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) @@ -348,6 +361,11 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri this.ReportErrorAndExit(tracer, ReturnCode.DehydrateFolderFailures, $"Failed to dehydrate {folderErrors.Count} folder(s)."); } + + if (foldersToDehydrate.Count > 0) + { + this.FinalizeBackup(tracer, backupRoot); + } } private static string GetBackupSrcPath(string backupRoot) @@ -469,6 +487,7 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri this.Output.WriteLine(); this.WriteMessage(tracer, "The repo was successfully dehydrated and remounted"); + this.FinalizeBackup(tracer, backupRoot); } } else @@ -871,6 +890,53 @@ private bool TryRecreateIndex(ITracer tracer, GVFSEnlistment enlistment) return true; } + private void FinalizeBackup(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), leave the backup + // untouched and stay silent so we never reference options that verb does not have. + if (this.RunningVerbName != DehydrateVerbName) + { + return; + } + + if (this.DiscardBackup) + { + if (this.TryIO(tracer, () => this.fileSystem.DeleteDirectory(backupRoot), $"Delete backup folder {backupRoot}", out string error)) + { + this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); + this.TryRemoveEmptyBackupParent(tracer, backupRoot); + } + else + { + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}: {error}"); + } + } + else + { + 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 TryRemoveEmptyBackupParent(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); From 3320d9b9595f20dfad057b35144a96f36bfb6360 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 13:09:48 -0700 Subject: [PATCH 02/12] test: run healthy DehydrateTests in CI, scope [SkipInCI] per-method The DehydrateTests fixture was marked [SkipInCI] at the class level (commit b395f5ec) as atrophied. Ran the full fixture locally to triage it. Move the skip from the fixture to individual tests: - 13 verified-passing tests (including the two new backup tests) now run in CI. - 4 tests are genuinely atrophied (backup layout gained a 'databases' folder; --no-status now exits 0; nonexistent-folder now reports success; locked-folder returns exit 7) and keep [SkipInCI] with specific reasons. - 13 folder tests could not be verified because the fixture hangs locally on ProjFS 'provider temporarily unavailable' after ~15 mount/dehydrate cycles; they keep [SkipInCI] pending triage. The atrophied/unverified tests are addressed in a stacked follow-up. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../EnlistmentPerFixture/DehydrateTests.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 0f60bf24a9..58586af1da 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,6 +57,7 @@ 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); @@ -85,6 +85,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); @@ -211,6 +212,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"; @@ -229,6 +231,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"; @@ -249,6 +252,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"; @@ -267,6 +271,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"; @@ -286,6 +291,7 @@ public void FolderDehydrateFolderWithFilesThatWereWrittenTo() } [TestCase] + [SkipInCI("Hangs locally (ProjFS 'provider temporarily unavailable'). Triage in follow-up.")] public void FolderDehydrateFolderThatWasDeleted() { string folderToDehydrate = "Scripts"; @@ -303,6 +309,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"; @@ -359,6 +366,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"; @@ -383,6 +391,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"; @@ -407,6 +416,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"; @@ -433,6 +443,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"; @@ -452,6 +463,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"; @@ -486,6 +498,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"; @@ -501,6 +514,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[] @@ -525,6 +539,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"; @@ -532,6 +547,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"; From ad181aca6d2231f022753a607bffde4023b31390 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 13:49:28 -0700 Subject: [PATCH 03/12] test: make backup functional tests folder-based; re-skip fragile full dehydrates CI slice 4 failed: the shared EnlistmentPerFixture cannot run consecutive full dehydrates -- the first succeeds, later ones fail to back up (leaving the enlistment dirty, which cascades). My earlier verification passed only because each test ran against a fresh enlistment in isolation. - Convert the two new feature tests (FolderDehydrateWithDiscardBackupShouldDeleteBackup, DehydratePruneBackupsShouldDeleteExistingBackups) to use folder dehydration, the reliable default path, so --discard-backup and prune-backups still get real CI coverage. - Re-skip FullDehydrateShouldSucceedInCommonCase and DehydrateShouldSucceedEvenIfObjectCacheIsDeleted: they need a per-test-case enlistment to run alongside other full dehydrates. Triage in follow-up. Verified locally: both folder-based tests pass when run together. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../Tests/EnlistmentPerFixture/DehydrateTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 58586af1da..7278a61028 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -64,6 +64,7 @@ public void FullDehydrateShouldExitWithoutConfirm() } [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(); @@ -108,14 +110,15 @@ public void DehydrateShouldBackupFiles() } [TestCase] - public void FullDehydrateWithDiscardBackupShouldDeleteBackup() + public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() { this.DehydrateShouldSucceed( - new[] { "The repo was successfully dehydrated and remounted", "(--discard-backup)" }, + new[] { "folder dehydrate successful", "(--discard-backup)" }, confirm: true, noStatus: false, - full: true, - discardBackup: true); + full: false, + discardBackup: true, + foldersToDehydrate: "GVFS"); string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); backupFolder.ShouldNotExistOnDisk(this.fileSystem); @@ -124,7 +127,7 @@ public void FullDehydrateWithDiscardBackupShouldDeleteBackup() [TestCase] public void DehydratePruneBackupsShouldDeleteExistingBackups() { - this.DehydrateShouldSucceed(new[] { "The repo was successfully dehydrated and remounted" }, confirm: true, noStatus: false, full: true); + 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); From be7ec03f7ae459c43ced048edf00b206ade17a09 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 14:08:56 -0700 Subject: [PATCH 04/12] release: bump version to 2.1 for dehydrate backup-cleanup feature Adds the --discard-backup flag and the 'gvfs dehydrate prune-backups' sub-verb, a backward-compatible new feature, so bump the minor version 2.0 -> 2.1. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .azure-pipelines/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 6cb88ee80d..aac9b3c167 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' From 437743b6c30908312369d605fed099ba93cb270e Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 15:05:51 -0700 Subject: [PATCH 05/12] dehydrate: retry backup deletion to tolerate transient ProjFS unavailability A dehydrate backup can contain ProjFS placeholders (e.g. from a folder dehydrate). Deleting them right after the remount can transiently fail with 'The provider that supports file system virtualization is temporarily unavailable', which made --discard-backup leave the backup behind. Add PhysicalFileSystem.TryWaitForDirectoryDelete (a directory analogue of the existing TryWaitForDelete) and use it for both --discard-backup and 'prune-backups', retrying with a short backoff so the space is reliably reclaimed. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../FileSystem/PhysicalFileSystem.cs | 54 +++++++++++++++++++ .../CommandLine/DehydratePruneBackupsVerb.cs | 18 +++---- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 10 +++- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs index 3b1ebe2675..136ebfbf21 100644 --- a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs +++ b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs @@ -526,5 +526,59 @@ public bool TryWaitForDelete( return true; } + + /// + /// Retry deleting a directory (and all of its contents) until it succeeds or the maximum + /// number of retries is reached. This tolerates transient failures such as ProjFS + /// "provider temporarily unavailable" that can occur when the directory contains + /// virtualization placeholders shortly after a dehydrate remount. + /// + /// ITracer for logging and telemetry, can be null + /// Path of directory to delete + /// Amount of time to wait between each delete attempt. If 0, there will be no delays between attempts + /// Maximum number of retries (if 0, a single attempt will be made) + /// True if the delete succeeded, and false otherwise + public bool TryWaitForDirectoryDelete( + ITracer tracer, + string path, + int retryDelayMs, + int maxRetries) + { + int failureCount = 0; + while (this.DirectoryExists(path)) + { + Exception exception = null; + if (!this.TryDeleteDirectory(path, out exception)) + { + if (failureCount >= maxRetries) + { + if (tracer != null) + { + EventMetadata metadata = new EventMetadata(); + if (exception != null) + { + metadata.Add("Exception", exception.ToString()); + } + + metadata.Add("path", path); + metadata.Add("failureCount", failureCount + 1); + metadata.Add("maxRetries", maxRetries); + tracer.RelatedWarning(metadata, $"{nameof(this.TryWaitForDirectoryDelete)}: Failed to delete directory."); + } + + return false; + } + + ++failureCount; + + if (retryDelayMs > 0) + { + Thread.Sleep(retryDelayMs); + } + } + } + + return true; + } } } diff --git a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs index 52ed524bb5..ee84b77062 100644 --- a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -11,6 +11,11 @@ public class DehydratePruneBackupsVerb : GVFSVerb.ForExistingEnlistment { private const string PruneBackupsVerbName = "prune-backups"; + // Backups can contain ProjFS placeholders that transiently fail to delete right after a + // remount; retry with a short backoff so pruning reliably reclaims the space. + private const int BackupDeleteRetryDelayMs = 1000; + private const int BackupDeleteMaxRetries = 15; + private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); public DehydratePruneBackupsVerb() @@ -91,16 +96,9 @@ protected override void Execute(GVFSEnlistment enlistment) private bool TryDeleteDirectory(ITracer tracer, string path) { - try - { - this.fileSystem.DeleteDirectory(path); - return true; - } - catch (Exception e) - { - tracer.RelatedError($"Failed to delete '{path}': {e.Message}"); - return false; - } + // A backup can contain ProjFS placeholders that transiently fail to delete with + // "provider ... temporarily unavailable"; retry with a short backoff. + return this.fileSystem.TryWaitForDirectoryDelete(tracer, path, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries); } private void WriteMessage(ITracer tracer, string message) diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index ff4ae466b9..541a457268 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -23,6 +23,12 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment internal const string BackupFolderName = "dehydrate_backup"; + // A dehydrate backup can contain ProjFS placeholders; deleting them immediately after a + // remount can transiently fail with "provider ... temporarily unavailable". Retry with a + // short backoff so --discard-backup / prune-backups reliably reclaim the space. + private const int BackupDeleteRetryDelayMs = 1000; + private const int BackupDeleteMaxRetries = 15; + private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); public bool Confirmed { get; set; } @@ -902,14 +908,14 @@ private void FinalizeBackup(ITracer tracer, string backupRoot) if (this.DiscardBackup) { - if (this.TryIO(tracer, () => this.fileSystem.DeleteDirectory(backupRoot), $"Delete backup folder {backupRoot}", out string error)) + if (this.fileSystem.TryWaitForDirectoryDelete(tracer, backupRoot, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries)) { this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); this.TryRemoveEmptyBackupParent(tracer, backupRoot); } else { - this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}: {error}"); + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}."); } } else From a2c0befb21ea82a1923ba18fdcdba22009547061 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 16:08:48 -0700 Subject: [PATCH 06/12] test: skip folder-dehydrate --discard-backup test pending placeholder-delete fix The moved folder contents in a dehydrate backup are ProjFS placeholders orphaned outside the virtualization root. The inline --discard-backup delete fails with ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE and does not recover with retry (added last commit). Deleting orphaned placeholders needs a proper fix (native reparse-point delete, or unmount-before-delete). Skip the functional test for now so CI is green; the prune-backups test still exercises deletion via a separate process. Feature parsing is covered by GvfsMainCliTests. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../Tests/EnlistmentPerFixture/DehydrateTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 7278a61028..f4c7b99288 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -110,6 +110,7 @@ public void DehydrateShouldBackupFiles() } [TestCase] + [SkipInCI("Folder-dehydrate backup contains orphaned ProjFS placeholders (moved outside the virtualization root); the inline --discard-backup delete fails with ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE and does not recover with retry. Needs a proper fix (native reparse-point delete, or unmount-before-delete). Tracked for follow-up.")] public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() { this.DehydrateShouldSucceed( From b72b496ad9b57ebfd63c5bcf6ffde96b44812bed Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 8 Jul 2026 10:32:04 -0700 Subject: [PATCH 07/12] dehydrate: delete --discard-backup backup in a separate process A folder-dehydrate backup contains ProjFS placeholders that were moved outside the virtualization root. The process that performed the dehydrate cannot delete them (the delete fails with ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE and does not recover with retry), but a freshly-spawned process deletes them with a plain recursive delete. This was confirmed empirically and matches CI: the in-process --discard-backup delete failed while the separate 'prune-backups' process succeeded. Delegate the deletion to a separate 'gvfs dehydrate prune-backups' process: - prune-backups gains a hidden --backup-path option to delete a single backup (with a safety check that the path is under the enlistment's dehydrate_backup folder) and returns a non-zero exit code on failure. - --discard-backup launches that process. By default it runs detached (StartBackgroundVFS4GProcess) so large backups don't block the command. - New --wait-for-backup-delete makes it wait for the deletion and report the result; the functional test uses it for determinism. Applies to both folder and full dehydrate. No native reparse-point code needed. Un-skip FolderDehydrateWithDiscardBackupShouldDeleteBackup (now uses --wait-for-backup-delete). Adds CLI parse coverage for --wait-for-backup-delete and prune-backups --backup-path. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../GvfsMainCliTests.cs | 16 +++- .../EnlistmentPerFixture/DehydrateTests.cs | 15 ++-- .../CommandLine/DehydratePruneBackupsVerb.cs | 83 +++++++++++++++++-- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 83 +++++++++++-------- 4 files changed, 151 insertions(+), 46 deletions(-) diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs index 69edf16d7a..8a611f3702 100644 --- a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -293,6 +293,20 @@ public void Dehydrate_DiscardBackup_ParsesCorrectly() Assert.That(parseResult.Errors, Is.Empty, "dehydrate --confirm --full --discard-backup should parse without errors"); } + [Test] + public void Dehydrate_DiscardBackupWithWait_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "dehydrate", "--confirm", "--discard-backup", "--wait-for-backup-delete" }); + Assert.That(parseResult.Errors, Is.Empty, "dehydrate --discard-backup --wait-for-backup-delete should parse without errors"); + } + + [Test] + public void Dehydrate_PruneBackups_BackupPath_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "dehydrate", "prune-backups", "--backup-path", @"C:\repo\dehydrate_backup\20260101_000000" }); + Assert.That(parseResult.Errors, Is.Empty, "dehydrate prune-backups --backup-path should parse without errors"); + } + [Test] public void Dehydrate_PruneBackups_IsSubcommand() { @@ -375,7 +389,7 @@ public void Clone_HasAllExpectedOptions() [Test] public void Dehydrate_HasAllExpectedOptions() { - var expected = new[] { "--confirm", "--no-status", "--folders", "--full", "--discard-backup" }; + var expected = new[] { "--confirm", "--no-status", "--folders", "--full", "--discard-backup", "--wait-for-backup-delete" }; foreach (var optName in expected) { Assert.That(FindOptionOnCommand("dehydrate", optName), Is.Not.Null, diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index f4c7b99288..f369c07397 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -110,7 +110,6 @@ public void DehydrateShouldBackupFiles() } [TestCase] - [SkipInCI("Folder-dehydrate backup contains orphaned ProjFS placeholders (moved outside the virtualization root); the inline --discard-backup delete fails with ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE and does not recover with retry. Needs a proper fix (native reparse-point delete, or unmount-before-delete). Tracked for follow-up.")] public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() { this.DehydrateShouldSucceed( @@ -119,6 +118,7 @@ public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() noStatus: false, full: false, discardBackup: true, + waitForBackupDelete: true, foldersToDehydrate: "GVFS"); string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); @@ -619,9 +619,9 @@ private void CheckDehydratedFolderAfterUnmount(string path) } } - private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, bool noStatus, bool full = false, bool discardBackup = false, params string[] foldersToDehydrate) + private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, bool noStatus, bool full = false, bool discardBackup = false, bool waitForBackupDelete = false, params string[] foldersToDehydrate) { - ProcessResult result = this.RunDehydrateProcess(confirm, noStatus, full, discardBackup, foldersToDehydrate); + ProcessResult result = this.RunDehydrateProcess(confirm, noStatus, full, discardBackup, waitForBackupDelete, 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")) @@ -635,12 +635,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, discardBackup: false, foldersToDehydrate: foldersToDehydrate); + ProcessResult result = this.RunDehydrateProcess(confirm: true, noStatus: noStatus, full: full, discardBackup: false, waitForBackupDelete: 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, bool discardBackup = false, params string[] foldersToDehydrate) + private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full = false, bool discardBackup = false, bool waitForBackupDelete = false, params string[] foldersToDehydrate) { string dehydrateFlags = string.Empty; if (confirm) @@ -663,6 +663,11 @@ private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full dehydrateFlags += " --discard-backup "; } + if (waitForBackupDelete) + { + dehydrateFlags += " --wait-for-backup-delete "; + } + if (foldersToDehydrate.Length > 0) { dehydrateFlags += $" --folders {string.Join(";", foldersToDehydrate)}"; diff --git a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs index ee84b77062..ea827d38e9 100644 --- a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -23,6 +23,15 @@ public DehydratePruneBackupsVerb() { } + /// + /// When set, only this single backup folder is deleted (instead of all backups). Used by + /// 'gvfs dehydrate --discard-backup', which launches this verb in a separate process to + /// delete just the backup it created. Deleting from a fresh process avoids the ProjFS + /// "provider temporarily unavailable" failure that occurs when the process that performed + /// the dehydrate tries to delete the (now-orphaned) placeholders itself. + /// + public string SingleBackupPath { get; set; } + public static System.CommandLine.Command CreateCommand() { System.CommandLine.Command cmd = new System.CommandLine.Command( @@ -32,10 +41,21 @@ public static System.CommandLine.Command CreateCommand() System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); cmd.Add(enlistmentArg); + System.CommandLine.Option backupPathOption = new System.CommandLine.Option("--backup-path") + { + Description = "Delete only the specified backup folder instead of all backups.", + Hidden = true, + }; + cmd.Add(backupPathOption); + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); - GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.SingleBackupPath = result.GetValue(backupPathOption); + }); return cmd; } @@ -54,7 +74,13 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Informational, Keywords.Any); - string backupParent = Path.Combine(enlistment.PrimaryEnlistmentRoot, DehydrateVerb.BackupFolderName); + string backupParent = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, DehydrateVerb.BackupFolderName)); + + if (!string.IsNullOrEmpty(this.SingleBackupPath)) + { + this.PruneSingleBackup(tracer, backupParent); + return; + } if (!this.fileSystem.DirectoryExists(backupParent)) { @@ -84,13 +110,58 @@ protected override void Execute(GVFSEnlistment enlistment) } } - // Remove the now-empty parent folder if everything was pruned. - if (!Directory.EnumerateFileSystemEntries(backupParent).Any()) + this.TryRemoveEmptyParent(tracer, backupParent); + + this.WriteMessage(tracer, $"Pruned {deleted} of {backups.Length} backup folder(s)."); + + if (deleted != backups.Length) { - this.TryDeleteDirectory(tracer, backupParent); + this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Failed to delete {backups.Length - deleted} backup folder(s)."); } + } + } - this.WriteMessage(tracer, $"Pruned {deleted} of {backups.Length} backup folder(s)."); + private void PruneSingleBackup(ITracer tracer, string backupParent) + { + string backup = Path.GetFullPath(this.SingleBackupPath); + + // Only ever delete something that is actually a backup folder for this enlistment. + if (!this.IsPathWithin(backup, backupParent)) + { + this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Refusing to delete '{backup}': it is not a dehydrate backup folder under '{backupParent}'."); + } + + if (!this.fileSystem.DirectoryExists(backup)) + { + this.WriteMessage(tracer, $"Backup folder {backup} does not exist; nothing to delete."); + this.TryRemoveEmptyParent(tracer, backupParent); + return; + } + + if (this.TryDeleteDirectory(tracer, backup)) + { + this.WriteMessage(tracer, $"Deleted backup folder {backup}."); + this.TryRemoveEmptyParent(tracer, backupParent); + } + else + { + this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Failed to delete backup folder {backup}."); + } + } + + private bool IsPathWithin(string path, string parent) + { + string normalizedParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent)); + string normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + return normalizedPath.StartsWith(normalizedParent + Path.DirectorySeparatorChar, GVFSPlatform.Instance.Constants.PathComparison); + } + + private void TryRemoveEmptyParent(ITracer tracer, string backupParent) + { + if (this.fileSystem.DirectoryExists(backupParent) && + !Directory.EnumerateFileSystemEntries(backupParent).Any()) + { + this.TryDeleteDirectory(tracer, backupParent); } } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 541a457268..7bf4c0261b 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -23,12 +23,6 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment internal const string BackupFolderName = "dehydrate_backup"; - // A dehydrate backup can contain ProjFS placeholders; deleting them immediately after a - // remount can transiently fail with "provider ... temporarily unavailable". Retry with a - // short backoff so --discard-backup / prune-backups reliably reclaim the space. - private const int BackupDeleteRetryDelayMs = 1000; - private const int BackupDeleteMaxRetries = 15; - private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); public bool Confirmed { get; set; } @@ -41,6 +35,8 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment public bool DiscardBackup { get; set; } + public bool WaitForBackupDelete { get; set; } + public string RunningVerbName { get; set; } = DehydrateVerbName; public string ActionName { get; set; } = DehydrateVerbName; @@ -67,9 +63,12 @@ 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." }; + 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. By default the deletion runs in the background so the command returns promptly." }; cmd.Add(discardBackupOption); + System.CommandLine.Option waitForBackupDeleteOption = new System.CommandLine.Option("--wait-for-backup-delete") { Description = "With --discard-backup, wait for the backup deletion to finish (and report the result) instead of deleting it in the background." }; + cmd.Add(waitForBackupDeleteOption); + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); @@ -81,6 +80,7 @@ public static System.CommandLine.Command CreateCommand() verb.Folders = result.GetValue(foldersOption) ?? ""; verb.Full = result.GetValue(fullOption); verb.DiscardBackup = result.GetValue(discardBackupOption); + verb.WaitForBackupDelete = result.GetValue(waitForBackupDeleteOption); }); // 'prune-backups' is a sub-verb of 'dehydrate' so it clearly only deletes backups @@ -118,6 +118,7 @@ protected override void Execute(GVFSEnlistment enlistment) { "NoStatus", this.NoStatus }, { "Full", this.Full }, { "DiscardBackup", this.DiscardBackup }, + { "WaitForBackupDelete", this.WaitForBackupDelete }, { "NamedPipeName", enlistment.NamedPipeName }, { "Folders", this.Folders }, { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, @@ -370,7 +371,7 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri if (foldersToDehydrate.Count > 0) { - this.FinalizeBackup(tracer, backupRoot); + this.FinalizeBackup(tracer, enlistment, backupRoot); } } @@ -493,7 +494,7 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri this.Output.WriteLine(); this.WriteMessage(tracer, "The repo was successfully dehydrated and remounted"); - this.FinalizeBackup(tracer, backupRoot); + this.FinalizeBackup(tracer, enlistment, backupRoot); } } else @@ -896,7 +897,7 @@ private bool TryRecreateIndex(ITracer tracer, GVFSEnlistment enlistment) return true; } - private void FinalizeBackup(ITracer tracer, string backupRoot) + private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string backupRoot) { // Backup management is only exposed by the 'dehydrate' verb. When this code runs // on behalf of 'gvfs sparse --prune' (RunningVerbName != dehydrate), leave the backup @@ -906,41 +907,55 @@ private void FinalizeBackup(ITracer tracer, string backupRoot) return; } - if (this.DiscardBackup) + if (!this.DiscardBackup) + { + 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'."); + return; + } + + // The backup contains ProjFS placeholders that were moved outside the virtualization + // root. The process that just performed the dehydrate cannot delete them (the delete + // fails with "provider ... temporarily unavailable"), but a fresh process can. So we + // always delegate the deletion to a separate 'gvfs dehydrate prune-backups' process. + string gvfsExe = Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSPlatform.Instance.Constants.GVFSExecutableName); + string[] args = new[] { - if (this.fileSystem.TryWaitForDirectoryDelete(tracer, backupRoot, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries)) + DehydrateVerbName, + "prune-backups", + enlistment.PrimaryEnlistmentRoot, + "--backup-path", + backupRoot, + }; + + if (this.WaitForBackupDelete) + { + string argString = string.Join(" ", args.Select(CommandLineEscaping.EscapeArgument)); + ProcessResult result = ProcessHelper.Run(gvfsExe, argString); + if (result.ExitCode == (int)ReturnCode.Success) { this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); - this.TryRemoveEmptyBackupParent(tracer, backupRoot); } else { - this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}."); + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}. Run 'gvfs dehydrate prune-backups' to retry."); } } else { - 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 TryRemoveEmptyBackupParent(ITracer tracer, string backupRoot) - { - string backupParent = Path.GetDirectoryName(backupRoot); - this.TryIO( - tracer, - () => + try { - if (this.fileSystem.DirectoryExists(backupParent) && - !Directory.EnumerateFileSystemEntries(backupParent).Any()) - { - this.fileSystem.DeleteDirectory(backupParent); - } - }, - $"Remove empty backup folder {backupParent}", - out _); + GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); + this.WriteMessage(tracer, $"Deleting backup folder {backupRoot} in the background (--discard-backup)."); + this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); + } + catch (Exception e) + { + this.WriteMessage(tracer, $"WARNING: Failed to start background deletion of backup folder {backupRoot}: {e.Message}"); + this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' to delete it."); + } + } } private void WriteMessage(ITracer tracer, string message) From ac4cbe606c0a9fba9787186b42e8e467554a1b6a Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 9 Jul 2026 10:42:40 -0700 Subject: [PATCH 08/12] dehydrate: launch backup deletion without inheriting handles (fixes CI) CI slice 4 showed the --discard-backup delete still failing with 'provider temporarily unavailable' for 15+ seconds, while the separate 'prune-backups' process spawned by the test framework deleted the same kind of backup instantly. The two child processes were identical (same verb, same working directory, both UseShellExecute=false) except for their parent: a child of the dehydrating gvfs.exe fails, a child of an unrelated process succeeds. The failure is therefore caused by the child inheriting the dehydrating process's handles, not by timing (retry never recovered). For --wait-for-backup-delete, launch the prune-backups child with StartBackgroundVFS4GProcess (ShellExecuteEx, which does not inherit the parent's handles and runs from the install directory) and then WaitForExit and check the exit code, instead of ProcessHelper.Run (which inherits handles). The default background path already used StartBackgroundVFS4GProcess, so both paths now avoid handle inheritance. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 54 ++++++++++++++------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 7bf4c0261b..128a068779 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -10,6 +10,7 @@ using GVFS.Virtualization.Projection; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -917,8 +918,12 @@ private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string ba // The backup contains ProjFS placeholders that were moved outside the virtualization // root. The process that just performed the dehydrate cannot delete them (the delete - // fails with "provider ... temporarily unavailable"), but a fresh process can. So we - // always delegate the deletion to a separate 'gvfs dehydrate prune-backups' process. + // fails with "provider ... temporarily unavailable" and does not recover with retry), + // but a fresh process that does NOT inherit this process's handles can. So we always + // delegate the deletion to a separate 'gvfs dehydrate prune-backups' process launched + // via StartBackgroundVFS4GProcess (ShellExecuteEx, which does not inherit handles). + // Using ProcessHelper.Run here instead would inherit this process's handles and hit the + // same "provider temporarily unavailable" failure. string gvfsExe = Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSPlatform.Instance.Constants.GVFSExecutableName); string[] args = new[] { @@ -929,32 +934,33 @@ private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string ba backupRoot, }; - if (this.WaitForBackupDelete) + Process pruneProcess; + try { - string argString = string.Join(" ", args.Select(CommandLineEscaping.EscapeArgument)); - ProcessResult result = ProcessHelper.Run(gvfsExe, argString); - if (result.ExitCode == (int)ReturnCode.Success) - { - 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' to retry."); - } + pruneProcess = GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); + } + catch (Exception e) + { + this.WriteMessage(tracer, $"WARNING: Failed to start deletion of backup folder {backupRoot}: {e.Message}"); + this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' to delete it."); + return; + } + + if (!this.WaitForBackupDelete) + { + this.WriteMessage(tracer, $"Deleting backup folder {backupRoot} in the background (--discard-backup)."); + this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); + return; + } + + pruneProcess.WaitForExit(); + if (pruneProcess.ExitCode == (int)ReturnCode.Success) + { + this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); } else { - try - { - GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); - this.WriteMessage(tracer, $"Deleting backup folder {backupRoot} in the background (--discard-backup)."); - this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); - } - catch (Exception e) - { - this.WriteMessage(tracer, $"WARNING: Failed to start background deletion of backup folder {backupRoot}: {e.Message}"); - this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' to delete it."); - } + this.WriteMessage(tracer, $"WARNING: Failed to delete backup folder {backupRoot}. Run 'gvfs dehydrate prune-backups' to retry."); } } From b95d5b06c7201654189bcc692f7e4bd4440c9d0d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 9 Jul 2026 11:51:43 -0700 Subject: [PATCH 09/12] dehydrate: always delete --discard-backup backup in the background Two CI runs showed the --discard-backup delete failing identically regardless of how the child prune-backups process was launched (ProcessHelper.Run, then StartBackgroundVFS4GProcess). In both runs the separate prune-backups process spawned by the test AFTER the dehydrate process exited deleted an equivalent backup instantly. The one consistent difference: the delete fails (persistently, for 15+ seconds) only while the dehydrating gvfs.exe process is still alive. Waiting for the deletion keeps that process alive, which is exactly what blocks the deletion. So a synchronous --wait-for-backup-delete cannot work. Remove it and always run the deletion in a detached 'gvfs dehydrate prune-backups' process that completes after this process exits. The functional test now polls for the backup folder to disappear. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../GvfsMainCliTests.cs | 9 +--- .../EnlistmentPerFixture/DehydrateTests.cs | 24 +++++----- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 47 +++++-------------- 3 files changed, 25 insertions(+), 55 deletions(-) diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs index 8a611f3702..c294fa450d 100644 --- a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -293,13 +293,6 @@ public void Dehydrate_DiscardBackup_ParsesCorrectly() Assert.That(parseResult.Errors, Is.Empty, "dehydrate --confirm --full --discard-backup should parse without errors"); } - [Test] - public void Dehydrate_DiscardBackupWithWait_ParsesCorrectly() - { - var parseResult = rootCommand.Parse(new[] { "dehydrate", "--confirm", "--discard-backup", "--wait-for-backup-delete" }); - Assert.That(parseResult.Errors, Is.Empty, "dehydrate --discard-backup --wait-for-backup-delete should parse without errors"); - } - [Test] public void Dehydrate_PruneBackups_BackupPath_ParsesCorrectly() { @@ -389,7 +382,7 @@ public void Clone_HasAllExpectedOptions() [Test] public void Dehydrate_HasAllExpectedOptions() { - var expected = new[] { "--confirm", "--no-status", "--folders", "--full", "--discard-backup", "--wait-for-backup-delete" }; + 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.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index f369c07397..f6cf76bb30 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -113,15 +113,22 @@ public void DehydrateShouldBackupFiles() public void FolderDehydrateWithDiscardBackupShouldDeleteBackup() { this.DehydrateShouldSucceed( - new[] { "folder dehydrate successful", "(--discard-backup)" }, + new[] { "folder dehydrate successful", "will be deleted in the background" }, confirm: true, noStatus: false, full: false, discardBackup: true, - waitForBackupDelete: true, foldersToDehydrate: "GVFS"); + // --discard-backup deletes the backup in a detached process that only succeeds once + // this dehydrate process has exited, so poll for the backup folder to disappear. string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); + Stopwatch timeout = Stopwatch.StartNew(); + while (this.fileSystem.DirectoryExists(backupFolder) && timeout.Elapsed < TimeSpan.FromSeconds(60)) + { + System.Threading.Thread.Sleep(500); + } + backupFolder.ShouldNotExistOnDisk(this.fileSystem); } @@ -619,9 +626,9 @@ private void CheckDehydratedFolderAfterUnmount(string path) } } - private void DehydrateShouldSucceed(string[] expectedInOutput, bool confirm, bool noStatus, bool full = false, bool discardBackup = false, bool waitForBackupDelete = 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, discardBackup, waitForBackupDelete, 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")) @@ -635,12 +642,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, discardBackup: false, waitForBackupDelete: false, 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, bool discardBackup = false, bool waitForBackupDelete = 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) @@ -663,11 +670,6 @@ private ProcessResult RunDehydrateProcess(bool confirm, bool noStatus, bool full dehydrateFlags += " --discard-backup "; } - if (waitForBackupDelete) - { - dehydrateFlags += " --wait-for-backup-delete "; - } - if (foldersToDehydrate.Length > 0) { dehydrateFlags += $" --folders {string.Join(";", foldersToDehydrate)}"; diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 128a068779..dd92887610 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -10,7 +10,6 @@ using GVFS.Virtualization.Projection; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -36,8 +35,6 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment public bool DiscardBackup { get; set; } - public bool WaitForBackupDelete { get; set; } - public string RunningVerbName { get; set; } = DehydrateVerbName; public string ActionName { get; set; } = DehydrateVerbName; @@ -64,12 +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. By default the deletion runs in the background so the command returns promptly." }; + 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. The deletion runs in the background after the command returns." }; cmd.Add(discardBackupOption); - System.CommandLine.Option waitForBackupDeleteOption = new System.CommandLine.Option("--wait-for-backup-delete") { Description = "With --discard-backup, wait for the backup deletion to finish (and report the result) instead of deleting it in the background." }; - cmd.Add(waitForBackupDeleteOption); - System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); @@ -81,7 +75,6 @@ public static System.CommandLine.Command CreateCommand() verb.Folders = result.GetValue(foldersOption) ?? ""; verb.Full = result.GetValue(fullOption); verb.DiscardBackup = result.GetValue(discardBackupOption); - verb.WaitForBackupDelete = result.GetValue(waitForBackupDeleteOption); }); // 'prune-backups' is a sub-verb of 'dehydrate' so it clearly only deletes backups @@ -119,7 +112,6 @@ protected override void Execute(GVFSEnlistment enlistment) { "NoStatus", this.NoStatus }, { "Full", this.Full }, { "DiscardBackup", this.DiscardBackup }, - { "WaitForBackupDelete", this.WaitForBackupDelete }, { "NamedPipeName", enlistment.NamedPipeName }, { "Folders", this.Folders }, { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, @@ -917,13 +909,13 @@ private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string ba } // The backup contains ProjFS placeholders that were moved outside the virtualization - // root. The process that just performed the dehydrate cannot delete them (the delete - // fails with "provider ... temporarily unavailable" and does not recover with retry), - // but a fresh process that does NOT inherit this process's handles can. So we always - // delegate the deletion to a separate 'gvfs dehydrate prune-backups' process launched - // via StartBackgroundVFS4GProcess (ShellExecuteEx, which does not inherit handles). - // Using ProcessHelper.Run here instead would inherit this process's handles and hit the - // same "provider temporarily unavailable" failure. + // root. The process that just performed the dehydrate cannot delete them: while this + // process is alive the delete fails with "provider ... temporarily unavailable" and + // never recovers (retrying for 15+ seconds does not help). A separate process deletes + // them successfully, but ONLY once this dehydrate process has exited. So we launch a + // detached 'gvfs dehydrate prune-backups' process and return immediately; the deletion + // completes in the background after this process exits. This also keeps the command + // responsive when the backup is large. string gvfsExe = Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSPlatform.Instance.Constants.GVFSExecutableName); string[] args = new[] { @@ -934,33 +926,16 @@ private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string ba backupRoot, }; - Process pruneProcess; try { - pruneProcess = GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); + GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); + this.WriteMessage(tracer, $"The backup folder {backupRoot} will be deleted in the background (--discard-backup)."); + this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); } catch (Exception e) { this.WriteMessage(tracer, $"WARNING: Failed to start deletion of backup folder {backupRoot}: {e.Message}"); this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' to delete it."); - return; - } - - if (!this.WaitForBackupDelete) - { - this.WriteMessage(tracer, $"Deleting backup folder {backupRoot} in the background (--discard-backup)."); - this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); - return; - } - - pruneProcess.WaitForExit(); - if (pruneProcess.ExitCode == (int)ReturnCode.Success) - { - 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' to retry."); } } From a037d891b35141caa4a946ac58d3cd3d59c35847 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 9 Jul 2026 14:44:14 -0700 Subject: [PATCH 10/12] dehydrate: delete backup placeholders without recalling through ProjFS The backup deletion kept failing in CI with 'provider ... temporarily unavailable'. Root cause: CI runs test fixtures in parallel (Parallelizable(ParallelScope.Fixtures)), so many enlistments drive ProjFS concurrently. A normal recursive delete enumerates the moved backup placeholders by opening directory handles WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, which recalls through the ProjFS provider; while the provider is busy (or no longer projecting the moved folders) that returns ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE, and retrying does not help. Add NativeMethods.DeleteDirectoryWithoutProviderRecall, which recursively deletes a tree opening every handle with FILE_FLAG_OPEN_REPARSE_POINT (+ FILE_FLAG_BACKUP_SEMANTICS for directories, FILE_FLAG_DELETE_ON_CLOSE to delete). This enumerates and deletes the reparse points directly and never contacts the provider, so it is immune to provider contention. PhysicalFileSystem.TryDeleteDirectoryWithoutProviderRecall wraps it with a light retry, and 'gvfs dehydrate prune-backups' uses it. Verified: 887/887 unit tests pass; discard + prune functional tests pass locally. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../FileSystem/PhysicalFileSystem.cs | 26 +-- .../NativeMethods.ReparseDelete.cs | 191 ++++++++++++++++++ .../CommandLine/DehydratePruneBackupsVerb.cs | 7 +- 3 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs diff --git a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs index 136ebfbf21..8a22361148 100644 --- a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs +++ b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs @@ -528,17 +528,18 @@ public bool TryWaitForDelete( } /// - /// Retry deleting a directory (and all of its contents) until it succeeds or the maximum - /// number of retries is reached. This tolerates transient failures such as ProjFS - /// "provider temporarily unavailable" that can occur when the directory contains - /// virtualization placeholders shortly after a dehydrate remount. + /// Recursively deletes a directory that may contain ProjFS placeholders (for example a + /// 'gvfs dehydrate' backup folder) without recalling their content through the ProjFS + /// provider. This avoids the "provider ... temporarily unavailable" failure that a normal + /// recursive delete hits when the moved placeholders are enumerated while the provider is + /// busy or no longer projecting them. A small retry tolerates genuinely transient I/O. /// /// ITracer for logging and telemetry, can be null /// Path of directory to delete /// Amount of time to wait between each delete attempt. If 0, there will be no delays between attempts /// Maximum number of retries (if 0, a single attempt will be made) /// True if the delete succeeded, and false otherwise - public bool TryWaitForDirectoryDelete( + public virtual bool TryDeleteDirectoryWithoutProviderRecall( ITracer tracer, string path, int retryDelayMs, @@ -547,23 +548,22 @@ public bool TryWaitForDirectoryDelete( int failureCount = 0; while (this.DirectoryExists(path)) { - Exception exception = null; - if (!this.TryDeleteDirectory(path, out exception)) + try + { + NativeMethods.DeleteDirectoryWithoutProviderRecall(path); + } + catch (Exception exception) { if (failureCount >= maxRetries) { if (tracer != null) { EventMetadata metadata = new EventMetadata(); - if (exception != null) - { - metadata.Add("Exception", exception.ToString()); - } - + metadata.Add("Exception", exception.ToString()); metadata.Add("path", path); metadata.Add("failureCount", failureCount + 1); metadata.Add("maxRetries", maxRetries); - tracer.RelatedWarning(metadata, $"{nameof(this.TryWaitForDirectoryDelete)}: Failed to delete directory."); + tracer.RelatedWarning(metadata, $"{nameof(this.TryDeleteDirectoryWithoutProviderRecall)}: Failed to delete directory."); } return false; diff --git a/GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs b/GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs new file mode 100644 index 0000000000..a39983f19f --- /dev/null +++ b/GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs @@ -0,0 +1,191 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; + +namespace GVFS.Common +{ + public static partial class NativeMethods + { + private const int ERROR_NO_MORE_FILES = 0x12; + + // FILE_INFO_BY_HANDLE_CLASS.FileFullDirectoryInfo + private const int FileFullDirectoryInfo = 14; + + // Offsets into FILE_FULL_DIR_INFO. + private const int FileFullDirInfo_NextEntryOffset = 0; + private const int FileFullDirInfo_FileAttributes = 56; + private const int FileFullDirInfo_FileNameLength = 60; + private const int FileFullDirInfo_FileName = 68; + + /// + /// Recursively deletes a directory tree that may contain ProjFS placeholders WITHOUT + /// recalling their content through the ProjFS provider. Every handle is opened with + /// FILE_FLAG_OPEN_REPARSE_POINT, so the reparse points are enumerated and deleted directly + /// instead of being materialized by the provider. This avoids the + /// "provider ... temporarily unavailable" failures that occur when the provider is busy + /// (e.g. under heavy concurrent ProjFS load) or is no longer projecting the moved + /// placeholders (as is the case for a dehydrate backup folder). + /// + public static void DeleteDirectoryWithoutProviderRecall(string path) + { + foreach (DirectoryEntry entry in EnumerateReparsePointAware(path)) + { + string childPath = Path.Combine(path, entry.Name); + if ((entry.Attributes & (uint)FileAttributes.FILE_ATTRIBUTE_DIRECTORY) != 0) + { + DeleteDirectoryWithoutProviderRecall(childPath); + } + else + { + DeleteFileReparsePointAware(childPath, entry.Attributes); + } + } + + // The directory is now physically empty; delete it as a reparse point. + DeleteEmptyDirectoryReparsePointAware(path); + } + + private static IEnumerable EnumerateReparsePointAware(string path) + { + List results = new List(); + + FileAccess access = (FileAccess)((uint)FileAccess.FILE_LIST_DIRECTORY | (uint)FileAccess.FILE_READ_ATTRIBUTES); + FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_BACKUP_SEMANTICS | (uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT); + + using (SafeFileHandle directory = CreateFile( + path, + access, + FileShare.ReadWrite | FileShare.Delete, + IntPtr.Zero, + FileMode.Open, + flags, + IntPtr.Zero)) + { + if (directory.IsInvalid) + { + ThrowLastWin32Exception($"Failed to open directory for enumeration: {path}"); + } + + const int BufferSize = 64 * 1024; + IntPtr buffer = Marshal.AllocHGlobal(BufferSize); + try + { + while (GetFileInformationByHandleEx(directory, FileFullDirectoryInfo, buffer, (uint)BufferSize)) + { + IntPtr current = buffer; + while (true) + { + uint nextOffset = (uint)Marshal.ReadInt32(current, FileFullDirInfo_NextEntryOffset); + uint fileAttributes = (uint)Marshal.ReadInt32(current, FileFullDirInfo_FileAttributes); + int fileNameLength = Marshal.ReadInt32(current, FileFullDirInfo_FileNameLength); + string name = Marshal.PtrToStringUni(IntPtr.Add(current, FileFullDirInfo_FileName), fileNameLength / 2); + + if (name != "." && name != "..") + { + results.Add(new DirectoryEntry(name, fileAttributes)); + } + + if (nextOffset == 0) + { + break; + } + + current = IntPtr.Add(current, (int)nextOffset); + } + } + + int error = Marshal.GetLastWin32Error(); + if (error != ERROR_NO_MORE_FILES) + { + throw new Win32Exception(error, $"Failed to enumerate directory: {path}"); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + return results; + } + + private static void DeleteFileReparsePointAware(string path, uint attributes) + { + // FILE_FLAG_DELETE_ON_CLOSE fails on read-only files, so clear the attribute first. + // Setting attributes does not recall placeholder content. + if ((attributes & (uint)FileAttributes.FILE_ATTRIBUTE_READONLY) != 0) + { + uint newAttributes = attributes & ~(uint)FileAttributes.FILE_ATTRIBUTE_READONLY; + if (newAttributes == 0) + { + newAttributes = (uint)FileAttributes.FILE_ATTRIBUTE_NORMAL; + } + + SetFileAttributesW(path, newAttributes); + } + + FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT | (uint)FileAttributes.FILE_FLAG_DELETE_ON_CLOSE); + using (SafeFileHandle handle = CreateFile( + path, + FileAccess.DELETE, + FileShare.ReadWrite | FileShare.Delete, + IntPtr.Zero, + FileMode.Open, + flags, + IntPtr.Zero)) + { + if (handle.IsInvalid) + { + ThrowLastWin32Exception($"Failed to open file for deletion: {path}"); + } + } + } + + private static void DeleteEmptyDirectoryReparsePointAware(string path) + { + FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_BACKUP_SEMANTICS | (uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT | (uint)FileAttributes.FILE_FLAG_DELETE_ON_CLOSE); + using (SafeFileHandle handle = CreateFile( + path, + FileAccess.DELETE, + FileShare.ReadWrite | FileShare.Delete, + IntPtr.Zero, + FileMode.Open, + flags, + IntPtr.Zero)) + { + if (handle.IsInvalid) + { + ThrowLastWin32Exception($"Failed to open directory for deletion: {path}"); + } + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle hFile, + int fileInformationClass, + IntPtr lpFileInformation, + uint dwBufferSize); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileAttributesW(string lpFileName, uint dwFileAttributes); + + private readonly struct DirectoryEntry + { + public DirectoryEntry(string name, uint attributes) + { + this.Name = name; + this.Attributes = attributes; + } + + public string Name { get; } + + public uint Attributes { get; } + } + } +} diff --git a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs index ea827d38e9..d5c002215c 100644 --- a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -167,9 +167,10 @@ private void TryRemoveEmptyParent(ITracer tracer, string backupParent) private bool TryDeleteDirectory(ITracer tracer, string path) { - // A backup can contain ProjFS placeholders that transiently fail to delete with - // "provider ... temporarily unavailable"; retry with a short backoff. - return this.fileSystem.TryWaitForDirectoryDelete(tracer, path, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries); + // A backup can contain ProjFS placeholders. Delete them without recalling their + // content through the (possibly busy or no-longer-projecting) provider, which would + // otherwise fail with "provider ... temporarily unavailable". + return this.fileSystem.TryDeleteDirectoryWithoutProviderRecall(tracer, path, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries); } private void WriteMessage(ITracer tracer, string message) From ee06a1c21ae64c4af0ebc2abc321810073c59300 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 10 Jul 2026 10:52:24 -0700 Subject: [PATCH 11/12] dehydrate: delete --discard-backup backup during the unmounted window A dehydrate backup contains ProjFS placeholders moved outside the virtualization root. They can only be deleted while the repo is UNMOUNTED: while mounted, deleting them fails with ERROR_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE and no retry, process-isolation, or FILE_FLAG_OPEN_REPARSE_POINT avoids it (all tried and failed in CI). The bug only reproduces when the dehydrated folder was hydrated first, which is why it showed up in CI (shared fixture) but not in isolated local runs. Delete the backup during the dehydrate's existing unmounted window instead: - Full dehydrate (CLI): RunFullDehydrate deletes the backup after moving src and before remounting. - Folder dehydrate (mount): DehydrateFolders.Request carries a DiscardBackup flag; the mount deletes the backup inside BackupFoldersWhileUnmounted (while unmounted) and reports success via Response.BackupDiscarded. - Deletion is synchronous; no detached process or polling. prune-backups (for old backups) no longer auto-unmounts (that could break the user's running processes and is unintuitive). It tries to delete and, if it fails while the repo is mounted, tells the user to unmount and retry. Removed the hidden --backup-path option and the native reparse-point delete (a dead end -- OPEN_REPARSE_POINT does not bypass the ProjFS filter). Tests now hydrate the folder before dehydrating (required to reproduce the real scenario) and assert synchronous deletion. Covers discard, prune-when-unmounted, and prune-while-mounted-guidance. 887/887 unit tests and 58 CLI parse tests pass; the three functional tests pass locally. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../GvfsMainCliTests.cs | 7 - .../FileSystem/PhysicalFileSystem.cs | 54 ----- .../NamedPipes/NamedPipeMessages.cs | 15 +- .../NativeMethods.ReparseDelete.cs | 191 ------------------ .../EnlistmentPerFixture/DehydrateTests.cs | 72 ++++++- GVFS/GVFS.Mount/InProcessMount.cs | 21 ++ .../CommandLine/DehydratePruneBackupsVerb.cs | 98 +++------ GVFS/GVFS/CommandLine/DehydrateVerb.cs | 122 +++++++---- 8 files changed, 205 insertions(+), 375 deletions(-) delete mode 100644 GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs index c294fa450d..69edf16d7a 100644 --- a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -293,13 +293,6 @@ public void Dehydrate_DiscardBackup_ParsesCorrectly() Assert.That(parseResult.Errors, Is.Empty, "dehydrate --confirm --full --discard-backup should parse without errors"); } - [Test] - public void Dehydrate_PruneBackups_BackupPath_ParsesCorrectly() - { - var parseResult = rootCommand.Parse(new[] { "dehydrate", "prune-backups", "--backup-path", @"C:\repo\dehydrate_backup\20260101_000000" }); - Assert.That(parseResult.Errors, Is.Empty, "dehydrate prune-backups --backup-path should parse without errors"); - } - [Test] public void Dehydrate_PruneBackups_IsSubcommand() { diff --git a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs index 8a22361148..3b1ebe2675 100644 --- a/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs +++ b/GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs @@ -526,59 +526,5 @@ public bool TryWaitForDelete( return true; } - - /// - /// Recursively deletes a directory that may contain ProjFS placeholders (for example a - /// 'gvfs dehydrate' backup folder) without recalling their content through the ProjFS - /// provider. This avoids the "provider ... temporarily unavailable" failure that a normal - /// recursive delete hits when the moved placeholders are enumerated while the provider is - /// busy or no longer projecting them. A small retry tolerates genuinely transient I/O. - /// - /// ITracer for logging and telemetry, can be null - /// Path of directory to delete - /// Amount of time to wait between each delete attempt. If 0, there will be no delays between attempts - /// Maximum number of retries (if 0, a single attempt will be made) - /// True if the delete succeeded, and false otherwise - public virtual bool TryDeleteDirectoryWithoutProviderRecall( - ITracer tracer, - string path, - int retryDelayMs, - int maxRetries) - { - int failureCount = 0; - while (this.DirectoryExists(path)) - { - try - { - NativeMethods.DeleteDirectoryWithoutProviderRecall(path); - } - catch (Exception exception) - { - if (failureCount >= maxRetries) - { - if (tracer != null) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", exception.ToString()); - metadata.Add("path", path); - metadata.Add("failureCount", failureCount + 1); - metadata.Add("maxRetries", maxRetries); - tracer.RelatedWarning(metadata, $"{nameof(this.TryDeleteDirectoryWithoutProviderRecall)}: Failed to delete directory."); - } - - return false; - } - - ++failureCount; - - if (retryDelayMs > 0) - { - Thread.Sleep(retryDelayMs); - } - } - } - - return true; - } } } diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 62447659ee..94dc8248ab 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.Common/NativeMethods.ReparseDelete.cs b/GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs deleted file mode 100644 index a39983f19f..0000000000 --- a/GVFS/GVFS.Common/NativeMethods.ReparseDelete.cs +++ /dev/null @@ -1,191 +0,0 @@ -using Microsoft.Win32.SafeHandles; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; - -namespace GVFS.Common -{ - public static partial class NativeMethods - { - private const int ERROR_NO_MORE_FILES = 0x12; - - // FILE_INFO_BY_HANDLE_CLASS.FileFullDirectoryInfo - private const int FileFullDirectoryInfo = 14; - - // Offsets into FILE_FULL_DIR_INFO. - private const int FileFullDirInfo_NextEntryOffset = 0; - private const int FileFullDirInfo_FileAttributes = 56; - private const int FileFullDirInfo_FileNameLength = 60; - private const int FileFullDirInfo_FileName = 68; - - /// - /// Recursively deletes a directory tree that may contain ProjFS placeholders WITHOUT - /// recalling their content through the ProjFS provider. Every handle is opened with - /// FILE_FLAG_OPEN_REPARSE_POINT, so the reparse points are enumerated and deleted directly - /// instead of being materialized by the provider. This avoids the - /// "provider ... temporarily unavailable" failures that occur when the provider is busy - /// (e.g. under heavy concurrent ProjFS load) or is no longer projecting the moved - /// placeholders (as is the case for a dehydrate backup folder). - /// - public static void DeleteDirectoryWithoutProviderRecall(string path) - { - foreach (DirectoryEntry entry in EnumerateReparsePointAware(path)) - { - string childPath = Path.Combine(path, entry.Name); - if ((entry.Attributes & (uint)FileAttributes.FILE_ATTRIBUTE_DIRECTORY) != 0) - { - DeleteDirectoryWithoutProviderRecall(childPath); - } - else - { - DeleteFileReparsePointAware(childPath, entry.Attributes); - } - } - - // The directory is now physically empty; delete it as a reparse point. - DeleteEmptyDirectoryReparsePointAware(path); - } - - private static IEnumerable EnumerateReparsePointAware(string path) - { - List results = new List(); - - FileAccess access = (FileAccess)((uint)FileAccess.FILE_LIST_DIRECTORY | (uint)FileAccess.FILE_READ_ATTRIBUTES); - FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_BACKUP_SEMANTICS | (uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT); - - using (SafeFileHandle directory = CreateFile( - path, - access, - FileShare.ReadWrite | FileShare.Delete, - IntPtr.Zero, - FileMode.Open, - flags, - IntPtr.Zero)) - { - if (directory.IsInvalid) - { - ThrowLastWin32Exception($"Failed to open directory for enumeration: {path}"); - } - - const int BufferSize = 64 * 1024; - IntPtr buffer = Marshal.AllocHGlobal(BufferSize); - try - { - while (GetFileInformationByHandleEx(directory, FileFullDirectoryInfo, buffer, (uint)BufferSize)) - { - IntPtr current = buffer; - while (true) - { - uint nextOffset = (uint)Marshal.ReadInt32(current, FileFullDirInfo_NextEntryOffset); - uint fileAttributes = (uint)Marshal.ReadInt32(current, FileFullDirInfo_FileAttributes); - int fileNameLength = Marshal.ReadInt32(current, FileFullDirInfo_FileNameLength); - string name = Marshal.PtrToStringUni(IntPtr.Add(current, FileFullDirInfo_FileName), fileNameLength / 2); - - if (name != "." && name != "..") - { - results.Add(new DirectoryEntry(name, fileAttributes)); - } - - if (nextOffset == 0) - { - break; - } - - current = IntPtr.Add(current, (int)nextOffset); - } - } - - int error = Marshal.GetLastWin32Error(); - if (error != ERROR_NO_MORE_FILES) - { - throw new Win32Exception(error, $"Failed to enumerate directory: {path}"); - } - } - finally - { - Marshal.FreeHGlobal(buffer); - } - } - - return results; - } - - private static void DeleteFileReparsePointAware(string path, uint attributes) - { - // FILE_FLAG_DELETE_ON_CLOSE fails on read-only files, so clear the attribute first. - // Setting attributes does not recall placeholder content. - if ((attributes & (uint)FileAttributes.FILE_ATTRIBUTE_READONLY) != 0) - { - uint newAttributes = attributes & ~(uint)FileAttributes.FILE_ATTRIBUTE_READONLY; - if (newAttributes == 0) - { - newAttributes = (uint)FileAttributes.FILE_ATTRIBUTE_NORMAL; - } - - SetFileAttributesW(path, newAttributes); - } - - FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT | (uint)FileAttributes.FILE_FLAG_DELETE_ON_CLOSE); - using (SafeFileHandle handle = CreateFile( - path, - FileAccess.DELETE, - FileShare.ReadWrite | FileShare.Delete, - IntPtr.Zero, - FileMode.Open, - flags, - IntPtr.Zero)) - { - if (handle.IsInvalid) - { - ThrowLastWin32Exception($"Failed to open file for deletion: {path}"); - } - } - } - - private static void DeleteEmptyDirectoryReparsePointAware(string path) - { - FileAttributes flags = (FileAttributes)((uint)FileAttributes.FILE_FLAG_BACKUP_SEMANTICS | (uint)FileAttributes.FILE_FLAG_OPEN_REPARSE_POINT | (uint)FileAttributes.FILE_FLAG_DELETE_ON_CLOSE); - using (SafeFileHandle handle = CreateFile( - path, - FileAccess.DELETE, - FileShare.ReadWrite | FileShare.Delete, - IntPtr.Zero, - FileMode.Open, - flags, - IntPtr.Zero)) - { - if (handle.IsInvalid) - { - ThrowLastWin32Exception($"Failed to open directory for deletion: {path}"); - } - } - } - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetFileInformationByHandleEx( - SafeFileHandle hFile, - int fileInformationClass, - IntPtr lpFileInformation, - uint dwBufferSize); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetFileAttributesW(string lpFileName, uint dwFileAttributes); - - private readonly struct DirectoryEntry - { - public DirectoryEntry(string name, uint attributes) - { - this.Name = name; - this.Attributes = attributes; - } - - public string Name { get; } - - public uint Attributes { get; } - } - } -} diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index f6cf76bb30..7659fc3ad7 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -112,39 +112,67 @@ public void DehydrateShouldBackupFiles() [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", "will be deleted in the background" }, + new[] { "folder dehydrate successful", "(--discard-backup)" }, confirm: true, noStatus: false, full: false, discardBackup: true, foldersToDehydrate: "GVFS"); - // --discard-backup deletes the backup in a detached process that only succeeds once - // this dehydrate process has exited, so poll for the backup folder to disappear. string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup"); - Stopwatch timeout = Stopwatch.StartNew(); - while (this.fileSystem.DirectoryExists(backupFolder) && timeout.Elapsed < TimeSpan.FromSeconds(60)) - { - System.Threading.Thread.Sleep(500); - } - backupFolder.ShouldNotExistOnDisk(this.fileSystem); } [TestCase] - public void DehydratePruneBackupsShouldDeleteExistingBackups() + 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] @@ -699,6 +727,30 @@ private ProcessResult RunDehydratePruneBackupsProcess() 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 b88af37fc0..b98ce1b469 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 index d5c002215c..1bc8f69526 100644 --- a/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydratePruneBackupsVerb.cs @@ -1,5 +1,6 @@ using GVFS.Common; using GVFS.Common.FileSystem; +using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using System; using System.IO; @@ -11,11 +12,6 @@ public class DehydratePruneBackupsVerb : GVFSVerb.ForExistingEnlistment { private const string PruneBackupsVerbName = "prune-backups"; - // Backups can contain ProjFS placeholders that transiently fail to delete right after a - // remount; retry with a short backoff so pruning reliably reclaims the space. - private const int BackupDeleteRetryDelayMs = 1000; - private const int BackupDeleteMaxRetries = 15; - private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); public DehydratePruneBackupsVerb() @@ -23,39 +19,19 @@ public DehydratePruneBackupsVerb() { } - /// - /// When set, only this single backup folder is deleted (instead of all backups). Used by - /// 'gvfs dehydrate --discard-backup', which launches this verb in a separate process to - /// delete just the backup it created. Deleting from a fresh process avoids the ProjFS - /// "provider temporarily unavailable" failure that occurs when the process that performed - /// the dehydrate tries to delete the (now-orphaned) placeholders itself. - /// - public string SingleBackupPath { get; set; } - 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."); + "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 backupPathOption = new System.CommandLine.Option("--backup-path") - { - Description = "Delete only the specified backup folder instead of all backups.", - Hidden = true, - }; - cmd.Add(backupPathOption); - System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); - GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, - (verb, result) => - { - verb.SingleBackupPath = result.GetValue(backupPathOption); - }); + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); return cmd; } @@ -76,12 +52,6 @@ protected override void Execute(GVFSEnlistment enlistment) string backupParent = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, DehydrateVerb.BackupFolderName)); - if (!string.IsNullOrEmpty(this.SingleBackupPath)) - { - this.PruneSingleBackup(tracer, backupParent); - return; - } - if (!this.fileSystem.DirectoryExists(backupParent)) { this.Output.WriteLine($"No backups to prune. No backup folder was found at {backupParent}."); @@ -92,25 +62,35 @@ protected override void Execute(GVFSEnlistment enlistment) if (backups.Length == 0) { this.Output.WriteLine($"No backups to prune under {backupParent}."); - this.TryDeleteDirectory(tracer, backupParent); + this.TryDeleteDirectory(backupParent, out _); return; } int deleted = 0; + bool reportedMountedFailure = false; foreach (string backup in backups) { - if (this.TryDeleteDirectory(tracer, backup)) + 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}."); + 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(tracer, backupParent); + this.TryRemoveEmptyParent(backupParent); this.WriteMessage(tracer, $"Pruned {deleted} of {backups.Length} backup folder(s)."); @@ -121,56 +101,26 @@ protected override void Execute(GVFSEnlistment enlistment) } } - private void PruneSingleBackup(ITracer tracer, string backupParent) + private bool IsRepoMounted(GVFSEnlistment enlistment) { - string backup = Path.GetFullPath(this.SingleBackupPath); - - // Only ever delete something that is actually a backup folder for this enlistment. - if (!this.IsPathWithin(backup, backupParent)) + using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) { - this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Refusing to delete '{backup}': it is not a dehydrate backup folder under '{backupParent}'."); + return pipeClient.Connect(); } - - if (!this.fileSystem.DirectoryExists(backup)) - { - this.WriteMessage(tracer, $"Backup folder {backup} does not exist; nothing to delete."); - this.TryRemoveEmptyParent(tracer, backupParent); - return; - } - - if (this.TryDeleteDirectory(tracer, backup)) - { - this.WriteMessage(tracer, $"Deleted backup folder {backup}."); - this.TryRemoveEmptyParent(tracer, backupParent); - } - else - { - this.ReportErrorAndExit(tracer, ReturnCode.GenericError, $"Failed to delete backup folder {backup}."); - } - } - - private bool IsPathWithin(string path, string parent) - { - string normalizedParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent)); - string normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - return normalizedPath.StartsWith(normalizedParent + Path.DirectorySeparatorChar, GVFSPlatform.Instance.Constants.PathComparison); } - private void TryRemoveEmptyParent(ITracer tracer, string backupParent) + private void TryRemoveEmptyParent(string backupParent) { if (this.fileSystem.DirectoryExists(backupParent) && !Directory.EnumerateFileSystemEntries(backupParent).Any()) { - this.TryDeleteDirectory(tracer, backupParent); + this.TryDeleteDirectory(backupParent, out _); } } - private bool TryDeleteDirectory(ITracer tracer, string path) + private bool TryDeleteDirectory(string path, out Exception exception) { - // A backup can contain ProjFS placeholders. Delete them without recalling their - // content through the (possibly busy or no-longer-projecting) provider, which would - // otherwise fail with "provider ... temporarily unavailable". - return this.fileSystem.TryDeleteDirectoryWithoutProviderRecall(tracer, path, BackupDeleteRetryDelayMs, BackupDeleteMaxRetries); + return this.fileSystem.TryDeleteDirectory(path, out exception); } private void WriteMessage(ITracer tracer, string message) diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index dd92887610..6950404d2c 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -61,7 +61,7 @@ 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. The deletion runs in the background after the command returns." }; + 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(); @@ -346,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) @@ -364,7 +365,22 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri if (foldersToDehydrate.Count > 0) { - this.FinalizeBackup(tracer, enlistment, backupRoot); + // 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); + } } } @@ -419,7 +435,7 @@ private bool IsFolderValid(string folderPath) return true; } - private void SendDehydrateMessage( + private NamedPipeMessages.DehydrateFolders.Response SendDehydrateMessage( ITracer tracer, GVFSEnlistment enlistment, List folderErrors, @@ -444,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())); } @@ -467,6 +484,8 @@ private void SendDehydrateMessage( folderErrors.Add(folder); } } + + return response; } private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, string backupRoot, RetryConfig retryConfig) @@ -479,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( @@ -487,7 +515,20 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri this.Output.WriteLine(); this.WriteMessage(tracer, "The repo was successfully dehydrated and remounted"); - this.FinalizeBackup(tracer, enlistment, backupRoot); + + if (this.DiscardBackup) + { + if (backupDiscarded) + { + this.RemoveEmptyBackupParent(tracer, backupRoot); + } + + this.ReportDiscardResult(tracer, backupRoot, backupDiscarded); + } + else + { + this.ReportRetainedBackup(tracer, backupRoot); + } } } else @@ -890,55 +931,60 @@ private bool TryRecreateIndex(ITracer tracer, GVFSEnlistment enlistment) return true; } - private void FinalizeBackup(ITracer tracer, GVFSEnlistment enlistment, string backupRoot) + 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), leave the backup - // untouched and stay silent so we never reference options that verb does not have. + // 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; } - if (!this.DiscardBackup) + 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) { - 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'."); return; } - // The backup contains ProjFS placeholders that were moved outside the virtualization - // root. The process that just performed the dehydrate cannot delete them: while this - // process is alive the delete fails with "provider ... temporarily unavailable" and - // never recovers (retrying for 15+ seconds does not help). A separate process deletes - // them successfully, but ONLY once this dehydrate process has exited. So we launch a - // detached 'gvfs dehydrate prune-backups' process and return immediately; the deletion - // completes in the background after this process exits. This also keeps the command - // responsive when the backup is large. - string gvfsExe = Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSPlatform.Instance.Constants.GVFSExecutableName); - string[] args = new[] - { - DehydrateVerbName, - "prune-backups", - enlistment.PrimaryEnlistmentRoot, - "--backup-path", - backupRoot, - }; - - try + if (deleted) { - GVFSPlatform.Instance.StartBackgroundVFS4GProcess(tracer, gvfsExe, args); - this.WriteMessage(tracer, $"The backup folder {backupRoot} will be deleted in the background (--discard-backup)."); - this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' if you need to confirm or retry the deletion."); + this.WriteMessage(tracer, $"Deleted backup folder {backupRoot} (--discard-backup)."); } - catch (Exception e) + else { - this.WriteMessage(tracer, $"WARNING: Failed to start deletion of backup folder {backupRoot}: {e.Message}"); - this.WriteMessage(tracer, "Run 'gvfs dehydrate prune-backups' to delete it."); + 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); From 4f3b01a9f9fa320306e6aa7e1e0dffe3eaf612e4 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 7 Jul 2026 13:31:23 -0700 Subject: [PATCH 12/12] dehydrate: fix backup databases path regression, un-skip triaged tests Follow-up triage of the DehydrateTests fixture (stacked on the --discard-backup PR). Product fixes: - Full dehydrate backed up the .gvfs databases to a stray top-level 'databases' folder instead of '.gvfs/databases'. This was an accidental regression in f0243fea (the folder-dehydrate path and the code comment both use '.gvfs/databases'). Restore the correct location. - Update the --no-status option help: combining it with --folders is now supported (per the per-folder rewrite in c8298bd1); the old text still claimed it was disallowed. Test changes: - Un-skip DehydrateShouldBackupFiles (now passes with the databases-path fix). - Remove FolderDehydrateDirtyStatusWithNoStatusShouldFail: it asserted the now-removed '--no-status not valid with --folders' error; combining is intentionally allowed. - Remove FolderDehydrateFolderThatDoesNotExist: dehydrating a nonexistent folder is now a successful no-op; the test is obsolete. Verified locally: DehydrateShouldBackupFiles, FullDehydrateWithDiscardBackupShouldDeleteBackup, DehydratePruneBackupsShouldDeleteExistingBackups all pass. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- .../EnlistmentPerFixture/DehydrateTests.cs | 22 ------------------- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 4 ++-- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs index 7659fc3ad7..51ccf2cfc7 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/DehydrateTests.cs @@ -87,7 +87,6 @@ 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); @@ -481,19 +480,6 @@ public void FolderDehydrateDirtyStatusShouldFail() GitProcess.Invoke(this.Enlistment.RepoRoot, "clean -xdf"); } - [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"; - TestPath fileToCreate = new TestPath(this.Enlistment, Path.Combine(folderToDehydrate, $"{nameof(this.FolderDehydrateDirtyStatusWithNoStatusShouldFail)}.txt")); - this.fileSystem.WriteAllText(fileToCreate.VirtualPath, "new file contents"); - fileToCreate.VirtualPath.ShouldBeAFile(this.fileSystem); - - this.DehydrateShouldFail(new[] { "Dehydrate --no-status not valid with --folders" }, noStatus: true, foldersToDehydrate: folderToDehydrate); - GitProcess.Invoke(this.Enlistment.RepoRoot, "clean -xdf"); - } - [TestCase] public void FolderDehydrateCannotDehydrateDotGitFolder() { @@ -577,14 +563,6 @@ public void FolderDehydrateRelativePaths() foldersToDehydrate: foldersToDehydrate); } - [TestCase] - [SkipInCI("Atrophied: dehydrating a nonexistent folder now reports success instead of an error. Fix in follow-up.")] - public void FolderDehydrateFolderThatDoesNotExist() - { - string folderToDehydrate = "DoesNotExist"; - this.DehydrateShouldSucceed(new[] { $"Cannot dehydrate folder '{folderToDehydrate}': '{folderToDehydrate}' does not exist." }, confirm: true, noStatus: false, foldersToDehydrate: folderToDehydrate); - } - [TestCase] [SkipInCI("Unverified: fixture hangs on ProjFS before this test runs. Triage in follow-up.")] public void FolderDehydrateNewlyCreatedFolderAndFile() diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 6950404d2c..9e1d5c810e 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -48,7 +48,7 @@ public static System.CommandLine.Command CreateCommand() System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually do the dehydrate" }; cmd.Add(confirmOption); - System.CommandLine.Option noStatusOption = new System.CommandLine.Option("--no-status") { Description = "Do not require a clean git status when dehydrating. To prevent data loss, this option cannot be combined with --folders option." }; + System.CommandLine.Option noStatusOption = new System.CommandLine.Option("--no-status") { Description = "Do not require a clean 'git status' when dehydrating. Uncommitted changes to dehydrated files are moved to the backup folder instead of blocking the operation." }; cmd.Add(noStatusOption); System.CommandLine.Option foldersOption = new System.CommandLine.Option("--folders") @@ -742,7 +742,7 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba string backupSrc = GetBackupSrcPath(backupRoot); string backupGit = Path.Combine(backupRoot, ".git"); string backupGvfs = Path.Combine(backupRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string backupDatabases = GetBackupDatabasesPath(backupRoot); + string backupDatabases = GetBackupDatabasesPath(backupGvfs); string errorMessage = string.Empty; if (!this.ShowStatusWhileRunning(