diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbdb1a1..25da803 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,26 @@ dotnet test gclo.Engine.Tests dotnet format gclo.slnx --verify-no-changes --severity error ``` +## Debugging in Visual Studio: expect (and silence) exception breaks + +Per-repo failure isolation is exception-based by design: a repository that +cannot be cloned or pulled throws (`LibGit2SharpException`, +`InvalidRepositoryPathsException`, ...) and the sync engine catches it, marks +that row Failed, and keeps going. Many of these exceptions surface from inside +native libgit2 frames, so with **Just My Code** enabled Visual Studio breaks on +them as "user-unhandled" even though they are always caught — during a sync +with failing repositories the debugger pauses every thread on each one, which +looks like the whole UI is frozen until you press Continue. + +None of this happens outside the debugger. Options, in order of preference: + +1. Run without the debugger (**Ctrl+F5**) unless you are actively debugging. +2. In **Debug → Windows → Exception Settings**, uncheck *Break when this + exception type is user-unhandled* for `LibGit2Sharp.LibGit2SharpException` + (and `gclo.Engine.InvalidRepositoryPathsException`) after the first break. +3. Disable **Tools → Options → Debugging → Enable Just My Code** — the + debugger then only breaks on genuinely unhandled exceptions. + ## What CI enforces Every pull request must pass: diff --git a/gclo.Engine.Tests/SyncRequestTests.cs b/gclo.Engine.Tests/SyncRequestTests.cs new file mode 100644 index 0000000..2608f92 --- /dev/null +++ b/gclo.Engine.Tests/SyncRequestTests.cs @@ -0,0 +1,21 @@ +namespace gclo.Engine.Tests; + +/// +/// Pins the security contract of : the record ships on +/// NuGet, and its ToString must never print the PAT (the generated record +/// ToString would include every positional member). +/// +public sealed class SyncRequestTests +{ + [Fact] + public void ToString_NeverContainsTheToken() + { + var request = new SyncRequest("acme", "ghp_super_secret_token", @"C:\src", 8); + + string text = request.ToString(); + + Assert.DoesNotContain("ghp_super_secret_token", text); + Assert.Contains("[redacted]", text); + Assert.Contains("acme", text); // everything else stays diagnosable + } +} diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 1401d79..0379e2b 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -910,6 +910,75 @@ public sealed class WorkspaceViewModelTests Assert.NotEmpty(vm.Repos); // the empty-filter hint case: rows exist, none match } + [Fact] + public async Task NameFilter_CaseInsensitiveSubstring_ComposesWithStatusFilter() + { + var vm = await CreateLoadedViewModelAsync( + Repo("platform-api"), Repo("platform-web"), Repo("tools")); + vm.Repos.First(r => r.Name == "platform-api").Status = SyncStatus.Failed; + + vm.NameFilter = "PLATFORM"; + Assert.Equal(["platform-api", "platform-web"], vm.FilteredRepos.Select(r => r.Name)); + + vm.Filter = RepoFilter.Failed; // AND-composes with the name filter + Assert.Equal(["platform-api"], vm.FilteredRepos.Select(r => r.Name)); + + vm.NameFilter = ""; + vm.Filter = RepoFilter.All; + Assert.Equal(3, vm.FilteredRepos.Count); + } + + [Fact] + public async Task NameFilter_TrimsBeforeMatching() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + vm.NameFilter = " alpha "; + + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); + } + + [Fact] + public async Task BranchFilter_MatchesTheDisplayedBranchText() + { + var vm = await CreateLoadedViewModelAsync( + Repo("alpha", branch: "main"), + Repo("bravo", branch: "develop"), + Repo("empty", branch: null)); // empty repository: BranchText is "" + + vm.BranchFilter = "dev"; + + Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name)); + } + + [Fact] + public async Task ArchivedFilter_TriState_AllArchivedUnarchived() + { + var vm = await CreateLoadedViewModelAsync( + Repo("alpha", archived: true), Repo("bravo")); + + vm.ArchivedFilter = true; + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); + + vm.ArchivedFilter = false; + Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name)); + + vm.ArchivedFilter = null; + Assert.Equal(2, vm.FilteredRepos.Count); + } + + [Fact] + public async Task ColumnFilters_NeverTouchSelection_HiddenRowsStillSync() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + Assert.True(vm.Repos.All(r => r.IsSelected)); // load selects everything + + vm.NameFilter = "alpha"; // bravo is hidden but stays selected + + Assert.Equal(2, vm.SelectedCount); + Assert.Contains("2", vm.SyncButtonLabel); + } + [Fact] public async Task FilteredRepos_FollowsTerminalTransitions_AcrossARun() { diff --git a/gclo.Engine/SyncRequest.cs b/gclo.Engine/SyncRequest.cs index 3a91bda..f64b6ee 100644 --- a/gclo.Engine/SyncRequest.cs +++ b/gclo.Engine/SyncRequest.cs @@ -5,4 +5,14 @@ namespace gclo.Engine; /// GitHub Personal Access Token used for both the API and git transport. /// Local folder under which each repository is placed as a subfolder. /// Upper bound on simultaneous git operations. -public sealed record SyncRequest(string Organization, string Token, string TargetRoot, int MaxConcurrency = 8); +public sealed record SyncRequest(string Organization, string Token, string TargetRoot, int MaxConcurrency = 8) +{ + /// + /// Redacts the token: the record-generated ToString would print every positional + /// member, and this type ships publicly on NuGet where any log or exception + /// interpolation of a request would otherwise leak the PAT. + /// + public override string ToString() => + $"SyncRequest {{ Organization = {Organization}, Token = [redacted], " + + $"TargetRoot = {TargetRoot}, MaxConcurrency = {MaxConcurrency} }}"; +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index c180352..fff3534 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -74,6 +74,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable Organization = ""; Token = ""; TargetFolder = ""; + NameFilter = ""; + BranchFilter = ""; MaxConcurrency = AppSettings.DefaultConcurrency; StatusText = ""; ResultMessage = ""; @@ -196,6 +198,23 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [ObservableProperty] public partial RepoFilter Filter { get; set; } + /// + /// Column filter: case-insensitive substring the repository name must contain. + /// Empty (the default) matches everything. Composes with + /// and the other column filters; selection state is unaffected — hidden rows + /// stay selected and still sync. + /// + [ObservableProperty] + public partial string NameFilter { get; set; } + + /// Column filter: case-insensitive substring the branch name must contain. + [ObservableProperty] + public partial string BranchFilter { get; set; } + + /// Column filter: null shows all, true only archived, false only unarchived. + [ObservableProperty] + public partial bool? ArchivedFilter { get; set; } + /// Number of rows currently selected for the next sync. [ObservableProperty] public partial int SelectedCount { get; set; } @@ -231,6 +250,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable partial void OnFilterChanged(RepoFilter value) => RebuildFilteredRepos(); + partial void OnNameFilterChanged(string value) => RebuildFilteredRepos(); + + partial void OnBranchFilterChanged(string value) => RebuildFilteredRepos(); + + partial void OnArchivedFilterChanged(bool? value) => RebuildFilteredRepos(); + partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(SyncButtonLabel)); partial void OnIsRunningChanged(bool value) => UpdateCanEditInputs(); @@ -861,7 +886,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } } - private bool MatchesFilter(RepoItemViewModel repo) => Filter switch + private bool MatchesFilter(RepoItemViewModel repo) + => MatchesStatusFilter(repo) + && (NameFilter.Length == 0 + || repo.Name.Contains(NameFilter.Trim(), StringComparison.OrdinalIgnoreCase)) + && (BranchFilter.Length == 0 + || repo.BranchText.Contains(BranchFilter.Trim(), StringComparison.OrdinalIgnoreCase)) + && (ArchivedFilter is not { } archived || repo.IsArchived == archived); + + private bool MatchesStatusFilter(RepoItemViewModel repo) => Filter switch { RepoFilter.Active => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling, RepoFilter.Failed => repo.Status == SyncStatus.Failed, diff --git a/gclo/Services/UpdateService.cs b/gclo/Services/UpdateService.cs index 9ebdc00..25db2c7 100644 --- a/gclo/Services/UpdateService.cs +++ b/gclo/Services/UpdateService.cs @@ -1,6 +1,8 @@ using System; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using gclo.Engine; using Velopack; using Velopack.Sources; @@ -122,7 +124,21 @@ public sealed class UpdateService /// /// Created lazily so merely constructing the service can never fail; callers wrap this /// in try/catch and treat a throwing manager as "updates not supported". + /// A prerelease install (semver has a '-suffix') runs on the dev channel, whose + /// packages are attached to GitHub *prerelease* releases — GithubSource must be + /// told to look at prereleases or dev installs can never find any update at all. + /// Stable installs keep prereleases out of consideration; Velopack's channel + /// filtering then matches packages to the installed channel either way. /// - private UpdateManager GetManager() => - _manager ??= new UpdateManager(new GithubSource(RepoUrl, null, false)); + private UpdateManager GetManager() + { + if (_manager is null) + { + bool prerelease = BuildVersion + .Describe(Assembly.GetExecutingAssembly()) + .Contains('-'); + _manager = new UpdateManager(new GithubSource(RepoUrl, null, prerelease)); + } + return _manager; + } } diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index 1c03cb8..18fe1bc 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -9,12 +9,54 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + - @@ -305,11 +358,18 @@ x:Name="SelectionSummaryText" Grid.Column="1" VerticalAlignment="Center" + TextTrimming="CharacterEllipsis" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> - - - - + Every column sorts (button, composed automation name like + "Name, sorted ascending") and filters (funnel flyout; the funnel + turns accent-colored while its filter is active). The header row + itself gets subtle chrome to read as a header, not a data row. --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +