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