From 3f62f715ff9ec50361beff41f58b970aecf80dd8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 23 Sep 2026 11:48:06 -0700 Subject: [PATCH 1/7] Use worktree-specific git index paths Route index generation, repair, FastFetch, and dehydrate backups through GitIndexPath. Reject full dehydrate for linked worktrees before it moves the .git file. Assisted-by: GPT-5.6 Sol Signed-off-by: Tyrie Vella --- GVFS/FastFetch/CheckoutPrefetcher.cs | 4 +- GVFS/GVFS.Common/Git/GitIndexGenerator.cs | 11 +- .../CommandLine/GitIndexPathCallerTests.cs | 185 ++++++++++++++++++ GVFS/GVFS/CommandLine/DehydrateVerb.cs | 56 ++++-- GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs | 4 +- 5 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 4064aa7bc0..92afc28244 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -136,7 +136,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch) // All the slow stuff is over, so we will now move the final index into .git\index, 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/Git/GitIndexGenerator.cs b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs index 19c5981358..07b441716b 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; @@ -245,11 +247,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.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs new file mode 100644 index 0000000000..f3ef2e31d3 --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -0,0 +1,185 @@ +using GVFS.CommandLine; +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.RepairJobs; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO; + +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); + 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)] + [TestCase(true, true)] + 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)] + [TestCase(true, true)] + 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); + + result.ShouldBeTrue(); + 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(); + } + } + + private GVFSEnlistment CreateEnlistment(bool isWorktree) + { + string 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, "https://mock/repo", gitBinPath, authentication: null); + } + + string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); + 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: "https://mock/repo"); + } + } +} diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce303572..4b8b092572 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -144,6 +144,11 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } + if (fullDehydrate && enlistment.IsWorktree) + { + this.ReportErrorAndExit("Dehydrate --full is not supported for git worktrees."); + } + if (!this.Confirmed && fullDehydrate) { this.Output.WriteLine( @@ -634,13 +639,7 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str } // ... 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,8 +651,8 @@ 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)) { errorMessage = "Failed to backup .git lock files."; return false; @@ -721,13 +720,7 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba } // ... 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 +732,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)) { return false; } @@ -758,6 +751,33 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return true; } + internal bool TryBackupGitIndex( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) + { + string backupIndexPath = Path.Combine(backupGit, GVFSConstants.DotGit.IndexName); + Action backupIndex = move + ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) + : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); + + return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); + } + + internal bool TryBackupGitLocks( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move) + { + string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + } + private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) { string errorMessage; 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; } From bdb1223b0547fe44cc80aa658906a4779a414a7e Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 23 Sep 2026 13:28:37 -0700 Subject: [PATCH 2/7] Harden worktree dehydrate safeguards Reject full dehydrate when linked worktrees exist. Store worktree backups beside each worktree with unique paths. Preserve backup errors and add regression coverage. Assisted-by: GPT-5.6 Sol Signed-off-by: Tyrie Vella --- GVFS/FastFetch/CheckoutPrefetcher.cs | 2 +- .../CommandLine/GitIndexPathCallerTests.cs | 122 +++++++++++++++++- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 82 ++++++++++-- 3 files changed, 187 insertions(+), 19 deletions(-) diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 92afc28244..2b2f827979 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -134,7 +134,7 @@ 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 = this.Enlistment.GitIndexPath; this.Tracer.RelatedEvent(EventLevel.Informational, "MoveUpdatedIndexToFinalLocation", new EventMetadata() { { "UpdatedIndex", indexGen.TemporaryIndexFilePath }, { "Index", indexPath } }); diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index f3ef2e31d3..065754fe71 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -75,7 +75,6 @@ public void GitIndexRepairJobUsesEnlistmentIndexPath(bool isWorktree) [TestCase(false, false)] [TestCase(false, true)] [TestCase(true, false)] - [TestCase(true, true)] public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); @@ -112,7 +111,6 @@ public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) [TestCase(false, false)] [TestCase(false, true)] [TestCase(true, false)] - [TestCase(true, true)] public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool move) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); @@ -135,9 +133,10 @@ public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool mo new MockTracer(), enlistment, backupGit, - move); + move, + out string errorMessage); - result.ShouldBeTrue(); + result.ShouldEqual(true, errorMessage); File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName + ".lock")).ShouldEqual("working tree lock"); File.Exists(indexLockPath).ShouldEqual(!move); if (isWorktree) @@ -147,6 +146,121 @@ public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool mo } } + [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)); + + 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."); + } + + [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(false)] + [TestCase(true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + DateTime timestamp = new DateTime(2026, 9, 23, 13, 0, 0, 123); + + string backupRoot = DehydrateVerb.GetBackupRoot(enlistment, timestamp); + + string expectedParent = isWorktree + ? Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot) + : enlistment.PrimaryEnlistmentRoot; + string expectedSubfolder = isWorktree + ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) + : "dehydrate_backup"; + backupRoot.ShouldEqual( + Path.Combine( + expectedParent, + expectedSubfolder, + timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + } + + [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.ShouldNotBeNull(); + } + + [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(); + } + private GVFSEnlistment CreateEnlistment(bool isWorktree) { string gitBinPath = Path.Combine(this.testRoot, "git.exe"); diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 4b8b092572..7adf9bd19b 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,10 +145,7 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } - if (fullDehydrate && enlistment.IsWorktree) - { - this.ReportErrorAndExit("Dehydrate --full is not supported for git worktrees."); - } + this.ValidateFullDehydrate(tracer, enlistment); if (!this.Confirmed && fullDehydrate) { @@ -207,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) @@ -652,9 +650,8 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str } // ... backup all lock files for this working tree - if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false)) + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false, out errorMessage)) { - errorMessage = "Failed to backup .git lock files."; return false; } @@ -733,7 +730,7 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba } // ... backup all lock files for this working tree - if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true)) + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true, out errorMessage)) { return false; } @@ -766,16 +763,73 @@ internal bool TryBackupGitIndex( return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); } + 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."); + } + + if (GVFSEnlistment.GetKnownWorktreePaths(enlistment.DotGitRoot).Length > 0) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported while linked worktrees exist. Remove all linked worktrees first."); + } + } + + internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timestamp) + { + string backupParent = enlistment.PrimaryEnlistmentRoot; + string backupSubfolder = string.Empty; + if (enlistment.IsWorktree) + { + backupParent = Path.GetDirectoryName(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) + bool move, + out string errorMessage) { string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); - return move - ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") - : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + bool backupSucceeded = false; + if (!this.TryIO( + tracer, + () => + { + backupSucceeded = move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + }, + "Backup git lock files", + out errorMessage)) + { + return false; + } + + if (!backupSucceeded) + { + errorMessage = "Failed to backup .git lock files."; + } + + return backupSucceeded; } private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) @@ -788,7 +842,7 @@ private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string ba { if (!this.TryIO( tracer, - () => File.Move(file, file.Replace(folderPath, backupPath)), + () => File.Move(file, Path.Combine(backupPath, fileName)), $"Backing up {Path.GetFileName(file)}", out errorMessage)) { @@ -810,7 +864,7 @@ private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string back { if (!this.TryIO( tracer, - () => File.Copy(file, file.Replace(folderPath, backupPath)), + () => File.Copy(file, Path.Combine(backupPath, fileName)), $"Backing up {Path.GetFileName(file)}", out errorMessage)) { From 73d64a5d0e071d2c02d331d0ef401f936718a57d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 13:48:11 -0700 Subject: [PATCH 3/7] fix: address worktree index review feedback Block full dehydrate when worktree registration reads fail. List the registered paths and explain stale-registration cleanup. Preserve per-file backup errors and trace the resolved index destination. Add a real-worktree dehydrate test and unit coverage for guard failures, trace metadata, and sub-second backup paths. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 58 ++++++ GVFS/GVFS.Common/Git/GitIndexGenerator.cs | 7 +- .../EnlistmentPerFixture/WorktreeTests.cs | 57 +++++ .../CommandLine/GitIndexPathCallerTests.cs | 195 +++++++++++++++++- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 89 ++++---- 5 files changed, 353 insertions(+), 53 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 26e2de306f..d8035a300c 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -212,6 +212,64 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } + public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error) + { + ArgumentNullException.ThrowIfNull(gitDir); + paths = null; + error = 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 = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + if (!Path.IsPathFullyQualified(gitdirContent) || + !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) + { + error = $"Invalid worktree registration at '{pathToRead}': expected an absolute path to a .git file."; + return false; + } + + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent))); + } + + 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}"; + 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 07b441716b..5c27e32619 100644 --- a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs +++ b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs @@ -84,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(); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 7de8bb680a..54b1006cb2 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,62 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } + [TestCase] + public void WorktreeDehydratePreservesPrimaryIndex() + { + 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); + + GVFSEnlistment.WorktreeInfo worktreeInfo = GVFSEnlistment.TryGetWorktreeInfo(worktreePath); + worktreeInfo.ShouldNotBeNull(); + string worktreeIndex = Path.Combine(worktreeInfo.WorktreeGitDir, "index"); + string primaryIndex = Path.Combine(this.Enlistment.RepoBackingRoot, ".git", "index"); + 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."); + + ProcessResult dehydrate = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{worktreePath}\" --confirm --folders GVFS {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + 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); + } + } + } + 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 index 065754fe71..1731405175 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -1,12 +1,14 @@ 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 { @@ -184,6 +186,73 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() () => 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"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("not-an-absolute-path")] + [TestCase(@"C:\worktrees\bad\HEAD")] + public void FullDehydrateRejectsMissingOrMalformedRegistration(string registration) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); + Directory.CreateDirectory(worktreeGitDir); + if (registration != null) + { + 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")); + 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); + } + } + + [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] @@ -200,6 +269,52 @@ public void FullDehydrateAllowsPrimaryWithoutLinkedWorktrees() () => 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 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)] [TestCase(true)] public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) @@ -220,6 +335,7 @@ public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) expectedParent, expectedSubfolder, timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); } [TestCase] @@ -261,9 +377,62 @@ public void MissingLockDirectoryReturnsBackupError() errorMessage.ShouldNotBeNull(); } - private GVFSEnlistment CreateEnlistment(bool isWorktree) + [TestCase(false)] + [TestCase(true)] + public void LockBackupReturnsPreciseFileError(bool move) { - string gitBinPath = Path.Combine(this.testRoot, "git.exe"); + 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 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) + { + 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); @@ -271,7 +440,7 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree) if (!isWorktree) { - return new GVFSEnlistment(primaryRoot, "https://mock/repo", gitBinPath, authentication: null); + return new GVFSEnlistment(primaryRoot, "mock://repo", gitBinPath, authentication: null); } string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); @@ -293,7 +462,25 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree) gitBinPath, authentication: null, worktreeInfo, - repoUrl: "https://mock/repo"); + 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 7adf9bd19b..9cd8b7e8b3 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -631,7 +631,7 @@ 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; } @@ -711,7 +711,7 @@ 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; } @@ -775,11 +775,21 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) this.ReportErrorAndExit(tracer, "Dehydrate --full is not supported for git worktrees."); } - if (GVFSEnlistment.GetKnownWorktreePaths(enlistment.DotGitRoot).Length > 0) + if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error)) { this.ReportErrorAndExit( tracer, - "Dehydrate --full is not supported while linked worktrees exist. Remove all linked worktrees first."); + "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error); + } + + 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 + + "Use 'git worktree remove ' to remove a worktree. " + + "If its directory no longer exists, use 'git worktree prune' to remove stale registrations."); } } @@ -809,70 +819,52 @@ internal bool TryBackupGitLocks( out string errorMessage) { string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); - bool backupSucceeded = false; - if (!this.TryIO( - tracer, - () => - { - backupSucceeded = move - ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") - : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); - }, - "Backup git lock files", - out errorMessage)) - { - return false; - } + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage) + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage); + } - if (!backupSucceeded) - { - errorMessage = "Failed to backup .git lock files."; - } + 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); + } - return backupSucceeded; + 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 TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + private bool TryTransferFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, bool move, out string errorMessage, params string[] filenamesToSkip) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string[] files = null; + if (!this.TryIO(tracer, () => files = Directory.GetFiles(folderPath, searchPattern), $"Enumerate files in {folderPath}", out errorMessage)) { - string fileName = Path.GetFileName(file); - if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) - { - if (!this.TryIO( - tracer, - () => File.Move(file, Path.Combine(backupPath, fileName)), - $"Backing up {Path.GetFileName(file)}", - out errorMessage)) - { - return false; - } - } + errorMessage = $"Failed to enumerate '{folderPath}': {errorMessage}"; + return false; } - return true; - } - - private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) - { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + 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, Path.Combine(backupPath, fileName)), - $"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; } @@ -980,7 +972,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"); } From 2c1c6300be5c3f2cd1998dffa899e878170bdc6f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 14:26:35 -0700 Subject: [PATCH 4/7] fix: handle worktree path and backup failure boundaries Keep backups outside worktrees when paths include a trailing separator. Resolve relative worktree registrations and retain full exceptions in diagnostic logs. Report both index backup paths and return a failed dehydrate exit code after backup errors. Add CLI tests for full-dehydrate guards, backup failures, and trailing separators. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 14 +- .../EnlistmentPerFixture/WorktreeTests.cs | 125 ++++++++++++++++-- .../CommandLine/GitIndexPathCallerTests.cs | 89 +++++++++++-- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 45 +++++-- 4 files changed, 238 insertions(+), 35 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index d8035a300c..b895b452d9 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,11 +213,15 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } - public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error) + /// + /// Reads every registration and fails if any registration cannot be read or parsed. + /// + 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 { @@ -250,14 +255,14 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o { pathToRead = Path.Combine(entry, "gitdir"); string gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); - if (!Path.IsPathFullyQualified(gitdirContent) || + if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) { - error = $"Invalid worktree registration at '{pathToRead}': expected an absolute path to a .git file."; + error = $"Invalid worktree registration at '{pathToRead}': expected a path to a .git file."; return false; } - worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent))); + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent, entry))); } paths = worktreePaths.ToArray(); @@ -266,6 +271,7 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o 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; } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 54b1006cb2..cdc26ea0a8 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -364,8 +364,9 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } - [TestCase] - public void WorktreeDehydratePreservesPrimaryIndex() + [TestCase(false)] + [TestCase(true)] + public void WorktreeDehydratePreservesPrimaryIndex(bool trailingSeparator) { string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); string worktreeName = $"index-dehydrate-{suffix}"; @@ -385,20 +386,27 @@ public void WorktreeDehydratePreservesPrimaryIndex() GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "add worktree-index-only.txt").ExitCode.ShouldEqual(0); GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "commit -m \"Add worktree-only index entry\"").ExitCode.ShouldEqual(0); - GVFSEnlistment.WorktreeInfo worktreeInfo = GVFSEnlistment.TryGetWorktreeInfo(worktreePath); - worktreeInfo.ShouldNotBeNull(); - string worktreeIndex = Path.Combine(worktreeInfo.WorktreeGitDir, "index"); - string primaryIndex = Path.Combine(this.Enlistment.RepoBackingRoot, ".git", "index"); + 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."); - ProcessResult dehydrate = ProcessHelper.Run( - GVFSTestConfig.PathToGVFS, - $"dehydrate \"{worktreePath}\" --confirm --folders GVFS {TestConstants.InternalUseOnlyFlag} " + - GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), - workingDirectory: this.Enlistment.EnlistmentRoot, - timeoutMs: 120000); + 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."); @@ -420,6 +428,99 @@ public void WorktreeDehydratePreservesPrimaryIndex() } } + [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] + 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 index 1731405175..1fe58b5fa4 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -43,6 +43,11 @@ public void TearDown() 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"); @@ -189,6 +194,30 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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"); + } + + [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(null)] @@ -213,6 +242,8 @@ public void FullDehydrateRejectsMissingOrMalformedRegistration(string registrati () => 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(); } @@ -237,6 +268,13 @@ public void FullDehydrateRejectsUnreadableRegistration() output.ToString().ShouldContain("Full dehydrate is blocked."); output.ToString().ShouldContain(gitdirFile); tracer.RelatedErrorEvents.Count.ShouldEqual(1); + 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(); } } @@ -315,17 +353,19 @@ public void FullDehydrateRejectsGitDirectoryReplacedByFile() } } - [TestCase(false)] - [TestCase(true)] - public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree, bool trailingSeparator) { - GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + 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 - ? Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot) + ? this.testRoot : enlistment.PrimaryEnlistmentRoot; string expectedSubfolder = isWorktree ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) @@ -334,7 +374,7 @@ public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) Path.Combine( expectedParent, expectedSubfolder, - timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + "20260923_130000_1230000")); DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); } @@ -354,7 +394,8 @@ public void MissingIndexReturnsBackupError() out string errorMessage); result.ShouldBeFalse(); - errorMessage.ShouldNotBeNull(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(Path.Combine(backupGit, "index")); } [TestCase] @@ -406,6 +447,33 @@ public void LockBackupReturnsPreciseFileError(bool move) } } + [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) @@ -430,7 +498,7 @@ public void IndexGenerationTracesResolvedDestination(bool isWorktree) } } - private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = null) + 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"); @@ -444,6 +512,11 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = nul } 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); diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 9cd8b7e8b3..e826211e92 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -283,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(); @@ -485,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"); } } @@ -760,7 +760,13 @@ internal bool TryBackupGitIndex( ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); - return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); + if (!this.TryIO(tracer, backupIndex, $"Back up {enlistment.GitIndexPath} to {backupIndexPath}", out errorMessage)) + { + errorMessage = $"Failed to back up '{enlistment.GitIndexPath}' to '{backupIndexPath}': {errorMessage}"; + return false; + } + + return true; } internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) @@ -772,14 +778,33 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) if (enlistment.IsWorktree) { - this.ReportErrorAndExit(tracer, "Dehydrate --full is not supported for git worktrees."); + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported for git worktrees. " + + "Use 'gvfs dehydrate --confirm --folders ' for folder dehydrate."); } - if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error)) + 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."; + if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error, out Exception exception)) { + EventMetadata metadata = new EventMetadata + { + { "Error", error }, + { "DotGitRoot", enlistment.DotGitRoot }, + }; + if (exception != null) + { + metadata.Add("Exception", exception.ToString()); + } + + tracer.RelatedError(metadata, "Cannot verify linked worktree registrations."); this.ReportErrorAndExit( - tracer, - "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error); + "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); } if (worktreePaths.Length > 0) @@ -787,9 +812,7 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) this.ReportErrorAndExit( tracer, "Dehydrate --full is not supported while linked worktrees exist." + Environment.NewLine + - string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + - "Use 'git worktree remove ' to remove a worktree. " + - "If its directory no longer exists, use 'git worktree prune' to remove stale registrations."); + string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + cleanupAdvice); } } @@ -799,7 +822,7 @@ internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timesta string backupSubfolder = string.Empty; if (enlistment.IsWorktree) { - backupParent = Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot); + backupParent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(enlistment.WorkingDirectoryBackingRoot)); backupSubfolder = enlistment.Worktree.Name; } From bc2b8158d2e0d3fe6cc3fa104a5fb9e0e6272733 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 15:04:21 -0700 Subject: [PATCH 5/7] Ignore worktree metadata directories without gitdir files Match Git by ignoring directories that contain no worktree registration. Continue to block full dehydrate when a present gitdir file is unreadable or malformed. Cover empty metadata entries alone and alongside a valid worktree registration. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 13 +++++++++-- .../CommandLine/GitIndexPathCallerTests.cs | 23 ++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index b895b452d9..8e0dd0c179 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,7 +214,7 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads every registration and fails if any registration cannot be read or parsed. + /// Reads registrations with a gitdir file and fails if a present file cannot be read or parsed. /// public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error, out Exception exception) { @@ -254,7 +254,16 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o foreach (string entry in Directory.GetDirectories(worktreesDir)) { pathToRead = Path.Combine(entry, "gitdir"); - string gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + string gitdirContent; + try + { + gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + } + catch (FileNotFoundException) + { + continue; + } + if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) { diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 1fe58b5fa4..b91b9270b1 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -179,6 +179,7 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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 @@ -196,6 +197,7 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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] @@ -220,19 +222,15 @@ public void FullDehydrateResolvesRelativeRegistration() } } - [TestCase(null)] [TestCase("")] [TestCase("not-an-absolute-path")] [TestCase(@"C:\worktrees\bad\HEAD")] - public void FullDehydrateRejectsMissingOrMalformedRegistration(string registration) + public void FullDehydrateRejectsMalformedRegistration(string registration) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); Directory.CreateDirectory(worktreeGitDir); - if (registration != null) - { - File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); - } + File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); using (StringWriter output = new StringWriter()) using (MockTracer tracer = new MockTracer()) @@ -320,6 +318,19 @@ public void FullDehydrateAllowsEmptyRegistrationsDirectory() } } + [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 FullDehydrateRejectsMissingGitDirectory() { From bdd7c31f37460a13b72682ba9e5695f30248e499 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 15:30:55 -0700 Subject: [PATCH 6/7] Match Git when worktree registration entries are omitted Skip missing, zero-byte, and unreadable gitdir files. Record skipped read failures in diagnostic logs instead of blocking full dehydrate on entries Git does not list. Keep nonempty malformed registrations and enumeration errors blocking. Test whitespace-only content and mixed readable and unreadable registrations. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 29 ++++++++- .../CommandLine/GitIndexPathCallerTests.cs | 63 ++++++++++++++++--- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 15 ++++- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 8e0dd0c179..f4510303bc 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,14 +214,21 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads registrations with a gitdir file and fails if a present file cannot be read or parsed. + /// Reads registrations, skipping missing, empty, or unreadable files as Git does. + /// Nonempty malformed files and directory enumeration failures are reported to the caller. /// - public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error, out Exception exception) + public static bool TryGetKnownWorktreePaths( + string gitDir, + out string[] paths, + out string error, + out Exception exception, + out List skippedRegistrationErrors) { ArgumentNullException.ThrowIfNull(gitDir); paths = null; error = null; exception = null; + skippedRegistrationErrors = new List(); string pathToRead = gitDir; try { @@ -257,12 +264,28 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o string gitdirContent; try { - gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + using (FileStream stream = File.OpenRead(pathToRead)) + { + if (stream.Length == 0) + { + continue; + } + + using (StreamReader reader = new StreamReader(stream)) + { + gitdirContent = reader.ReadToEnd().Trim().Replace('/', Path.DirectorySeparatorChar); + } + } } catch (FileNotFoundException) { continue; } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + skippedRegistrationErrors.Add($"Failed to read '{pathToRead}': {ex}"); + continue; + } if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index b91b9270b1..721aeefd85 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -222,7 +222,9 @@ public void FullDehydrateResolvesRelativeRegistration() } } - [TestCase("")] + [TestCase("\n")] + [TestCase(" ")] + [TestCase(" \n")] [TestCase("not-an-absolute-path")] [TestCase(@"C:\worktrees\bad\HEAD")] public void FullDehydrateRejectsMalformedRegistration(string registration) @@ -248,7 +250,7 @@ public void FullDehydrateRejectsMalformedRegistration(string registration) } [TestCase] - public void FullDehydrateRejectsUnreadableRegistration() + public void FullDehydrateIgnoresUnreadableRegistration() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); @@ -261,15 +263,15 @@ public void FullDehydrateRejectsUnreadableRegistration() 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); - using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().Length.ShouldEqual(0); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + tracer.RelatedWarningEvents.Count.ShouldEqual(1); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedWarningEvents[0])) { - metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); - metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain(gitdirFile); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain("TryGetKnownWorktreePaths"); } output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); @@ -331,6 +333,47 @@ public void FullDehydrateIgnoresDirectoryWithoutGitdir() } } + [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 FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable() + { + 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("not supported while linked worktrees exist."); + output.ToString().ShouldContain(worktreePath); + tracer.RelatedWarningEvents.Count.ShouldEqual(1); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + [TestCase] public void FullDehydrateRejectsMissingGitDirectory() { diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index e826211e92..2005c469c3 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -789,7 +789,20 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) "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."; - if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error, out Exception exception)) + bool readSucceeded = GVFSEnlistment.TryGetKnownWorktreePaths( + enlistment.DotGitRoot, + out string[] worktreePaths, + out string error, + out Exception exception, + out List skippedRegistrationErrors); + foreach (string skippedRegistrationError in skippedRegistrationErrors) + { + tracer.RelatedWarning( + new EventMetadata { { "Error", skippedRegistrationError } }, + "Skipped unreadable worktree registration file."); + } + + if (!readSucceeded) { EventMetadata metadata = new EventMetadata { From cb754df28a9345eed69420a735276b643bae25a6 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 28 Oct 2026 14:04:54 -0700 Subject: [PATCH 7/7] fix: block full dehydrate on unverifiable worktree metadata Reject unreadable reverse gitdir pointers. Ignore missing or empty pointers only when the administrative entry contains no other metadata. Keep nondestructive discovery unchanged. Add real-worktree tests that demonstrate Git omitting usable worktrees and verify full dehydrate stops before index mutation. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 32 ++++----- .../EnlistmentPerFixture/WorktreeTests.cs | 67 +++++++++++++++++++ .../CommandLine/GitIndexPathCallerTests.cs | 63 +++++++++++++---- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 9 +-- 4 files changed, 135 insertions(+), 36 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index f4510303bc..959bd0eafd 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,21 +214,19 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads registrations, skipping missing, empty, or unreadable files as Git does. - /// Nonempty malformed files and directory enumeration failures are reported to the caller. + /// 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, - out List skippedRegistrationErrors) + out Exception exception) { ArgumentNullException.ThrowIfNull(gitDir); paths = null; error = null; exception = null; - skippedRegistrationErrors = new List(); string pathToRead = gitDir; try { @@ -261,32 +259,36 @@ public static bool TryGetKnownWorktreePaths( foreach (string entry in Directory.GetDirectories(worktreesDir)) { pathToRead = Path.Combine(entry, "gitdir"); - string gitdirContent; + string gitdirContent = string.Empty; try { using (FileStream stream = File.OpenRead(pathToRead)) { - if (stream.Length == 0) - { - continue; - } - using (StreamReader reader = new StreamReader(stream)) { - gitdirContent = reader.ReadToEnd().Trim().Replace('/', Path.DirectorySeparatorChar); + gitdirContent = reader.ReadToEnd(); } } } catch (FileNotFoundException) { - continue; } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + + if (gitdirContent.Length == 0) { - skippedRegistrationErrors.Add($"Failed to read '{pathToRead}': {ex}"); + 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)) { diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index cdc26ea0a8..1ab25b7080 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -469,6 +469,73 @@ public void FullDehydrateRejectsLinkedWorktreesBeforeMutation(bool fromWorktree) } } + [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() { diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 721aeefd85..c481285e99 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -250,7 +250,7 @@ public void FullDehydrateRejectsMalformedRegistration(string registration) } [TestCase] - public void FullDehydrateIgnoresUnreadableRegistration() + public void FullDehydrateRejectsUnreadableRegistration() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); @@ -263,15 +263,15 @@ public void FullDehydrateIgnoresUnreadableRegistration() using (MockTracer tracer = new MockTracer()) { DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; - Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); - output.ToString().Length.ShouldEqual(0); - tracer.RelatedErrorEvents.Count.ShouldEqual(0); - tracer.RelatedWarningEvents.Count.ShouldEqual(1); - using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedWarningEvents[0])) + 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("Error").GetString().ShouldContain(gitdirFile); - metadata.RootElement.GetProperty("Error").GetString().ShouldContain("System.IO.IOException"); - metadata.RootElement.GetProperty("Error").GetString().ShouldContain("TryGetKnownWorktreePaths"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); } output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); @@ -350,7 +350,7 @@ public void FullDehydrateIgnoresEmptyGitdir() } [TestCase] - public void FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable() + public void FullDehydrateRejectsUnverifiableStateWhenAnotherWorktreeIsReadable() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string validDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "valid"); @@ -367,9 +367,46 @@ public void FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable { 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); - tracer.RelatedWarningEvents.Count.ShouldEqual(1); + 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); } } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 2005c469c3..61d5968b8b 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -793,14 +793,7 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) enlistment.DotGitRoot, out string[] worktreePaths, out string error, - out Exception exception, - out List skippedRegistrationErrors); - foreach (string skippedRegistrationError in skippedRegistrationErrors) - { - tracer.RelatedWarning( - new EventMetadata { { "Error", skippedRegistrationError } }, - "Skipped unreadable worktree registration file."); - } + out Exception exception); if (!readSucceeded) {