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