diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 376796350..07e0c22fe 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -1,9 +1,15 @@ -using GVFS.FunctionalTests.Tools; +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.FunctionalTests.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -11,102 +17,287 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture [Category(Categories.GitCommands)] public class WorktreeTests : TestsWithEnlistmentPerFixture { - private const string WorktreeBranchA = "worktree-test-branch-a"; - private const string WorktreeBranchB = "worktree-test-branch-b"; + private const int MinWorktreeCount = 4; [TestCase] public void ConcurrentWorktreeAddCommitRemove() { - string worktreePathA = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-a-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - string worktreePathB = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-b-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + int count = Math.Max(Environment.ProcessorCount, MinWorktreeCount); + string[] worktreePaths; + string[] branchNames; + + // Adaptively scale down if concurrent adds overwhelm the primary + // GVFS mount. CI runners with fewer resources may not handle as + // many concurrent git operations as a developer workstation. + while (true) + { + this.InitWorktreeArrays(count, out worktreePaths, out branchNames); + ProcessResult[] addResults = this.ConcurrentWorktreeAdd(worktreePaths, branchNames, count); + + bool overloaded = addResults.Any(r => + r.ExitCode != 0 && + r.Errors != null && + r.Errors.Contains("does not appear to be mounted")); + + if (overloaded) + { + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + int reduced = count / 2; + if (reduced < MinWorktreeCount) + { + Assert.Fail( + $"Primary GVFS mount overloaded even at count={count}. " + + $"Cannot reduce below {MinWorktreeCount}."); + } + + count = reduced; + continue; + } + + // Non-overload failures are real errors + for (int i = 0; i < count; i++) + { + addResults[i].ExitCode.ShouldEqual(0, + $"worktree add {(char)('A' + i)} failed: {addResults[i].Errors}"); + } + + break; + } try { - // 1. Create both worktrees in parallel - ProcessResult addResultA = null; - ProcessResult addResultB = null; - System.Threading.Tasks.Parallel.Invoke( - () => addResultA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchA} \"{worktreePathA}\""), - () => addResultB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchB} \"{worktreePathB}\"")); - - addResultA.ExitCode.ShouldEqual(0, $"worktree add A failed: {addResultA.Errors}"); - addResultB.ExitCode.ShouldEqual(0, $"worktree add B failed: {addResultB.Errors}"); - - // 2. Verify both have projected files - Directory.Exists(worktreePathA).ShouldBeTrue("Worktree A directory should exist"); - Directory.Exists(worktreePathB).ShouldBeTrue("Worktree B directory should exist"); - File.Exists(Path.Combine(worktreePathA, "Readme.md")).ShouldBeTrue("Readme.md should be projected in A"); - File.Exists(Path.Combine(worktreePathB, "Readme.md")).ShouldBeTrue("Readme.md should be projected in B"); - - // 3. Verify git status is clean in both - ProcessResult statusA = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "status --porcelain"); - ProcessResult statusB = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "status --porcelain"); - statusA.ExitCode.ShouldEqual(0, $"git status A failed: {statusA.Errors}"); - statusB.ExitCode.ShouldEqual(0, $"git status B failed: {statusB.Errors}"); - statusA.Output.Trim().ShouldBeEmpty("Worktree A should have clean status"); - statusB.Output.Trim().ShouldBeEmpty("Worktree B should have clean status"); - - // 4. Verify worktree list shows all three + // 2. Primary assertion: verify GVFS mount is running for each + // worktree by probing the worktree-specific named pipe. + for (int i = 0; i < count; i++) + { + this.AssertWorktreeMounted(worktreePaths[i], $"worktree {(char)('A' + i)}"); + } + + // 3. Verify projected files are visible (secondary assertion) + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeTrue( + $"Worktree {(char)('A' + i)} directory should exist"); + File.Exists(Path.Combine(worktreePaths[i], "Readme.md")).ShouldBeTrue( + $"Readme.md should be projected in {(char)('A' + i)}"); + } + + // 4. Verify git status is clean in each worktree + for (int i = 0; i < count; i++) + { + ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], "status --porcelain"); + status.ExitCode.ShouldEqual(0, + $"git status {(char)('A' + i)} failed: {status.Errors}"); + status.Output.Trim().ShouldBeEmpty( + $"Worktree {(char)('A' + i)} should have clean status"); + } + + // 5. Verify worktree list shows all entries ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo( this.Enlistment.RepoRoot, "worktree list"); listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}"); string listOutput = listResult.Output; - Assert.IsTrue(listOutput.Contains(worktreePathA.Replace('\\', '/')), - $"worktree list should contain A. Output: {listOutput}"); - Assert.IsTrue(listOutput.Contains(worktreePathB.Replace('\\', '/')), - $"worktree list should contain B. Output: {listOutput}"); - - // 5. Make commits in both worktrees - File.WriteAllText(Path.Combine(worktreePathA, "from-a.txt"), "created in worktree A"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "add from-a.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "commit -m \"commit from A\"") - .ExitCode.ShouldEqual(0); - - File.WriteAllText(Path.Combine(worktreePathB, "from-b.txt"), "created in worktree B"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "add from-b.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "commit -m \"commit from B\"") - .ExitCode.ShouldEqual(0); - - // 6. Verify commits are visible from all worktrees (shared objects) - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - - // A can see B's commit and vice versa - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - - // 7. Remove both in parallel - ProcessResult removeA = null; - ProcessResult removeB = null; - System.Threading.Tasks.Parallel.Invoke( - () => removeA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathA}\""), - () => removeB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathB}\"")); - - removeA.ExitCode.ShouldEqual(0, $"worktree remove A failed: {removeA.Errors}"); - removeB.ExitCode.ShouldEqual(0, $"worktree remove B failed: {removeB.Errors}"); - - // 8. Verify cleanup - Directory.Exists(worktreePathA).ShouldBeFalse("Worktree A directory should be deleted"); - Directory.Exists(worktreePathB).ShouldBeFalse("Worktree B directory should be deleted"); + for (int i = 0; i < count; i++) + { + Assert.IsTrue( + listOutput.Contains(worktreePaths[i].Replace('\\', '/')), + $"worktree list should contain {(char)('A' + i)}. Output: {listOutput}"); + } + + // 6. Make commits in all worktrees + for (int i = 0; i < count; i++) + { + char letter = (char)('a' + i); + File.WriteAllText( + Path.Combine(worktreePaths[i], $"from-{letter}.txt"), + $"created in worktree {(char)('A' + i)}"); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePaths[i], $"add from-{letter}.txt") + .ExitCode.ShouldEqual(0); + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"commit -m \"commit from {(char)('A' + i)}\"") + .ExitCode.ShouldEqual(0); + } + + // 7. Verify commits are visible from main repo + for (int i = 0; i < count; i++) + { + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"log -1 --format=%s {branchNames[i]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {(char)('A' + i)}" }); + } + + // 8. Verify cross-worktree commit visibility (shared objects) + for (int i = 0; i < count; i++) + { + int other = (i + 1) % count; + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"log -1 --format=%s {branchNames[other]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {(char)('A' + other)}" }); + } + + // 9. Remove all worktrees in parallel + ProcessResult[] removeResults = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + removeResults[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove --force \"{worktreePaths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + for (int i = 0; i < count; i++) + { + removeResults[i].ExitCode.ShouldEqual(0, + $"worktree remove {(char)('A' + i)} failed: {removeResults[i].Errors}"); + } + + // 10. Verify cleanup + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeFalse( + $"Worktree {(char)('A' + i)} directory should be deleted"); + } } finally { - this.ForceCleanupWorktree(worktreePathA, WorktreeBranchA); - this.ForceCleanupWorktree(worktreePathB, WorktreeBranchB); + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + } + } + + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) + { + paths = new string[count]; + branches = new string[count]; + for (int i = 0; i < count; i++) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + paths[i] = Path.Combine(this.Enlistment.EnlistmentRoot, $"test-wt-{(char)('a' + i)}-{suffix}"); + branches[i] = $"worktree-test-branch-{(char)('a' + i)}-{suffix}"; + } + } + + private ProcessResult[] ConcurrentWorktreeAdd(string[] paths, string[] branches, int count) + { + ProcessResult[] results = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + results[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branches[idx]} \"{paths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + return results; + } + + /// + /// Asserts that the GVFS mount for a worktree is running by probing + /// the worktree-specific named pipe. This is the definitive signal + /// that ProjFS projection is active — much stronger than File.Exists + /// which depends on projection timing. + /// + private void AssertWorktreeMounted(string worktreePath, string label) + { + string basePipeName = GVFSPlatform.Instance.GetNamedPipeName( + this.Enlistment.EnlistmentRoot); + string suffix = GVFSEnlistment.GetWorktreePipeSuffix(worktreePath); + + Assert.IsNotNull(suffix, + $"Could not determine pipe suffix for {label} at {worktreePath}. " + + $"The worktree .git file may be missing or malformed."); + + string pipeName = basePipeName + suffix; + + using (NamedPipeClient client = new NamedPipeClient(pipeName)) + { + if (!client.Connect(10000)) + { + string diagnostics = this.CaptureWorktreeDiagnostics(worktreePath); + Assert.Fail( + $"GVFS mount is NOT running for {label}.\n" + + $"Path: {worktreePath}\n" + + $"Pipe: {pipeName}\n" + + $"This indicates the post-hook 'gvfs mount' failed silently.\n" + + $"Diagnostics:\n{diagnostics}"); + } + } + } + + private string CaptureWorktreeDiagnostics(string worktreePath) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine($" Directory exists: {Directory.Exists(worktreePath)}"); + if (Directory.Exists(worktreePath)) + { + string dotGit = Path.Combine(worktreePath, ".git"); + sb.AppendLine($" .git file exists: {File.Exists(dotGit)}"); + if (File.Exists(dotGit)) + { + try + { + sb.AppendLine($" .git contents: {File.ReadAllText(dotGit).Trim()}"); + } + catch (Exception ex) + { + sb.AppendLine($" .git read failed: {ex.Message}"); + } + } + + try + { + string[] entries = Directory.GetFileSystemEntries(worktreePath); + sb.AppendLine($" Directory listing ({entries.Length} entries):"); + foreach (string entry in entries) + { + sb.AppendLine($" {Path.GetFileName(entry)}"); + } + } + catch (Exception ex) + { + sb.AppendLine($" Directory listing failed: {ex.Message}"); + } + } + + return sb.ToString(); + } + + private void CleanupAllWorktrees(string[] paths, string[] branches, int count) + { + for (int i = 0; i < count; i++) + { + this.ForceCleanupWorktree(paths[i], branches[i]); } } diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index c04f0c778..8cf44b48d 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -508,6 +508,26 @@ private static bool IsAlias(string command) return !string.IsNullOrEmpty(result.Output); } + /// + /// Extracts the git exit code from hook args. Git appends --exit_code=N + /// to post-command hook arguments. + /// + private static int GetHookExitCode(string[] args) + { + for (int i = args.Length - 1; i >= 0; i--) + { + if (args[i].StartsWith("--exit_code=")) + { + if (int.TryParse(args[i].Substring("--exit_code=".Length), out int code)) + { + return code; + } + } + } + + return 0; + } + private static string GetGitCommandSessionId() { try