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. -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Active
+ Failed
+ Pending
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Archived only
+ Not archived
+
+
+
+
+
+
+