From 7d7f402e205ee19ce8e8d838de42a9e771bcca38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 28 Oct 2026 20:35:03 -0400 Subject: [PATCH] Check NuGet updates against the newest installed version Update-PSResource installs each new version side by side, and Get-InstalledPSResource lists the copies sorted as strings (1.9.0, 1.12.6, 1.10.1). The update check took whichever copy was enumerated last, so a module like XKCD kept reporting 1.10.1 as installed and looked like it never updated, even though every update succeeded. Pick the highest installed version per id for the V2, V3 and local feed update paths, and take the update scope from that same copy. Co-Authored-By: Claude Opus 5.5 --- .../BaseNuGet.cs | 89 ++++++++++++------- .../PowerShell7ManagerTests.cs | 53 +++++++++++ 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs index ef9849431c..93a05e2e09 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs @@ -328,15 +328,15 @@ INativeTaskLogger logger entries.Add(candidate); } - var installed = new Dictionary(); - foreach (IPackage package in installedPackages) - installed[package.Id.ToLower()] = (package.Id, package.VersionString); - - var scopeMap = BuildInstalledScopeMap(installedPackages); + var newestInstalled = SelectNewestInstalled(installedPackages); + var scopeMap = BuildInstalledScopeMap(newestInstalled.Values); List packages = []; - foreach ((string id, string installedVersion) in installed.Values) + foreach (IPackage installedPackage in newestInstalled.Values) { + string id = installedPackage.Id; + string installedVersion = installedPackage.VersionString; + if (!availableById.TryGetValue(id, out List? entries)) continue; @@ -493,10 +493,12 @@ out int v3Errors continue; } - var packageIdVersion = new Dictionary(); - foreach (var package in pair.Value) - packageIdVersion[package.Id.ToLower()] = package.VersionString; - var packageIdScope = BuildInstalledScopeMap(pair.Value); + var newestInstalled = SelectNewestInstalled(pair.Value); + var packageIdVersion = newestInstalled.ToDictionary( + entry => entry.Key, + entry => entry.Value.VersionString + ); + var packageIdScope = BuildInstalledScopeMap(newestInstalled.Values); using HttpClient client = new(CoreTools.GenericHttpClientParameters); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); @@ -658,11 +660,13 @@ out int errors return null; } - var installed = new Dictionary(); - foreach (var package in installedPackages) - installed[package.Id.ToLower()] = (package.Id, package.VersionString); + var newestInstalled = SelectNewestInstalled(installedPackages); + var installed = newestInstalled.ToDictionary( + entry => entry.Key, + entry => (entry.Value.Id, Version: entry.Value.VersionString) + ); - var scopeMap = BuildInstalledScopeMap(installedPackages); + var scopeMap = BuildInstalledScopeMap(newestInstalled.Values); var candidates = new ConcurrentDictionary(); var failures = new ConcurrentDictionary(); @@ -742,26 +746,15 @@ internal IReadOnlyList KeepUpdatesNewerThanInstalled( IReadOnlyList installedPackages ) { - var highestInstalledById = new Dictionary(); - foreach (var package in installedPackages) - { - string key = package.Id.ToLower(); - if ( - !highestInstalledById.TryGetValue(key, out string? highest) - || CompareVersions(package.VersionString, highest) > 0 - ) - { - highestInstalledById[key] = package.VersionString; - } - } + var newestInstalled = SelectNewestInstalled(installedPackages); List kept = []; foreach (var candidate in candidates) { if ( - !highestInstalledById.TryGetValue( + !newestInstalled.TryGetValue( candidate.Id.ToLower(), - out string? highestInstalled + out IPackage? newestPackage ) ) { @@ -769,6 +762,7 @@ out string? highestInstalled continue; } + string highestInstalled = newestPackage.VersionString; bool isNewer = CompareVersions(highestInstalled, candidate.NewVersionString) is { } comparison @@ -783,14 +777,41 @@ out string? highestInstalled return kept; } + /// + /// Picks, for each installed package id (lowercased), the copy its update applies to: + /// the highest installed version, the last one enumerated on a tie. Update-PSResource + /// installs a new version side by side with the old ones, and Get-InstalledPSResource + /// lists them sorted as strings (1.9.0, 1.12.6, 1.10.1), so taking whichever copy came + /// last reported a stale installed version that never moved after an update. + /// + internal Dictionary SelectNewestInstalled( + IEnumerable installedPackages + ) + { + var newest = new Dictionary(); + foreach (var package in installedPackages) + { + string key = package.Id.ToLower(); + if ( + !newest.TryGetValue(key, out IPackage? current) + || !IsOlderVersion(package.VersionString, current.VersionString) + ) + { + newest[key] = package; + } + } + + return newest; + } + /// /// Maps each installed package id (lowercased) to the scope its update should target. - /// Mirrors the last-wins keying of the version map so the scope and the installed - /// version always come from the same enumerated package (issue #5163). A module - /// installed in a single scope updates in that scope; a module installed in both - /// resolves to whichever scope is enumerated last (as its version does) — surfacing - /// an independent update per scope would require scope-aware package identity, which - /// the upgrade loader does not currently support. + /// Callers pass the copies chosen by , so the scope + /// and the installed version always come from the same enumerated package (issue + /// #5163). A module installed in a single scope updates in that scope; a module + /// installed in both resolves to the scope holding its newest version — surfacing an + /// independent update per scope would require scope-aware package identity, which the + /// upgrade loader does not currently support. /// internal static Dictionary BuildInstalledScopeMap( IEnumerable installedPackages diff --git a/src/UniGetUI.PackageEngine.Tests/PowerShell7ManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PowerShell7ManagerTests.cs index 67b0f7b664..855ca1bf7e 100644 --- a/src/UniGetUI.PackageEngine.Tests/PowerShell7ManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PowerShell7ManagerTests.cs @@ -410,6 +410,59 @@ public void BuildInstalledScopeMap_TracksSameEnumeratedPackageAsVersion() Assert.Equal(PackageScope.User, installed[^1].OverridenOptions.Scope); } + // Update-PSResource installs side by side and Get-InstalledPSResource lists the versions + // sorted as strings, so the last enumerated copy is not the newest. The update must start + // from the newest copy, or it keeps reporting the same stale installed version after + // every successful update. + [Fact] + public void SelectNewestInstalled_PicksHighestVersionNotLastEnumerated() + { + var manager = new PowerShell7(); + var installed = PowerShell7.ParseInstalledPackages( + [ + "##SCOPE:AllUsers##", + "##SCOPE:CurrentUser##", + "XKCD\t1.9.0\tPSGallery", + "XKCD\t1.8.0\tPSGallery", + "XKCD\t1.12.6\tPSGallery", + "XKCD\t1.11.0\tPSGallery", + "XKCD\t1.10.1\tPSGallery", + ], manager); + + var newest = Assert.Single(manager.SelectNewestInstalled(installed)); + Assert.Equal("xkcd", newest.Key); + Assert.Equal("1.12.6", newest.Value.VersionString); + + var xml = "XKCD1.12.7"; + var update = Assert.Single(BaseNuGet.ParseUpdatesResponse( + xml, + new Dictionary { [newest.Key] = newest.Value.VersionString }, + BaseNuGet.BuildInstalledScopeMap([newest.Value]), + installed[0].Source, + manager)); + + Assert.Equal("1.12.6", update.VersionString); + Assert.Equal("1.12.7", update.NewVersionString); + Assert.Equal(PackageScope.User, update.OverridenOptions.Scope); + } + + [Fact] + public void SelectNewestInstalled_ScopeFollowsNewestCopy() + { + var manager = new PowerShell7(); + var installed = PowerShell7.ParseInstalledPackages( + [ + "##SCOPE:AllUsers##", "Devolutions.PowerShell\t2025.2.0\tPSGallery", + "##SCOPE:CurrentUser##", "Devolutions.PowerShell\t2025.1.0\tPSGallery", + ], manager); + + var newest = manager.SelectNewestInstalled(installed); + var idScope = BaseNuGet.BuildInstalledScopeMap(newest.Values); + + Assert.Equal("2025.2.0", newest["devolutions.powershell"].VersionString); + Assert.Equal(PackageScope.Machine, idScope["devolutions.powershell"]); + } + // Regression for https://github.com/Devolutions/UniGetUI/issues/4781: // the previous Format-Table-based pipeline truncated long names (e.g. // "Microsoft.Graph.Beta.DeviceManagement.Administration" → "Microsoft.Graph.Beta..")