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..")