From 735f6892c3b501a3f483ef69909362d53528f714 Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 15:16:38 +0200 Subject: [PATCH 1/6] Fix diff list not loading when HEAD is detached AssertValidReference rejected any repository whose HEAD friendly name was not in the branch list, so a detached HEAD (checked-out commit/tag, mid-rebase/bisect, or detached worktree) threw HeadNotFoundException. The UI caught it and rendered an empty, unchanging list. Validate HEAD by checking it resolves to a real commit (Head.Tip) instead, which accepts detached HEAD while still rejecting an unborn/head-less repo. Adds detached-HEAD changeset tests over the Basic and Merge fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .../DetachedHeadChangesetTest.cs | 77 +++++++++++++++++++ .../Test/RepositoryState.cs | 45 +++++++++++ GitTreeFilter.Core/SolutionRepository.cs | 8 +- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 GitTreeFilter.Core.Tests/DetachedHeadChangesetTest.cs create mode 100644 GitTreeFilter.Core.Tests/Test/RepositoryState.cs 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/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/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(); } From b0d68efd4f7e93cac6bb8a6b5494fcbb37f2a5e5 Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 16:40:06 +0200 Subject: [PATCH 2/6] Activate plugin for worktrees/submodules VS IGitExt does not surface TryGetRootRepositoryPath relied solely on IGitExt.ActiveRepositories, which is empty for git worktrees and submodules (.git is a file). The plugin then deactivated with 'Could not identify GIT repository to use', so selecting the filter did nothing even though the solution was inside a Git repo. Add filesystem-based GitRepositoryDiscovery (LibGit2Sharp Repository.Discover) and use it as a fallback from the solution directory when IGitExt reports no repository. Covers worktrees, submodules, and nested-subdirectory solutions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .../GitRepositoryDiscoveryTest.cs | 78 +++++++++++++++++++ GitTreeFilter.Core/GitRepositoryDiscovery.cs | 60 ++++++++++++++ GitTreeFilter/GitTreeFilterService.cs | 43 +++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 GitTreeFilter.Core.Tests/GitRepositoryDiscoveryTest.cs create mode 100644 GitTreeFilter.Core/GitRepositoryDiscovery.cs 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/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/GitTreeFilterService.cs b/GitTreeFilter/GitTreeFilterService.cs index 9847837..ad446b1 100644 --- a/GitTreeFilter/GitTreeFilterService.cs +++ b/GitTreeFilter/GitTreeFilterService.cs @@ -195,6 +195,30 @@ private bool TryGetRootRepositoryPath(out string repositoryPath) { ThreadHelper.ThrowIfNotOnUIThread(); + if (TryGetRepositoryPathFromGitExt(out 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)) + { + Debug.WriteLine(string.Format(CultureInfo.CurrentCulture, "Discovered Git repository from the solution directory: {0}", repositoryPath)); + return true; + } + + repositoryPath = null; + return false; + } + + private bool TryGetRepositoryPathFromGitExt(out string repositoryPath) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_gitExt.ActiveRepositories.Count > 0) { if (_gitExt.ActiveRepositories.Count > 1) @@ -224,11 +248,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() From a246704c86ffd2118225a3127b85fe6f056039b5 Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 17:12:15 +0200 Subject: [PATCH 3/6] Add Output-pane diagnostics across filter activation flow The worktree scenario reaches PluginState=RUNNING yet selecting the filter does nothing and the settings button never appears. The existing logs used Debug.WriteLine (invisible in the VS Output pane) and none covered the filter/changeset path, so the failing boundary was unobservable. - GitTreeFilterOutput: drop the UI-thread guard (OutputStringThreadSafe is thread-safe) and add Log() with timestamp + thread id so background-thread stages can trace. This makes filter creation and changeset building visible. - Instrument GitFilterProvider.CreateFilter (service null / plugin state / reference object), GitFileFilter ctor + OnInitialized (settings-button trigger), GetIncludedItemsAsync, InvalidateAsync and CreateChangesetAsync. - Add a catch-all in CreateChangesetAsync so a non-GitOperationException no longer bubbles out of GetIncludedItemsAsync (which would abort filter initialization and hide the settings button); it is logged and degrades to an empty list instead. - Promote repo-discovery and reference-resolution logs in the service to the Output pane, and log IsFilterApplied/lifecycle transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .../Commands/AbstractGitFiltersCommand.cs | 9 +- GitTreeFilter/GitTreeFilterOutput.cs | 29 +++++-- .../GitTreeFilterProvider.GitFileFilter.cs | 82 ++++++++++++++----- GitTreeFilter/GitTreeFilterProvider.cs | 27 +++++- GitTreeFilter/GitTreeFilterService.cs | 14 +++- 5 files changed, 126 insertions(+), 35 deletions(-) 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 ad446b1..1e05438 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,6 +159,8 @@ private async Task SetUpAsync() return; } + GitTreeFilterOutput.Log($"SetUpAsync: using repository root '{repositoryRootPath}'"); + var gitSolution = new GitSolution(repositoryRootPath); Solution = gitSolution; @@ -173,6 +176,9 @@ private async Task SetUpAsync() _solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository(Solution, comparisonConfig); 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(); @@ -181,6 +187,7 @@ private async Task SetUpAsync() GitTreeFilterOutput.WriteLine($"Initialization done"); PluginState = PluginLifecycleState.RUNNING; + GitTreeFilterOutput.Log("SetUpAsync: PluginState=RUNNING"); } private void OnRepositoryChanged(object sender, PropertyChangedEventArgs e) @@ -197,6 +204,7 @@ private bool TryGetRootRepositoryPath(out string repositoryPath) if (TryGetRepositoryPathFromGitExt(out repositoryPath)) { + GitTreeFilterOutput.Log($"TryGetRootRepositoryPath: resolved via IGitExt.ActiveRepositories -> '{repositoryPath}'"); return true; } @@ -207,10 +215,12 @@ private bool TryGetRootRepositoryPath(out string repositoryPath) 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; } From 3a6cd86f64237aec5de87d8511c3cdacba51d984 Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 19:02:50 +0200 Subject: [PATCH 4/6] Fix diff failing on modern git index (LibGit2Sharp 0.31 upgrade) Bug #3 root cause: LibGit2Sharp 0.27.0-preview (libgit2 ~1.0) cannot parse the index written by modern git when the untracked-cache (UNTR) extension is present at scale (repos with core.untrackedCache/feature.manyFiles). This threw "invalid data in index - calculated checksum does not match expected" from RetrieveStatus in SolutionRepository.Changeset, leaving the Solution Explorer list empty ("won't budge"). Fix: upgrade LibGit2Sharp 0.27.0-preview-0182 -> 0.31.0 (libgit2 ~1.8), which parses the modern index cleanly. No API breaks. Native git2 binaries flow into the VSIX via the Core ProjectReference (lib/win32/*). Adds a regression test that builds a temp repo with feature.manyFiles + 500 files (confirmed RED on 0.27, GREEN on 0.31). Full Core suite: 83 passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .../ModernIndexChangesetTest.cs | 166 ++++++++++++++++++ GitTreeFilter.Core/GitTreeFilter.Core.csproj | 2 +- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 GitTreeFilter.Core.Tests/ModernIndexChangesetTest.cs 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/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 @@ - + From b35c7fd7bfb0ee4e58582e409a0833c6c80d2c5c Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 19:32:43 +0200 Subject: [PATCH 5/6] Fix plugin deactivation on repos owned by another user (Bug #4) libgit2 1.8 (bundled with LibGit2Sharp 0.31, adopted to fix the modern index-checksum bug) enforces the "dubious ownership" check from CVE-2022-24765. On enterprise machines a git worktree/clone is frequently owned by a different account than the one running Visual Studio, so `new Repository(path)` threw "repository path '...' is not owned by current user" inside GitTreeFilterService.SetUpAsync. That exception escaped the async service factory, leaving SGitFilterService null and making the filter provider fail with "Cannot find an instance of the GitTreeFilter. IGitFilterService service" ("cannot open"). Root cause fix: call GlobalSettings.SetOwnerValidation(false) in GitRepositoryFactory.Create before opening the repository. Visual Studio has already opened the repo for the user and the plugin only reads it, so the ownership gate adds no protection here. Defense in depth: wrap the fallible body of SetUpAsync in try/catch so any future setup exception degrades the plugin to INACTIVE (with the reason logged to the output pane) instead of nulling the whole service. TDD: RepositoryOwnershipTest.OpeningRepositoryDisablesLibGit2OwnerValidation opens a repo through the public SolutionRepository API and asserts owner validation is disabled. RED before the fix, GREEN after. Full Core suite 84/84. End-to-end probe against the real worktree with the Release-output 0.31 DLLs: open now succeeds and RetrieveStatus returns 1658 entries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .../RepositoryOwnershipTest.cs | 150 ++++++++++++++++++ GitTreeFilter.Core/GitRepositoryFactory.cs | 10 +- GitTreeFilter/GitTreeFilterService.cs | 61 ++++--- 3 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 GitTreeFilter.Core.Tests/RepositoryOwnershipTest.cs 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/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/GitTreeFilterService.cs b/GitTreeFilter/GitTreeFilterService.cs index 1e05438..b683b56 100644 --- a/GitTreeFilter/GitTreeFilterService.cs +++ b/GitTreeFilter/GitTreeFilterService.cs @@ -161,33 +161,52 @@ private async Task SetUpAsync() GitTreeFilterOutput.Log($"SetUpAsync: using repository root '{repositoryRootPath}'"); - var gitSolution = new GitSolution(repositoryRootPath); + try + { + var gitSolution = new GitSolution(repositoryRootPath); - Solution = gitSolution; - GlobalSettings = _package.Configuration; - ItemTagManager = new ItemTagManager(); + 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(); - 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(); + 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.Log("SetUpAsync: PluginState=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) From 2179a0a29ca5c4e0a78740e4afbb4d9ad8ab7a79 Mon Sep 17 00:00:00 2001 From: Grzegorz Gurgul Date: Mon, 20 Jul 2026 20:00:29 +0200 Subject: [PATCH 6/6] Update actions/cache v2 -> v4 in build workflow GitHub automatically fails runs that use the deprecated actions/cache v2. Bump to v4 (current major, input-compatible) to keep CI working. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09814c7d-b363-45cb-83eb-1832f7a8abcb --- .github/workflows/_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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') }}