diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 4064aa7bc0..2b2f827979 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -134,9 +134,9 @@ public override void Prefetch(string branchOrCommit, bool isBranch) bool allowIndexMetadataUpdateFromWorkingTree = this.allowIndexMetadataUpdateFromWorkingTree || checkout.UpdatedWholeTree; newIndex.UpdateFileSizesAndTimes(checkout.AddedOrEditedLocalFiles, allowIndexMetadataUpdateFromWorkingTree, shouldSignIndex, sourceIndex); - // All the slow stuff is over, so we will now move the final index into .git\index, shortly followed by + // All the slow stuff is over, so we will now move the final index into place, shortly followed by // updating the ref files and releasing index.lock. - string indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + string indexPath = this.Enlistment.GitIndexPath; this.Tracer.RelatedEvent(EventLevel.Informational, "MoveUpdatedIndexToFinalLocation", new EventMetadata() { { "UpdatedIndex", indexGen.TemporaryIndexFilePath }, { "Index", indexPath } }); File.Delete(indexPath); File.Move(indexGen.TemporaryIndexFilePath, indexPath); @@ -196,7 +196,7 @@ protected override void UpdateRefs(string branchOrCommit, bool isBranch, GitRefs private Index GetSourceIndex() { - string indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + string indexPath = this.Enlistment.GitIndexPath; if (File.Exists(indexPath)) { diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 26e2de306f..959bd0eafd 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -176,6 +176,7 @@ private static WorktreeInfo TryParseWorktreeGitFile(string worktreeRoot, string /// Returns the working directory paths of all worktrees registered /// under /worktrees by reading each entry's /// gitdir file. The primary worktree is not included. + /// Unreadable registrations are skipped. Do not use this method to guard destructive operations. /// public static string[] GetKnownWorktreePaths(string gitDir) { @@ -212,6 +213,103 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } + /// + /// Reads registrations for destructive-operation guards. + /// Missing or empty reverse pointers are ignored only when no other metadata remains. + /// + public static bool TryGetKnownWorktreePaths( + string gitDir, + out string[] paths, + out string error, + out Exception exception) + { + ArgumentNullException.ThrowIfNull(gitDir); + paths = null; + error = null; + exception = null; + string pathToRead = gitDir; + try + { + // Unlike Directory.Exists, this reports access failures instead of treating them as absence. + if ((File.GetAttributes(gitDir) & FileAttributes.Directory) == 0) + { + error = $"Invalid Git directory at '{gitDir}': expected a directory."; + return false; + } + string worktreesDir = Path.Combine(gitDir, "worktrees"); + pathToRead = worktreesDir; + try + { + File.GetAttributes(worktreesDir); + } + catch (FileNotFoundException) + { + File.GetAttributes(gitDir); + paths = Array.Empty(); + return true; + } + catch (DirectoryNotFoundException) + { + File.GetAttributes(gitDir); + paths = Array.Empty(); + return true; + } + + List worktreePaths = new List(); + foreach (string entry in Directory.GetDirectories(worktreesDir)) + { + pathToRead = Path.Combine(entry, "gitdir"); + string gitdirContent = string.Empty; + try + { + using (FileStream stream = File.OpenRead(pathToRead)) + { + using (StreamReader reader = new StreamReader(stream)) + { + gitdirContent = reader.ReadToEnd(); + } + } + } + catch (FileNotFoundException) + { + } + + if (gitdirContent.Length == 0) + { + foreach (string metadataPath in Directory.GetFileSystemEntries(entry)) + { + if (!string.Equals(Path.GetFileName(metadataPath), "gitdir", StringComparison.Ordinal)) + { + error = $"Cannot verify worktree registration at '{pathToRead}': the pointer is missing or empty, but metadata remains at '{metadataPath}'."; + return false; + } + } + + continue; + } + + gitdirContent = gitdirContent.Trim().Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(gitdirContent) || + !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) + { + error = $"Invalid worktree registration at '{pathToRead}': expected a path to a .git file."; + return false; + } + + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent, entry))); + } + + paths = worktreePaths.ToArray(); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException) + { + error = $"Failed to read worktree registrations at '{pathToRead}': {ex.Message}"; + exception = ex; + return false; + } + } + public class WorktreeInfo { public const string EnlistmentRootFileName = "gvfs-enlistment-root"; diff --git a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs index 19c5981358..5c27e32619 100644 --- a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs +++ b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs @@ -37,6 +37,7 @@ public class GitIndexGenerator 0, 0, 0, 0 // file length }; + private readonly string indexPath; private readonly string indexLockPath; private Enlistment enlistment; @@ -63,7 +64,8 @@ public GitIndexGenerator(ITracer tracer, Enlistment enlistment, bool shouldHashI // other is from the 'gvfs repair' verb. That environment is special in that it only runs on unmounted // repo's, so 'index.lock' is irrelevant as a locking mechanism in that context. There can't be git // commands to lock out. - this.indexLockPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName + ".lock2"); + this.indexPath = enlistment.GitIndexPath; + this.indexLockPath = this.indexPath + ".lock2"; } public string TemporaryIndexFilePath => this.indexLockPath; @@ -82,7 +84,12 @@ public GitIndexGenerator(ITracer tracer, Enlistment enlistment, bool shouldHashI /// public void CreateFromRef(string refName, uint indexVersion, bool isFinal) { - using (ITracer updateIndexActivity = this.tracer.StartActivity("CreateFromHeadTree", EventLevel.Informational)) + EventMetadata metadata = new EventMetadata + { + { "IndexPath", this.indexPath }, + { "IsWorktree", this.enlistment is GVFSEnlistment gvfsEnlistment && gvfsEnlistment.IsWorktree }, + }; + using (ITracer updateIndexActivity = this.tracer.StartActivity("CreateFromHeadTree", EventLevel.Informational, metadata)) { Thread entryWritingThread = new Thread(() => this.WriteAllEntries(indexVersion, isFinal)); entryWritingThread.Start(); @@ -245,11 +252,10 @@ private byte[] GetIndexHash() return new byte[20]; } - private void ReplaceExistingIndex() + internal void ReplaceExistingIndex() { - string indexPath = Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); - File.Delete(indexPath); - File.Move(this.indexLockPath, indexPath); + File.Delete(this.indexPath); + File.Move(this.indexLockPath, this.indexPath); } private class LsTreeEntry diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 7de8bb680a..1ab25b7080 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text; using System.Threading; +using ProcessHelper = GVFS.FunctionalTests.Tools.ProcessHelper; using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture @@ -363,6 +364,230 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } + [TestCase(false)] + [TestCase(true)] + public void WorktreeDehydratePreservesPrimaryIndex(bool trailingSeparator) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreeName = $"index-dehydrate-{suffix}"; + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, worktreeName); + string branchName = $"index-dehydrate-branch-{suffix}"; + string backupParent = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup", worktreeName); + + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "index dehydrate worktree"); + + File.WriteAllText(Path.Combine(worktreePath, "worktree-index-only.txt"), "Linked worktree content"); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "add worktree-index-only.txt").ExitCode.ShouldEqual(0); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "commit -m \"Add worktree-only index entry\"").ExitCode.ShouldEqual(0); + + string worktreeIndex = this.GetGitIndexPath(worktreePath); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + primaryIndexBefore.SequenceEqual(worktreeIndexBefore).ShouldBeFalse("The indexes must differ so the backup assertion detects the wrong source."); + + ProcessStartInfo startInfo = new ProcessStartInfo(GVFSTestConfig.PathToGVFS) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = this.Enlistment.EnlistmentRoot, + }; + startInfo.ArgumentList.Add("dehydrate"); + startInfo.ArgumentList.Add(worktreePath + (trailingSeparator ? Path.DirectorySeparatorChar.ToString() : string.Empty)); + startInfo.ArgumentList.Add("--confirm"); + startInfo.ArgumentList.Add("--folders"); + startInfo.ArgumentList.Add("GVFS"); + startInfo.ArgumentList.Add(TestConstants.InternalUseOnlyFlag); + startInfo.ArgumentList.Add("{\"ServiceName\":\"" + this.Enlistment.ServiceName + "\"}"); + ProcessResult dehydrate = ProcessHelper.Run(startInfo, timeoutMs: 120000); + dehydrate.ExitCode.ShouldEqual(0, dehydrate.Output + dehydrate.Errors); + dehydrate.Output.ShouldContain("GVFS folder dehydrate successful."); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex), "The primary index must not change."); + + string[] backups = Directory.GetDirectories(backupParent); + backups.Length.ShouldEqual(1); + CollectionAssert.AreEqual( + worktreeIndexBefore, + File.ReadAllBytes(Path.Combine(backups[0], ".git", "index")), + "The backup must contain the linked worktree index."); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + if (Directory.Exists(backupParent)) + { + Directory.Delete(backupParent, recursive: true); + } + } + } + + [TestCase(false)] + [TestCase(true)] + public void FullDehydrateRejectsLinkedWorktreesBeforeMutation(bool fromWorktree) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, $"full-guard-{suffix}"); + string branchName = $"full-guard-branch-{suffix}"; + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "full dehydrate guard worktree"); + + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + string dehydratePath = fromWorktree ? worktreePath : this.Enlistment.EnlistmentRoot; + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{dehydratePath}\" --full --confirm {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain(fromWorktree + ? "Dehydrate --full is not supported for git worktrees." + : "Dehydrate --full is not supported while linked worktrees exist."); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + CollectionAssert.AreEqual(worktreeIndexBefore, File.ReadAllBytes(worktreeIndex)); + this.AssertWorktreeMounted(worktreePath, "worktree after rejected full dehydrate"); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + } + } + + [TestCase("missing")] + [TestCase("empty")] + [TestCase("unreadable")] + public void FullDehydrateRejectsUsableWorktreeOmittedByGit(string pointerState) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, $"hidden-guard-{suffix}"); + string branchName = $"hidden-guard-{suffix}"; + string reversePointer = null; + byte[] originalPointer = null; + FileStream lockedPointer = null; + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "worktree with incomplete reverse pointer"); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + reversePointer = Path.Combine(Path.GetDirectoryName(worktreeIndex), "gitdir"); + originalPointer = File.ReadAllBytes(reversePointer); + if (pointerState == "missing") + { + File.Delete(reversePointer); + } + else if (pointerState == "empty") + { + File.WriteAllBytes(reversePointer, Array.Empty()); + } + else + { + lockedPointer = new FileStream(reversePointer, FileMode.Open, FileAccess.Read, FileShare.None); + } + + ProcessResult list = GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "worktree list --porcelain"); + list.ExitCode.ShouldEqual(0, list.Errors); + list.Output.Contains(worktreePath.Replace('\\', '/')).ShouldBeFalse("Git must omit the worktree in this regression scenario."); + ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "status --porcelain=v1"); + status.ExitCode.ShouldEqual(0, status.Errors); + + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{this.Enlistment.EnlistmentRoot}\" --full --confirm {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain("Full dehydrate is blocked."); + result.Output.ShouldContain(reversePointer); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + CollectionAssert.AreEqual(worktreeIndexBefore, File.ReadAllBytes(worktreeIndex)); + this.AssertWorktreeMounted(worktreePath, "worktree after blocked full dehydrate"); + } + finally + { + lockedPointer?.Dispose(); + if (originalPointer != null) + { + File.WriteAllBytes(reversePointer, originalPointer); + } + + this.ForceCleanupWorktree(worktreePath, branchName); + } + } + + [TestCase] + public void WorktreeDehydrateReportsBackupFailure() + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreeName = $"backup-failure-{suffix}"; + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, worktreeName); + string branchName = $"backup-failure-branch-{suffix}"; + string backupParent = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup", worktreeName); + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "backup failure worktree"); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + using (FileStream locked = new FileStream(worktreeIndex, FileMode.Open, FileAccess.Read, FileShare.None)) + { + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{worktreePath}\" --confirm --no-status --folders GVFS {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain("Failed to back up"); + result.Output.ShouldContain(worktreeIndex); + } + + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + if (Directory.Exists(backupParent)) + { + Directory.Delete(backupParent, recursive: true); + } + } + } + + private string GetGitIndexPath(string workingDirectory) + { + ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo( + workingDirectory, + "rev-parse --path-format=absolute --git-path index"); + result.ExitCode.ShouldEqual(0, result.Errors); + return Path.GetFullPath(result.Output.Trim()); + } + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) { paths = new string[count]; diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs new file mode 100644 index 0000000000..c481285e99 --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -0,0 +1,650 @@ +using GVFS.CommandLine; +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.RepairJobs; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO; +using System.Text.Json; + +namespace GVFS.UnitTests.CommandLine +{ + [TestFixture] + public class GitIndexPathCallerTests + { + private string testRoot; + + [SetUp] + public void SetUp() + { + if (GVFSPlatform.Instance == null) + { + GVFSPlatform.Register(new MockPlatform()); + } + + this.testRoot = Path.Combine(Path.GetTempPath(), "GitIndexPathCallerTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.testRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testRoot)) + { + Directory.Delete(this.testRoot, recursive: true); + } + } + + [TestCase(false)] + [TestCase(true)] + public void GitIndexGeneratorUsesEnlistmentIndexPath(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + if (!isWorktree) + { + enlistment.GitIndexPath.ShouldEqual(Path.Combine(enlistment.DotGitRoot, "index")); + } + + Directory.CreateDirectory(Path.GetDirectoryName(enlistment.GitIndexPath)); + + File.WriteAllText(enlistment.GitIndexPath, "old index"); + if (isWorktree) + { + File.WriteAllText(Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), "shared index"); + } + + GitIndexGenerator generator = new GitIndexGenerator(new MockTracer(), enlistment, shouldHashIndex: false); + generator.TemporaryIndexFilePath.ShouldEqual(enlistment.GitIndexPath + ".lock2"); + File.WriteAllText(generator.TemporaryIndexFilePath, "new index"); + + generator.ReplaceExistingIndex(); + + File.ReadAllText(enlistment.GitIndexPath).ShouldEqual("new index"); + if (isWorktree) + { + File.ReadAllText(Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName)).ShouldEqual("shared index"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void GitIndexRepairJobUsesEnlistmentIndexPath(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + GitIndexRepairJob repairJob = new GitIndexRepairJob(new MockTracer(), TextWriter.Null, enlistment); + + repairJob.IndexPath.ShouldEqual(enlistment.GitIndexPath); + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + string indexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + Directory.CreateDirectory(indexDirectory); + File.WriteAllText(enlistment.GitIndexPath, "working tree index"); + + string sharedIndexPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + if (isWorktree) + { + File.WriteAllText(sharedIndexPath, "shared index"); + } + + string backupGit = Path.Combine(this.testRoot, "backup-" + isWorktree + "-" + move, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitIndex( + new MockTracer(), + enlistment, + backupGit, + move, + out string errorMessage); + + result.ShouldEqual(true, errorMessage); + File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)).ShouldEqual("working tree index"); + File.Exists(enlistment.GitIndexPath).ShouldEqual(!move); + if (isWorktree) + { + File.ReadAllText(sharedIndexPath).ShouldEqual("shared index"); + } + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + string indexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + Directory.CreateDirectory(indexDirectory); + string indexLockPath = Path.Combine(indexDirectory, GVFSConstants.DotGit.IndexName + ".lock"); + File.WriteAllText(indexLockPath, "working tree lock"); + + string sharedLockPath = Path.Combine(enlistment.DotGitRoot, "shared.lock"); + if (isWorktree) + { + File.WriteAllText(sharedLockPath, "shared lock"); + } + + string backupGit = Path.Combine(this.testRoot, "lock-backup-" + isWorktree + "-" + move, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitLocks( + new MockTracer(), + enlistment, + backupGit, + move, + out string errorMessage); + + result.ShouldEqual(true, errorMessage); + File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName + ".lock")).ShouldEqual("working tree lock"); + File.Exists(indexLockPath).ShouldEqual(!move); + if (isWorktree) + { + File.ReadAllText(sharedLockPath).ShouldEqual("shared lock"); + File.Exists(Path.Combine(backupGit, "shared.lock")).ShouldBeFalse(); + } + } + + [TestCase] + public void FullDehydrateRejectsWorktree() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + StringWriter output = new StringWriter(); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = output, + }; + + Assert.Throws( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + + output.ToString().ShouldContain("Dehydrate --full is not supported for git worktrees."); + } + + [TestCase] + public void FullDehydrateRejectsPrimaryWithLinkedWorktree() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "linked-worktree"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText( + Path.Combine(worktreeGitDir, "gitdir"), + Path.Combine(this.testRoot, "linked-worktree", GVFSConstants.DotGit.Root)); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees", "not-a-registration")); + + StringWriter output = new StringWriter(); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = output, + }; + + Assert.Throws( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + + output.ToString().ShouldContain("Dehydrate --full is not supported while linked worktrees exist."); + output.ToString().ShouldContain(Path.Combine(this.testRoot, "linked-worktree")); + output.ToString().ShouldContain("git worktree remove "); + output.ToString().ShouldContain("git worktree prune"); + output.ToString().ShouldContain("git worktree unlock "); + output.ToString().ShouldContain("back up or push"); + output.ToString().Contains("not-a-registration").ShouldBeFalse(); + } + + [TestCase] + public void FullDehydrateResolvesRelativeRegistration() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "relative"); + Directory.CreateDirectory(worktreeGitDir); + string worktreePath = Path.Combine(this.testRoot, "relative-worktree"); + File.WriteAllText( + Path.Combine(worktreeGitDir, "gitdir"), + Path.GetRelativePath(worktreeGitDir, Path.Combine(worktreePath, ".git")).Replace('\\', '/') + "\n"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("not supported while linked worktrees exist."); + output.ToString().ShouldContain(worktreePath); + output.ToString().Contains("Invalid worktree registration").ShouldBeFalse(); + } + } + + [TestCase("\n")] + [TestCase(" ")] + [TestCase(" \n")] + [TestCase("not-an-absolute-path")] + [TestCase(@"C:\worktrees\bad\HEAD")] + public void FullDehydrateRejectsMalformedRegistration(string registration) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); + + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(Path.Combine(worktreeGitDir, "gitdir")); + output.ToString().ShouldContain("git worktree list"); + output.ToString().ShouldContain("git worktree prune"); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + Directory.Exists(enlistment.WorkingDirectoryBackingRoot).ShouldBeTrue(); + } + } + + [TestCase] + public void FullDehydrateRejectsUnreadableRegistration() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); + Directory.CreateDirectory(worktreeGitDir); + string gitdirFile = Path.Combine(worktreeGitDir, "gitdir"); + File.WriteAllText(gitdirFile, Path.Combine(this.testRoot, "linked-worktree", GVFSConstants.DotGit.Root)); + + using (FileStream locked = new FileStream(gitdirFile, FileMode.Open, FileAccess.Read, FileShare.None)) + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(gitdirFile); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); + } + + output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); + } + } + + [TestCase] + public void FullDehydrateRejectsUnreadableRegistrationsDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + File.WriteAllText(Path.Combine(enlistment.DotGitRoot, "worktrees"), "not a directory"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + } + } + + [TestCase] + public void FullDehydrateAllowsPrimaryWithoutLinkedWorktrees() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = TextWriter.Null, + }; + + Assert.DoesNotThrow( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + } + + [TestCase] + public void FullDehydrateAllowsEmptyRegistrationsDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees")); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateIgnoresDirectoryWithoutGitdir() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees", "not-a-registration")); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateIgnoresEmptyGitdir() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "empty"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllBytes(Path.Combine(worktreeGitDir, "gitdir"), Array.Empty()); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateRejectsUnverifiableStateWhenAnotherWorktreeIsReadable() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string validDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "valid"); + string unreadableDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "unreadable"); + Directory.CreateDirectory(validDir); + Directory.CreateDirectory(unreadableDir); + string worktreePath = Path.Combine(this.testRoot, "visible-worktree"); + File.WriteAllText(Path.Combine(validDir, "gitdir"), Path.Combine(worktreePath, ".git") + "\n"); + string unreadableFile = Path.Combine(unreadableDir, "gitdir"); + File.WriteAllText(unreadableFile, Path.Combine(this.testRoot, "hidden-worktree", ".git")); + using (FileStream locked = new FileStream(unreadableFile, FileMode.Open, FileAccess.Read, FileShare.None)) + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(unreadableFile); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase(false, "HEAD")] + [TestCase(true, "HEAD")] + [TestCase(false, "commondir")] + [TestCase(true, "commondir")] + [TestCase(false, ".gvfs")] + [TestCase(true, ".gvfs")] + public void FullDehydrateRejectsMissingOrEmptyPointerWithRemainingMetadata(bool emptyPointer, string metadataName) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string entry = Path.Combine(enlistment.DotGitRoot, "worktrees", "incomplete"); + Directory.CreateDirectory(entry); + if (emptyPointer) + { + File.WriteAllBytes(Path.Combine(entry, "gitdir"), Array.Empty()); + } + + string metadataPath = Path.Combine(entry, metadataName); + if (metadataName == ".gvfs") + { + Directory.CreateDirectory(metadataPath); + } + else + { + File.WriteAllText(metadataPath, "remaining metadata"); + } + + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(metadataPath); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase] + public void FullDehydrateRejectsMissingGitDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.Delete(enlistment.DotGitRoot); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase] + public void FullDehydrateRejectsGitDirectoryReplacedByFile() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.Delete(enlistment.DotGitRoot); + File.WriteAllText(enlistment.DotGitRoot, "not a directory"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree, bool trailingSeparator) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree, trailingSeparator: trailingSeparator); + DateTime timestamp = new DateTime(2026, 9, 23, 13, 0, 0, 123); + + string backupRoot = DehydrateVerb.GetBackupRoot(enlistment, timestamp); + + string expectedParent = isWorktree + ? this.testRoot + : enlistment.PrimaryEnlistmentRoot; + string expectedSubfolder = isWorktree + ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) + : "dehydrate_backup"; + backupRoot.ShouldEqual( + Path.Combine( + expectedParent, + expectedSubfolder, + "20260923_130000_1230000")); + DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); + } + + [TestCase] + public void MissingIndexReturnsBackupError() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + string backupGit = Path.Combine(this.testRoot, "missing-index-backup"); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitIndex( + new MockTracer(), + enlistment, + backupGit, + move: false, + out string errorMessage); + + result.ShouldBeFalse(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(Path.Combine(backupGit, "index")); + } + + [TestCase] + public void MissingLockDirectoryReturnsBackupError() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + Directory.Delete(Path.GetDirectoryName(enlistment.GitIndexPath)); + string backupGit = Path.Combine(this.testRoot, "missing-lock-backup"); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitLocks( + new MockTracer(), + enlistment, + backupGit, + move: false, + out string errorMessage); + + result.ShouldBeFalse(); + errorMessage.ShouldNotBeNull(); + } + + [TestCase(false)] + [TestCase(true)] + public void LockBackupReturnsPreciseFileError(bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string lockPath = enlistment.GitIndexPath + ".lock"; + File.WriteAllText(lockPath, "lock content"); + string backupGit = Path.Combine(this.testRoot, "occupied-backup"); + Directory.CreateDirectory(backupGit); + string backupLockPath = Path.Combine(backupGit, Path.GetFileName(lockPath)); + File.WriteAllText(backupLockPath, "previous backup"); + + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb(); + verb.TryBackupGitLocks(tracer, enlistment, backupGit, move, out string errorMessage).ShouldBeFalse(); + errorMessage.ShouldContain(lockPath); + errorMessage.ShouldContain(backupLockPath); + using (JsonDocument errorEvent = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + errorMessage.ShouldContain(errorEvent.RootElement.GetProperty("Error").GetString()); + errorEvent.RootElement.GetProperty("Exception").GetString().ShouldContain(nameof(IOException)); + } + + File.ReadAllText(lockPath).ShouldEqual("lock content"); + File.ReadAllText(backupLockPath).ShouldEqual("previous backup"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void IndexBackupReturnsPreciseFileError(bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + File.WriteAllText(enlistment.GitIndexPath, "worktree index"); + string backupGit = Path.Combine(this.testRoot, "occupied-index-backup"); + Directory.CreateDirectory(backupGit); + string backupIndex = Path.Combine(backupGit, "index"); + File.WriteAllText(backupIndex, "previous backup"); + + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb(); + verb.TryBackupGitIndex(tracer, enlistment, backupGit, move, out string errorMessage).ShouldBeFalse(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(backupIndex); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + errorMessage.ShouldContain(metadata.RootElement.GetProperty("Error").GetString()); + } + + File.ReadAllText(enlistment.GitIndexPath).ShouldEqual("worktree index"); + File.ReadAllText(backupIndex).ShouldEqual("previous backup"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void IndexGenerationTracesResolvedDestination(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree, gitBinPath: "git.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(enlistment.GitIndexPath)); + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "IndexGeneration", disableTelemetry: true)) + using (IndexGenerationListener listener = new IndexGenerationListener()) + { + tracer.AddEventListener(listener); + GitIndexGenerator generator = new GitIndexGenerator(tracer, enlistment, shouldHashIndex: false); + // No repository exists here. Git returns a failure without contacting a remote. + generator.CreateFromRef("HEAD", indexVersion: 4, isFinal: false); + + generator.HasFailures.ShouldBeTrue(); + listener.ActivityPayload.ShouldNotBeNull(); + using (JsonDocument metadata = JsonDocument.Parse(listener.ActivityPayload)) + { + metadata.RootElement.GetProperty("IndexPath").GetString().ShouldEqual(enlistment.GitIndexPath); + metadata.RootElement.GetProperty("IsWorktree").GetBoolean().ShouldEqual(isWorktree); + } + } + } + + private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = null, bool trailingSeparator = false) + { + gitBinPath = gitBinPath ?? Path.Combine(this.testRoot, "git.exe"); + string primaryRoot = Path.Combine(this.testRoot, isWorktree ? "worktree-primary" : "standard"); + string primarySrc = Path.Combine(primaryRoot, GVFSConstants.WorkingDirectoryRootName); + string sharedGitDir = Path.Combine(primarySrc, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(sharedGitDir); + + if (!isWorktree) + { + return new GVFSEnlistment(primaryRoot, "mock://repo", gitBinPath, authentication: null); + } + + string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); + if (trailingSeparator) + { + worktreePath += Path.DirectorySeparatorChar; + } + + string worktreeGitDir = Path.Combine(sharedGitDir, "worktrees", "linked-worktree"); + Directory.CreateDirectory(worktreePath); + Directory.CreateDirectory(worktreeGitDir); + + GVFSEnlistment.WorktreeInfo worktreeInfo = new GVFSEnlistment.WorktreeInfo + { + Name = "linked-worktree", + WorktreePath = worktreePath, + WorktreeGitDir = worktreeGitDir, + SharedGitDir = sharedGitDir, + PipeSuffix = "_WT_LINKED-WORKTREE", + }; + + return GVFSEnlistment.CreateForWorktree( + primaryRoot, + gitBinPath, + authentication: null, + worktreeInfo, + repoUrl: "mock://repo"); + } + + private class IndexGenerationListener : EventListener + { + public IndexGenerationListener() + : base(EventLevel.Informational, Keywords.Any, null) + { + } + + public string ActivityPayload { get; private set; } + + protected override void RecordMessageInternal(TraceEventMessage message) + { + if (message.EventName == "CreateFromHeadTree" && message.Opcode == EventOpcode.Start) + { + this.ActivityPayload = message.Payload; + } + } + } + } +} diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce303572..61d5968b8b 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -99,6 +99,7 @@ protected override void Execute(GVFSEnlistment enlistment) { "Confirmed", this.Confirmed }, { "NoStatus", this.NoStatus }, { "Full", this.Full }, + { "IsWorktree", enlistment.IsWorktree }, { "NamedPipeName", enlistment.NamedPipeName }, { "Folders", this.Folders }, { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, @@ -144,6 +145,8 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } + this.ValidateFullDehydrate(tracer, enlistment); + if (!this.Confirmed && fullDehydrate) { this.Output.WriteLine( @@ -202,7 +205,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 = GetBackupRoot(enlistment, DateTime.Now); this.Output.WriteLine(); if (fullDehydrate) @@ -280,7 +283,7 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri { if (!this.TryBackupNonSrcFiles(tracer, enlistment, backupRoot)) { - return; + this.ReportErrorAndExit(tracer, "Folder dehydrate failed because the backup failed."); } List foldersToDehydrate = new List(); @@ -482,7 +485,7 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri tracer, skipVersionCheck: !this.NoStatus); - this.WriteMessage(tracer, "Dehydrate failed, but remounting succeeded"); + this.ReportErrorAndExit(tracer, "Dehydrate failed, but remounting succeeded"); } } @@ -628,19 +631,13 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str // ... backup the .gvfs hydration-related data structures... string databasesFolder = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name); - if (!this.TryCopyFilesInFolder(tracer, databasesFolder, backupDatabases, searchPattern: "*", filenamesToSkip: "RepoMetadata.dat")) + if (!this.TryCopyFilesInFolder(tracer, databasesFolder, backupDatabases, "*", out errorMessage, "RepoMetadata.dat")) { return false; } // ... backup everything related to the .git\index... - if (!this.TryIO( - tracer, - () => File.Copy( - Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), - Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)), - "Backup the git index", - out errorMessage) || + if (!this.TryBackupGitIndex(tracer, enlistment, backupGit, move: false, out errorMessage) || !this.TryIO( tracer, () => File.Copy( @@ -652,10 +649,9 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str return false; } - // ... backup all .git\*.lock files - if (!this.TryCopyFilesInFolder(tracer, enlistment.DotGitRoot, backupGit, searchPattern: "*.lock")) + // ... backup all lock files for this working tree + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false, out errorMessage)) { - errorMessage = "Failed to backup .git lock files."; return false; } @@ -715,19 +711,13 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba // ... backup the .gvfs hydration-related data structures... string databasesFolder = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name); - if (!this.TryBackupFilesInFolder(tracer, databasesFolder, backupDatabases, searchPattern: "*", filenamesToSkip: "RepoMetadata.dat")) + if (!this.TryBackupFilesInFolder(tracer, databasesFolder, backupDatabases, "*", out errorMessage, "RepoMetadata.dat")) { return false; } // ... backup everything related to the .git\index... - if (!this.TryIO( - tracer, - () => File.Move( - Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), - Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)), - "Backup the git index", - out errorMessage) || + if (!this.TryBackupGitIndex(tracer, enlistment, backupGit, move: true, out errorMessage) || !this.TryIO( tracer, () => File.Move( @@ -739,8 +729,8 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return false; } - // ... backup all .git\*.lock files - if (!this.TryBackupFilesInFolder(tracer, enlistment.DotGitRoot, backupGit, searchPattern: "*.lock")) + // ... backup all lock files for this working tree + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true, out errorMessage)) { return false; } @@ -758,47 +748,152 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return true; } - private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + internal bool TryBackupGitIndex( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string backupIndexPath = Path.Combine(backupGit, GVFSConstants.DotGit.IndexName); + Action backupIndex = move + ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) + : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); + + if (!this.TryIO(tracer, backupIndex, $"Back up {enlistment.GitIndexPath} to {backupIndexPath}", out errorMessage)) { - string fileName = Path.GetFileName(file); - if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) + errorMessage = $"Failed to back up '{enlistment.GitIndexPath}' to '{backupIndexPath}': {errorMessage}"; + return false; + } + + return true; + } + + internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) + { + if (!this.Full) + { + return; + } + + if (enlistment.IsWorktree) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported for git worktrees. " + + "Use 'gvfs dehydrate --confirm --folders ' for folder dehydrate."); + } + + string cleanupAdvice = + "Before you remove worktrees, back up or push any work you need to keep. " + + "Use 'git worktree remove ' to remove a worktree. " + + "If its directory no longer exists, use 'git worktree prune' to remove stale registrations. " + + "If a stale registration is locked, use 'git worktree unlock ' before pruning."; + bool readSucceeded = GVFSEnlistment.TryGetKnownWorktreePaths( + enlistment.DotGitRoot, + out string[] worktreePaths, + out string error, + out Exception exception); + + if (!readSucceeded) + { + EventMetadata metadata = new EventMetadata { - if (!this.TryIO( - tracer, - () => File.Move(file, file.Replace(folderPath, backupPath)), - $"Backing up {Path.GetFileName(file)}", - out errorMessage)) - { - return false; - } + { "Error", error }, + { "DotGitRoot", enlistment.DotGitRoot }, + }; + if (exception != null) + { + metadata.Add("Exception", exception.ToString()); } + + tracer.RelatedError(metadata, "Cannot verify linked worktree registrations."); + this.ReportErrorAndExit( + "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error + Environment.NewLine + + "Check access to the registration path. Use 'git worktree list' to inspect registrations. " + cleanupAdvice); } - return true; + if (worktreePaths.Length > 0) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported while linked worktrees exist." + Environment.NewLine + + string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + cleanupAdvice); + } } - private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timestamp) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string backupParent = enlistment.PrimaryEnlistmentRoot; + string backupSubfolder = string.Empty; + if (enlistment.IsWorktree) + { + backupParent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(enlistment.WorkingDirectoryBackingRoot)); + backupSubfolder = enlistment.Worktree.Name; + } + + return Path.GetFullPath( + Path.Combine( + backupParent, + "dehydrate_backup", + backupSubfolder, + timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + } + + internal bool TryBackupGitLocks( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) + { + string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage) + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage); + } + + private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, out string errorMessage, params string[] filenamesToSkip) + { + return this.TryTransferFilesInFolder(tracer, folderPath, backupPath, searchPattern, move: true, out errorMessage, filenamesToSkip); + } + + private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, out string errorMessage, params string[] filenamesToSkip) + { + return this.TryTransferFilesInFolder(tracer, folderPath, backupPath, searchPattern, move: false, out errorMessage, filenamesToSkip); + } + + private bool TryTransferFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, bool move, out string errorMessage, params string[] filenamesToSkip) + { + string[] files = null; + if (!this.TryIO(tracer, () => files = Directory.GetFiles(folderPath, searchPattern), $"Enumerate files in {folderPath}", out errorMessage)) + { + errorMessage = $"Failed to enumerate '{folderPath}': {errorMessage}"; + return false; + } + + foreach (string file in files) { string fileName = Path.GetFileName(file); if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) { + string destination = Path.Combine(backupPath, fileName); + Action transfer = move + ? () => File.Move(file, destination) + : () => File.Copy(file, destination); if (!this.TryIO( tracer, - () => File.Copy(file, file.Replace(folderPath, backupPath)), - $"Backing up {Path.GetFileName(file)}", + transfer, + $"Back up {file} to {destination}", out errorMessage)) { + errorMessage = $"Failed to back up '{file}' to '{destination}': {errorMessage}"; return false; } } } + errorMessage = null; return true; } @@ -906,7 +1001,8 @@ private bool TryIO(ITracer tracer, Action action, string description, out string new EventMetadata { { "Description", description }, - { "Error", error } + { "Error", error }, + { "Exception", e.ToString() } }, "TryIO: Caught exception performing action"); } diff --git a/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs b/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs index 153634badc..14bca8d531 100644 --- a/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs +++ b/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs @@ -13,9 +13,11 @@ public class GitIndexRepairJob : RepairJob public GitIndexRepairJob(ITracer tracer, TextWriter output, GVFSEnlistment enlistment) : base(tracer, output, enlistment) { - this.indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + this.indexPath = enlistment.GitIndexPath; } + internal string IndexPath => this.indexPath; + public override string Name { get { return GVFSConstants.DotGit.Index; }