diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 0cf7485..9b740fe 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -34,7 +34,7 @@ jobs: run: | ./Scripts/UpdateSubmodules.ps1 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.config') }} diff --git a/GitTreeFilter.Core.Tests/DetachedHeadChangesetTest.cs b/GitTreeFilter.Core.Tests/DetachedHeadChangesetTest.cs new file mode 100644 index 0000000..2734998 --- /dev/null +++ b/GitTreeFilter.Core.Tests/DetachedHeadChangesetTest.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using GitTreeFilter.Core.Exceptions; +using GitTreeFilter.Core.Tests.Test; +using GitTreeFilter.Core.Tests.Test.Repositories; +using GitTreeFilter.Core.Tests.Test.Repositories.Basic; +using GitTreeFilter.Core.Tests.Test.Repositories.Merge; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Immutable; +using System.Linq; + +namespace GitTreeFilter.Core.Tests +{ + [TestClass] + [TestCategory("SolutionRepository.Changeset")] + public class DetachedHeadChangesetTest : SolutionRepositoryTest + { + [DataTestMethod] + [AllRepositoriesData] + public void DoesNotThrowWhenHeadIsDetached(ITestRepository testRepository) + { + // given + var solutionRepository = CreateSolutionRepository(testRepository); + ReferenceObject = testRepository.Head; + + // when & then + RepositoryState.WithDetachedHead(testRepository, () => + { + solutionRepository.Invoking(x => x.Changeset) + .Should() + .NotThrow(); + }); + } + + [DataTestMethod] + [BasicRepositoryChangesetDataSource] + public void BasicRepositoryChangesetWithDetachedHead( + ITestRepository testRepository, + TestComparisonConfig comparisonConfig, + IImmutableSet changedFilesPaths) + { + AssertChangesetUnderDetachedHead(testRepository, comparisonConfig, changedFilesPaths); + } + + [DataTestMethod] + [MergeRepositoryChangesetDataSource] + public void MergeRepositoryChangesetWithDetachedHead( + ITestRepository testRepository, + TestComparisonConfig comparisonConfig, + IImmutableSet changedFilesPaths) + { + AssertChangesetUnderDetachedHead(testRepository, comparisonConfig, changedFilesPaths); + } + + private void AssertChangesetUnderDetachedHead( + ITestRepository testRepository, + TestComparisonConfig comparisonConfig, + IImmutableSet changedFilesPaths) + { + // given + var solutionRepository = CreateSolutionRepository(testRepository); + SetComparisonConfig(comparisonConfig); + + // when & then + RepositoryState.WithDetachedHead(testRepository, () => + { + var changeset = solutionRepository.Changeset; + + using (new AssertionScope()) + { + changeset.Should().NotBeNull(); + changeset.Select(x => x.AbsoluteFilePath).Should().BeEquivalentTo(changedFilesPaths); + } + }); + } + } +} diff --git a/GitTreeFilter.Core.Tests/GitRepositoryDiscoveryTest.cs b/GitTreeFilter.Core.Tests/GitRepositoryDiscoveryTest.cs new file mode 100644 index 0000000..54f16c1 --- /dev/null +++ b/GitTreeFilter.Core.Tests/GitRepositoryDiscoveryTest.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using FluentAssertions; +using GitTreeFilter.Core; +using GitTreeFilter.Core.Tests.Test.Repositories; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GitTreeFilter.Core.Tests +{ + [TestClass] + [TestCategory("GitRepositoryDiscovery")] + public class GitRepositoryDiscoveryTest + { + [TestMethod] + public void DiscoversRepositoryRootWhenGitIsAFile() + { + // given a working directory whose ".git" is a file (submodule/worktree pointer) + var repositoryRoot = TestRepositories.Basic.Solution.SolutionPath; + File.Exists(Path.Combine(repositoryRoot, ".git")) + .Should().BeTrue("this fixture must reproduce the worktree/submodule '.git is a file' case"); + + // when + var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(repositoryRoot, out var root); + + // then + discovered.Should().BeTrue(); + NormalizeDirectory(root).Should().Be(NormalizeDirectory(repositoryRoot)); + } + + [TestMethod] + public void DiscoversRepositoryRootFromNestedSubdirectory() + { + // given a nested subdirectory inside the repository working directory + var repositoryRoot = TestRepositories.Basic.Solution.SolutionPath; + var nested = Path.Combine(repositoryRoot, "nested-" + Guid.NewGuid().ToString("N"), "deeper"); + Directory.CreateDirectory(nested); + + try + { + // when + var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(nested, out var root); + + // then + discovered.Should().BeTrue(); + NormalizeDirectory(root).Should().Be(NormalizeDirectory(repositoryRoot)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(nested), recursive: true); + } + } + + [TestMethod] + public void ReturnsFalseWhenPathIsNotInsideRepository() + { + // given a directory that is not inside any git repository + var outside = Path.Combine(Path.GetTempPath(), "gtf-not-a-repo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outside); + + try + { + // when + var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(outside, out var root); + + // then + discovered.Should().BeFalse(); + root.Should().BeNull(); + } + finally + { + Directory.Delete(outside, recursive: true); + } + } + + private static string NormalizeDirectory(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } +} diff --git a/GitTreeFilter.Core.Tests/ModernIndexChangesetTest.cs b/GitTreeFilter.Core.Tests/ModernIndexChangesetTest.cs new file mode 100644 index 0000000..c706fed --- /dev/null +++ b/GitTreeFilter.Core.Tests/ModernIndexChangesetTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Diagnostics; +using System.IO; +using FluentAssertions; +using GitTreeFilter.Core.Models; +using GitTreeFilter.Core.Tests.Test; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GitTreeFilter.Core.Tests +{ + /// + /// Regression tests for the "invalid data in index - calculated checksum does not + /// match expected" failure. Modern git (2.x with feature.manyFiles / untracked cache) + /// writes index extensions that the previously bundled libgit2 could not parse, which + /// made throw and left the Solution Explorer + /// list empty ("won't budge"). + /// + [TestClass] + [TestCategory("SolutionRepository.Changeset")] + public class ModernIndexChangesetTest + { + [TestMethod] + public void ChangesetDoesNotThrowForModernGitIndex() + { + // given a repository whose on-disk index was written by a modern git with + // feature.manyFiles enabled (index v4 + untracked-cache extension). This is the + // corner case that older libgit2 rejected with a checksum error. + if (!IsGitAvailable()) + { + Assert.Inconclusive("git CLI is required to materialize a modern index for this test."); + } + + var repositoryPath = CreateModernIndexRepository(out var headSha); + + try + { + var comparisonConfig = new TestComparisonConfig + { + ReferenceObject = new GitCommit(new GitCommitObject(headSha, "HEAD")) + }; + + var solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository( + new GitSolution(repositoryPath), comparisonConfig); + + // when & then: reading the changeset must parse the index without throwing. + solutionRepository.Invoking(x => x.Changeset) + .Should() + .NotThrow("modern git index extensions must be parseable"); + + solutionRepository.Changeset.Should().NotBeNull(); + } + finally + { + ForceDeleteDirectory(repositoryPath); + } + } + + /// + /// Builds a throwaway git repository whose index is large enough and configured such + /// that git writes the modern extensions (index v4 + untracked cache) that reproduce + /// the checksum-parsing failure. Returns the working directory path. + /// + private static string CreateModernIndexRepository(out string headSha) + { + var path = Path.Combine(Path.GetTempPath(), "gtf-modern-index-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + + Git(path, "init"); + // feature.manyFiles turns on index.version=4, index.threads and core.untrackedCache, + // i.e. exactly the extensions that older libgit2 mis-parsed. + Git(path, "config feature.manyFiles true"); + Git(path, "config core.autocrlf false"); + Git(path, "config user.email test@example.com"); + Git(path, "config user.name test"); + + // Enough files (spread across directories) to push the index past the size at which + // the extension mis-parse manifests as a checksum mismatch. + for (var i = 0; i < 500; i++) + { + var directory = Path.Combine(path, "dir" + (i % 16)); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "f" + i + ".txt"), "content " + i); + } + + Git(path, "add ."); + Git(path, "commit -m init"); + // Force git to (re)write the untracked-cache extension into the index. + Git(path, "status --short"); + + headSha = Git(path, "rev-parse HEAD").Trim(); + return path; + } + + private static bool IsGitAvailable() + { + try + { + RunProcess(Environment.CurrentDirectory, "git", "--version"); + return true; + } + catch + { + return false; + } + } + + private static string Git(string workingDirectory, string arguments) => + RunProcess(workingDirectory, "git", arguments); + + private static string RunProcess(string workingDirectory, string fileName, string arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"'{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}"); + } + + return output; + } + + private static void ForceDeleteDirectory(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + try + { + File.SetAttributes(file, FileAttributes.Normal); + } + catch + { + // best effort + } + } + + try + { + Directory.Delete(path, recursive: true); + } + catch + { + // best effort cleanup of a temp directory + } + } + } +} diff --git a/GitTreeFilter.Core.Tests/RepositoryOwnershipTest.cs b/GitTreeFilter.Core.Tests/RepositoryOwnershipTest.cs new file mode 100644 index 0000000..019ad60 --- /dev/null +++ b/GitTreeFilter.Core.Tests/RepositoryOwnershipTest.cs @@ -0,0 +1,150 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using FluentAssertions; +using GitTreeFilter.Core.Models; +using GitTreeFilter.Core.Tests.Test; +using LibGit2Sharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GitTreeFilter.Core.Tests +{ + /// + /// Regression tests for repositories that Visual Studio has already opened but + /// whose working directory is owned by a different account than the one running + /// Visual Studio (git worktrees or clones created by another user - common on + /// enterprise machines). libgit2 >= 1.4 refuses to open such repositories + /// ("repository path ... is not owned by current user", CVE-2022-24765). That + /// exception was thrown while the plugin was setting up, which aborted service + /// creation and left the filter provider with a null service - so selecting the + /// filter did nothing ("cannot open"). + /// + [TestClass] + [TestCategory("SolutionRepository")] + public class RepositoryOwnershipTest + { + [TestMethod] + public void OpeningRepositoryDisablesLibGit2OwnerValidation() + { + if (!IsGitAvailable()) + { + Assert.Inconclusive("git CLI is required to materialize a repository for this test."); + } + + // baseline: libgit2 owner validation on (its default) - this is what + // rejects repositories owned by another user. + GlobalSettings.SetOwnerValidation(true); + + var repositoryPath = CreateRepository(); + + try + { + var solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository( + new GitSolution(repositoryPath), new TestComparisonConfig()); + + // when: the plugin opens the repository through its factory. + _ = solutionRepository.Branches.ToList(); + + // then: owner validation must have been disabled so repositories that + // Visual Studio already trusts (e.g. another user's worktree) can open. + GlobalSettings.GetOwnerValidation().Should().BeFalse( + "the plugin must trust repositories Visual Studio has already opened"); + } + finally + { + ForceDeleteDirectory(repositoryPath); + GlobalSettings.SetOwnerValidation(true); + } + } + + private static string CreateRepository() + { + var path = Path.Combine(Path.GetTempPath(), "gtf-ownership-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + + Git(path, "init"); + Git(path, "config core.autocrlf false"); + Git(path, "config user.email test@example.com"); + Git(path, "config user.name test"); + + File.WriteAllText(Path.Combine(path, "file.txt"), "content"); + Git(path, "add ."); + Git(path, "commit -m init"); + + return path; + } + + private static bool IsGitAvailable() + { + try + { + RunProcess(Environment.CurrentDirectory, "git", "--version"); + return true; + } + catch + { + return false; + } + } + + private static string Git(string workingDirectory, string arguments) => + RunProcess(workingDirectory, "git", arguments); + + private static string RunProcess(string workingDirectory, string fileName, string arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"'{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}"); + } + + return output; + } + + private static void ForceDeleteDirectory(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + try + { + File.SetAttributes(file, FileAttributes.Normal); + } + catch + { + // best effort + } + } + + try + { + Directory.Delete(path, recursive: true); + } + catch + { + // best effort cleanup of a temp directory + } + } + } +} diff --git a/GitTreeFilter.Core.Tests/Test/RepositoryState.cs b/GitTreeFilter.Core.Tests/Test/RepositoryState.cs new file mode 100644 index 0000000..4f79163 --- /dev/null +++ b/GitTreeFilter.Core.Tests/Test/RepositoryState.cs @@ -0,0 +1,45 @@ +using System; +using GitTreeFilter.Core.Tests.Test.Repositories; +using LibGit2Sharp; + +namespace GitTreeFilter.Core.Tests.Test +{ + /// + /// Helpers to temporarily manipulate the on-disk state of a test repository + /// working directory so that scenarios such as a detached HEAD can be exercised. + /// + internal static class RepositoryState + { + /// + /// Runs while the working directory of + /// is in a detached HEAD state. + /// HEAD is detached at the current branch tip, so no working-tree files + /// change. The originally checked out branch is always restored afterwards. + /// + public static void WithDetachedHead(ITestRepository testRepository, Action action) + { + var path = testRepository.Solution.SolutionPath; + using var repository = new Repository(path); + + if (repository.Info.IsHeadDetached) + { + action(); + return; + } + + var originalBranch = repository.Head.FriendlyName; + var tip = repository.Head.Tip; + + try + { + // Checking out a commit (rather than a branch) detaches HEAD. + Commands.Checkout(repository, tip); + action(); + } + finally + { + Commands.Checkout(repository, repository.Branches[originalBranch]); + } + } + } +} diff --git a/GitTreeFilter.Core/GitRepositoryDiscovery.cs b/GitTreeFilter.Core/GitRepositoryDiscovery.cs new file mode 100644 index 0000000..0496254 --- /dev/null +++ b/GitTreeFilter.Core/GitRepositoryDiscovery.cs @@ -0,0 +1,60 @@ +using System.IO; +using LibGit2Sharp; + +namespace GitTreeFilter.Core; + +/// +/// Filesystem based discovery of the Git repository that owns a given path. +/// Unlike Visual Studio's IGitExt.ActiveRepositories, this works for +/// repositories that Visual Studio does not surface - notably git worktrees and +/// submodules (whose ".git" is a file pointing elsewhere) - as well as when the +/// solution lives in a nested subdirectory of the repository. +/// +public static class GitRepositoryDiscovery +{ + /// + /// Walks up from to locate the owning Git + /// repository and returns its working directory. + /// + /// + /// true and the repository working directory in + /// when a repository is found; otherwise + /// false and null. + /// + public static bool TryGetRepositoryRoot(string startingPath, out string repositoryRoot) + { + repositoryRoot = null; + + if (string.IsNullOrEmpty(startingPath)) + { + return false; + } + + // Repository.Discover needs a directory path; if a file was supplied, use its directory. + var searchPath = File.Exists(startingPath) ? Path.GetDirectoryName(startingPath) : startingPath; + if (string.IsNullOrEmpty(searchPath) || !Directory.Exists(searchPath)) + { + return false; + } + + // Repository.Discover resolves worktree/submodule ".git" pointer files and + // walks up parent directories, returning the path to the resolved git directory. + var gitDirectory = Repository.Discover(searchPath); + if (string.IsNullOrEmpty(gitDirectory)) + { + return false; + } + + using var repository = new Repository(gitDirectory); + var workingDirectory = repository.Info.WorkingDirectory; + if (string.IsNullOrEmpty(workingDirectory)) + { + // Bare repository - there is no working tree to filter. + return false; + } + + repositoryRoot = Path.GetFullPath(workingDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return true; + } +} diff --git a/GitTreeFilter.Core/GitRepositoryFactory.cs b/GitTreeFilter.Core/GitRepositoryFactory.cs index 9afb251..94e5f44 100644 --- a/GitTreeFilter.Core/GitRepositoryFactory.cs +++ b/GitTreeFilter.Core/GitRepositoryFactory.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using System.IO; namespace GitTreeFilter.Core; @@ -7,6 +7,14 @@ internal class GitRepositoryFactory { public IRepository Create(GitSolution solution) { + // libgit2 (>= 1.4) refuses to open a repository whose working directory is + // owned by a different account than the current process ("repository path ... + // is not owned by current user", CVE-2022-24765). Visual Studio has already + // opened this repository for the user and the plugin only reads it, so trust + // it. Without this, worktrees or clones created by another account (common on + // enterprise machines) throw during setup and deactivate the plugin entirely. + GlobalSettings.SetOwnerValidation(false); + var currentDirectory = solution.SolutionPath; try { diff --git a/GitTreeFilter.Core/GitTreeFilter.Core.csproj b/GitTreeFilter.Core/GitTreeFilter.Core.csproj index 0c7d50c..2118452 100644 --- a/GitTreeFilter.Core/GitTreeFilter.Core.csproj +++ b/GitTreeFilter.Core/GitTreeFilter.Core.csproj @@ -13,7 +13,7 @@ - + diff --git a/GitTreeFilter.Core/SolutionRepository.cs b/GitTreeFilter.Core/SolutionRepository.cs index 7e33cac..45baafb 100644 --- a/GitTreeFilter.Core/SolutionRepository.cs +++ b/GitTreeFilter.Core/SolutionRepository.cs @@ -277,14 +277,14 @@ private static void AssertValidReference(IRepository repository, GitReference branch.FriendlyName); - var activeBranch = repository.Head?.FriendlyName; - if (repository.GetTargetCommit(reference) == null) { throw new RepositoryTargetNotFoundException(); } - if (string.IsNullOrEmpty(activeBranch) || !branchesInRepo.Contains(activeBranch)) + // HEAD is valid as long as it resolves to a real commit. This accepts a + // detached HEAD (checked-out commit/tag, mid-rebase/bisect, or a detached + // git worktree) while still rejecting a genuinely head-less (unborn) repo. + if (repository.Head?.Tip == null) { throw new HeadNotFoundException(); } diff --git a/GitTreeFilter/Commands/AbstractGitFiltersCommand.cs b/GitTreeFilter/Commands/AbstractGitFiltersCommand.cs index ff5a882..d8b0445 100644 --- a/GitTreeFilter/Commands/AbstractGitFiltersCommand.cs +++ b/GitTreeFilter/Commands/AbstractGitFiltersCommand.cs @@ -1,4 +1,4 @@ -using Microsoft; +using Microsoft; using System; namespace GitTreeFilter.Commands; @@ -17,8 +17,11 @@ public AbstractGitFiltersCommand(IGitFilterService gitFilterService) GitFilterService.GitFilterLifecycleEvent += OnLifecycleEvent; } - private void OnLifecycleEvent(object sender, NotifyGitFilterLifecycleEventArgs args) => - VisibilityChanged?.Raise(this, new NotifyCommandVisibilityChangedEventArgs()); + private void OnLifecycleEvent(object sender, NotifyGitFilterLifecycleEventArgs args) + { + GitTreeFilterOutput.Log($"{GetType().Name}: lifecycle event received -> re-querying visibility (IsVisible={IsVisible})"); + VisibilityChanged?.Raise(this, new NotifyCommandVisibilityChangedEventArgs()); + } public abstract void OnExecute(object sender, EventArgs e); diff --git a/GitTreeFilter/GitTreeFilterOutput.cs b/GitTreeFilter/GitTreeFilterOutput.cs index 4a51423..5706ebb 100644 --- a/GitTreeFilter/GitTreeFilterOutput.cs +++ b/GitTreeFilter/GitTreeFilterOutput.cs @@ -1,6 +1,8 @@ -using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using System; +using System.Globalization; +using System.Threading; public static class GitTreeFilterOutput { @@ -23,11 +25,26 @@ public static void Initialize(IServiceProvider serviceProvider) public static void WriteLine(string message) { - ThreadHelper.ThrowIfNotOnUIThread(); + // OutputStringThreadSafe is safe to call from any thread, so we do not + // require the UI thread here. Diagnostic logging must work from the + // background threads that drive filter creation and changeset building. + _outputPane?.OutputStringThreadSafe(message + Environment.NewLine); + } - if (_outputPane != null) - { - _outputPane.OutputStringThreadSafe(message + Environment.NewLine); - } + /// + /// Writes a diagnostic line prefixed with a timestamp and the current managed + /// thread id. Safe to call from any thread. Use for tracing the activation and + /// filter/changeset flow so the sequence and threading of events is visible. + /// + public static void Log(string message) + { + var line = string.Format( + CultureInfo.InvariantCulture, + "[{0:HH:mm:ss.fff} T{1}] {2}", + DateTime.Now, + Thread.CurrentThread.ManagedThreadId, + message); + + WriteLine(line); } } diff --git a/GitTreeFilter/GitTreeFilterProvider.GitFileFilter.cs b/GitTreeFilter/GitTreeFilterProvider.GitFileFilter.cs index c2de96a..cdd8e10 100644 --- a/GitTreeFilter/GitTreeFilterProvider.GitFileFilter.cs +++ b/GitTreeFilter/GitTreeFilterProvider.GitFileFilter.cs @@ -1,4 +1,4 @@ -using GitTreeFilter.Core; +using GitTreeFilter.Core; using GitTreeFilter.Core.Exceptions; using GitTreeFilter.Core.Models; using GitTreeFilter.Tagging; @@ -35,6 +35,7 @@ public GitFileFilter( _gitFiltersService = filterService; Initialized += OnInitialized; + GitTreeFilterOutput.Log("GitFileFilter constructed, subscribed to Initialized"); } private ISolutionRepository SolutionRepository => _gitFiltersService.SolutionRepository; @@ -43,6 +44,7 @@ public GitFileFilter( private void OnInitialized(object sender, EventArgs e) { + GitTreeFilterOutput.Log("GitFileFilter.OnInitialized fired -> IsFilterApplied=true (settings button should become visible)"); _gitFiltersService.GitFilterChanged += OnFilterChangedHandler; _gitFiltersService.IsFilterApplied = true; } @@ -60,27 +62,40 @@ protected override void DisposeManagedResources() protected override async Task GetIncludedItemsAsync(IEnumerable rootItems) { - if (SolutionRepository is null) + GitTreeFilterOutput.Log("GitFileFilter.GetIncludedItemsAsync: entered"); + try { - // We are called before initialization. - _gitFiltersService.ErrorPresenter.ShowError("You must select a repository first."); - return null; - } - - var root = HierarchyUtilities.FindCommonAncestor(rootItems); - var sourceItems = await _hierarchyCollectionProvider.GetDescendantsAsync( - root.HierarchyIdentity.NestedHierarchy, - CancellationToken - ); - - ItemSet = await GitFiltersObservableSet.CreateGitFiltersObservableSetAsync( - SolutionRepository, - _gitFiltersService, - _hierarchyCollectionProvider, - sourceItems, - CancellationToken); + if (SolutionRepository is null) + { + // We are called before initialization. + GitTreeFilterOutput.Log("GitFileFilter.GetIncludedItemsAsync: SolutionRepository is NULL -> aborting with 'select a repository first'"); + _gitFiltersService.ErrorPresenter.ShowError("You must select a repository first."); + return null; + } - return ItemSet; + var root = HierarchyUtilities.FindCommonAncestor(rootItems); + var sourceItems = await _hierarchyCollectionProvider.GetDescendantsAsync( + root.HierarchyIdentity.NestedHierarchy, + CancellationToken + ); + GitTreeFilterOutput.Log($"GitFileFilter.GetIncludedItemsAsync: collected {sourceItems?.Count ?? 0} source items, building filtered set"); + + ItemSet = await GitFiltersObservableSet.CreateGitFiltersObservableSetAsync( + SolutionRepository, + _gitFiltersService, + _hierarchyCollectionProvider, + sourceItems, + CancellationToken); + + GitTreeFilterOutput.Log($"GitFileFilter.GetIncludedItemsAsync: returning item set with {ItemSet?.Count ?? 0} included items"); + return ItemSet; + } + catch (Exception ex) + { + GitTreeFilterOutput.Log($"GitFileFilter.GetIncludedItemsAsync: UNHANDLED {ex.GetType().Name}: {ex.Message}"); + GitTreeFilterOutput.Log(ex.StackTrace); + throw; + } } private static bool TryGetAbsoluteFilePath(IVsHierarchyItem hierarchyItem, out string filepath) @@ -185,16 +200,19 @@ public async Task InvalidateAsync(CancellationToken cancellationToken) { await TaskScheduler.Default; + GitTreeFilterOutput.Log("GitFiltersObservableSet.InvalidateAsync: computing changeset"); GitChangeset gitChangeset = await CreateChangesetAsync(cancellationToken).ConfigureAwait(false); if (gitChangeset is null) { // Displaying no items is better than displaying all as it would suggest filter doesn't work + GitTreeFilterOutput.Log("GitFiltersObservableSet.InvalidateAsync: changeset is NULL -> rendering empty list"); IncludedItems = await _hierarchyCollectionProvider.GetFilteredHierarchyItemsAsync(_allItems, (_) => false, cancellationToken); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); return false; } + GitTreeFilterOutput.Log($"GitFiltersObservableSet.InvalidateAsync: changeset has {gitChangeset.Count} entries, applying predicate"); var filter = new ChangesetFilter(gitChangeset, _gitFilterService.ItemTagManager); IncludedItems = await _hierarchyCollectionProvider.GetFilteredHierarchyItemsAsync( @@ -203,6 +221,7 @@ public async Task InvalidateAsync(CancellationToken cancellationToken) cancellationToken); await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + GitTreeFilterOutput.Log($"GitFiltersObservableSet.InvalidateAsync: rendered {IncludedItems.Count} included items"); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); return true; } @@ -211,10 +230,13 @@ private async Task CreateChangesetAsync(CancellationToken cancella { try { - return _solutionRepository.Changeset; + var changeset = _solutionRepository.Changeset; + GitTreeFilterOutput.Log("GitFiltersObservableSet.CreateChangesetAsync: changeset computed successfully"); + return changeset; } catch (NothingToCompareException) { + GitTreeFilterOutput.Log("GitFiltersObservableSet.CreateChangesetAsync: NothingToCompareException (no reference object selected)"); await ShowErrorOnMainThreadAsync( "You must first select the reference object to compare your worktree to. Use the green button with the settings icon in the solution toolbar!", "No selected reference"); @@ -222,6 +244,7 @@ await ShowErrorOnMainThreadAsync( } catch (GitRepoNotFoundException) { + GitTreeFilterOutput.Log("GitFiltersObservableSet.CreateChangesetAsync: GitRepoNotFoundException"); await ShowErrorOnMainThreadAsync( "You must select a git repository first! You can do this at the bottom right corner of the window.", "Not a git repository"); @@ -229,18 +252,33 @@ await ShowErrorOnMainThreadAsync( } catch (HeadNotFoundException) { + GitTreeFilterOutput.Log("GitFiltersObservableSet.CreateChangesetAsync: HeadNotFoundException"); await ShowErrorOnMainThreadAsync( "Unable to locate the HEAD of your current changes. Make sure you are tracking a branch.", "Unable to find a HEAD in the current repository"); return null; } - catch (GitOperationException) + catch (GitOperationException ex) { + GitTreeFilterOutput.Log($"GitFiltersObservableSet.CreateChangesetAsync: GitOperationException ({ex.GetType().Name}): {ex.Message}"); await ShowErrorOnMainThreadAsync( "Unknown Git operation error.", "Unable to compare the selected refs"); return null; } + catch (Exception ex) + { + // Any exception that is not a recognised GitOperationException would + // otherwise bubble out of GetIncludedItemsAsync, abort filter + // initialization (so the settings button never appears) and leave the + // list unchanged. Log it explicitly and degrade to an empty list. + GitTreeFilterOutput.Log($"GitFiltersObservableSet.CreateChangesetAsync: UNEXPECTED {ex.GetType().Name}: {ex.Message}"); + GitTreeFilterOutput.Log(ex.StackTrace); + await ShowErrorOnMainThreadAsync( + $"Unexpected error while computing the diff: {ex.Message}", + $"Unexpected error: {ex.GetType().Name}"); + return null; + } } private async Task ShowErrorOnMainThreadAsync(string errorMessage, string logMessage) diff --git a/GitTreeFilter/GitTreeFilterProvider.cs b/GitTreeFilter/GitTreeFilterProvider.cs index 196f6b4..225ed9e 100644 --- a/GitTreeFilter/GitTreeFilterProvider.cs +++ b/GitTreeFilter/GitTreeFilterProvider.cs @@ -1,4 +1,5 @@ -using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell; +using System; using System.ComponentModel.Composition; namespace GitTreeFilter; @@ -15,14 +16,36 @@ public GitFilterProvider(SVsServiceProvider serviceProvider, IVsHierarchyItemCol _svcProvider = serviceProvider; _hierarchyCollectionProvider = hierarchyCollectionProvider; + GitTreeFilterOutput.Log("GitFilterProvider constructed (MEF) - priming SGitFilterService"); _ = _svcProvider.GetService(typeof(SGitFilterService)); } protected override HierarchyTreeFilter CreateFilter() { + GitTreeFilterOutput.Log("GitFilterProvider.CreateFilter called"); + var gitFilterService = _svcProvider.GetService(typeof(SGitFilterService)) as IGitFilterService; - return new GitFileFilter(gitFilterService, _hierarchyCollectionProvider); + if (gitFilterService is null) + { + GitTreeFilterOutput.Log("GitFilterProvider.CreateFilter: SGitFilterService resolved to NULL - filter cannot work"); + } + else + { + GitTreeFilterOutput.Log($"GitFilterProvider.CreateFilter: service resolved (PluginState={gitFilterService.PluginState}, SolutionRepository={(gitFilterService.SolutionRepository is null ? "null" : "present")}, ReferenceObject={(gitFilterService.ReferenceObject is null ? "null" : $"{gitFilterService.ReferenceObject.FriendlyName} ({gitFilterService.ReferenceObject.Reference?.Sha})")})"); + } + + try + { + var filter = new GitFileFilter(gitFilterService, _hierarchyCollectionProvider); + GitTreeFilterOutput.Log("GitFilterProvider.CreateFilter: GitFileFilter created successfully"); + return filter; + } + catch (Exception ex) + { + GitTreeFilterOutput.Log($"GitFilterProvider.CreateFilter: FAILED to create GitFileFilter - {ex.GetType().Name}: {ex.Message}"); + throw; + } } private readonly SVsServiceProvider _svcProvider; diff --git a/GitTreeFilter/GitTreeFilterService.cs b/GitTreeFilter/GitTreeFilterService.cs index 9847837..b683b56 100644 --- a/GitTreeFilter/GitTreeFilterService.cs +++ b/GitTreeFilter/GitTreeFilterService.cs @@ -1,4 +1,4 @@ -using EnvDTE; +using EnvDTE; using GitTreeFilter.Commands; using GitTreeFilter.Core; using GitTreeFilter.Core.Models; @@ -81,6 +81,7 @@ public bool IsFilterApplied set { _isFilterApplied = value; + GitTreeFilterOutput.Log($"IsFilterApplied set to {value} -> raising GitFilterLifecycleEvent (drives command/settings-button visibility)"); GitFilterLifecycleEvent?.Raise(this, new NotifyGitFilterLifecycleEventArgs()); } } @@ -148,7 +149,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken) private async Task SetUpAsync() { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - Debug.WriteLine(string.Format(CultureInfo.CurrentCulture, "Setting up solution information for GitFilterService")); + GitTreeFilterOutput.Log("SetUpAsync: setting up solution information for GitFilterService"); if (!TryGetRootRepositoryPath(out string repositoryRootPath)) { @@ -158,29 +159,54 @@ private async Task SetUpAsync() return; } - var gitSolution = new GitSolution(repositoryRootPath); + GitTreeFilterOutput.Log($"SetUpAsync: using repository root '{repositoryRootPath}'"); - Solution = gitSolution; - GlobalSettings = _package.Configuration; - ItemTagManager = new ItemTagManager(); + try + { + var gitSolution = new GitSolution(repositoryRootPath); + + Solution = gitSolution; + GlobalSettings = _package.Configuration; + ItemTagManager = new ItemTagManager(); - ComparisonConfig comparisonConfig = new( - () => GlobalSettings, - () => SessionSettings, - () => ReferenceObject - ); + ComparisonConfig comparisonConfig = new( + () => GlobalSettings, + () => SessionSettings, + () => ReferenceObject + ); - _solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository(Solution, comparisonConfig); + _solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository(Solution, comparisonConfig); - LoadTargetGitReference(); - LoadSessionSettings(); - ItemTagManager.CreateTagTables(); + LoadTargetGitReference(); + GitTreeFilterOutput.Log(_referenceObject is null + ? $"SetUpAsync: no reference object resolved (default branch '{GlobalSettings?.DefaultBranch}' not found and none stored) - filter will report 'select a reference'" + : $"SetUpAsync: reference object resolved -> {_referenceObject.FriendlyName} ({_referenceObject.Reference?.Sha})"); + LoadSessionSettings(); + ItemTagManager.CreateTagTables(); - GitTreeFilterOutput.WriteLine($"Initializing commands"); - await CommandRegistrar.InitializeAsync(_package); + GitTreeFilterOutput.WriteLine($"Initializing commands"); + await CommandRegistrar.InitializeAsync(_package); - GitTreeFilterOutput.WriteLine($"Initialization done"); - PluginState = PluginLifecycleState.RUNNING; + GitTreeFilterOutput.WriteLine($"Initialization done"); + PluginState = PluginLifecycleState.RUNNING; + GitTreeFilterOutput.Log("SetUpAsync: PluginState=RUNNING"); + } + catch (Exception ex) + { + // A failure here must never abort creation of the service itself: the + // service is produced by an async service factory, and an exception that + // escapes SetUpAsync leaves SGitFilterService null so the filter provider + // cannot create any filter at all ("cannot open"). Degrade gracefully to + // INACTIVE instead, and surface the reason to the output pane. + PluginState = PluginLifecycleState.INACTIVE; + GitTreeFilterOutput.Log($"SetUpAsync: FAILED - {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + { + GitTreeFilterOutput.Log($"SetUpAsync: inner - {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + GitTreeFilterOutput.Log($"SetUpAsync: stack - {ex.StackTrace}"); + GitTreeFilterOutput.WriteLine($"GitTreeFilter could not initialize for '{repositoryRootPath}': {ex.Message}. The plugin is inactive for this repository."); + } } private void OnRepositoryChanged(object sender, PropertyChangedEventArgs e) @@ -195,6 +221,33 @@ private bool TryGetRootRepositoryPath(out string repositoryPath) { ThreadHelper.ThrowIfNotOnUIThread(); + if (TryGetRepositoryPathFromGitExt(out repositoryPath)) + { + GitTreeFilterOutput.Log($"TryGetRootRepositoryPath: resolved via IGitExt.ActiveRepositories -> '{repositoryPath}'"); + return true; + } + + // Visual Studio's IGitExt does not surface every valid repository - notably + // git worktrees and submodules (whose ".git" is a file) - which left the + // plugin deactivated even though the solution is inside a Git repository. + // Fall back to discovering the repository directly from the solution location. + if (TryGetSolutionDirectory(out var solutionDirectory) + && GitRepositoryDiscovery.TryGetRepositoryRoot(solutionDirectory, out repositoryPath)) + { + GitTreeFilterOutput.Log($"TryGetRootRepositoryPath: resolved via filesystem discovery from '{solutionDirectory}' -> '{repositoryPath}'"); + Debug.WriteLine(string.Format(CultureInfo.CurrentCulture, "Discovered Git repository from the solution directory: {0}", repositoryPath)); + return true; + } + + GitTreeFilterOutput.Log("TryGetRootRepositoryPath: neither IGitExt nor filesystem discovery found a repository"); + repositoryPath = null; + return false; + } + + private bool TryGetRepositoryPathFromGitExt(out string repositoryPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_gitExt.ActiveRepositories.Count > 0) { if (_gitExt.ActiveRepositories.Count > 1) @@ -224,11 +277,26 @@ private bool TryGetRootRepositoryPath(out string repositoryPath) repositoryPath = gitRepositoryInfo.RepositoryPath; return true; } - else + + repositoryPath = null; + return false; + } + + private bool TryGetSolutionDirectory(out string directory) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + directory = null; + + var solutionFile = _dte?.Solution?.FullName; + if (string.IsNullOrEmpty(solutionFile)) { - repositoryPath = null; return false; } + + // In "Open Folder" mode FullName can already be a directory; otherwise it is a .sln file. + directory = Directory.Exists(solutionFile) ? solutionFile : Path.GetDirectoryName(solutionFile); + return !string.IsNullOrEmpty(directory); } private void LoadTargetGitReference()