Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 55 additions & 34 deletions src/UniGetUI.PackageEngine.Managers.Generic.NuGet/BaseNuGet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,15 +328,15 @@ INativeTaskLogger logger
entries.Add(candidate);
}

var installed = new Dictionary<string, (string Id, string Version)>();
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<Package> 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<LocalNuGetPackage>? entries))
continue;

Expand Down Expand Up @@ -493,10 +493,12 @@ out int v3Errors
continue;
}

var packageIdVersion = new Dictionary<string, string>();
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);
Expand Down Expand Up @@ -658,11 +660,13 @@ out int errors
return null;
}

var installed = new Dictionary<string, (string Id, string Version)>();
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<string, string>();
var failures = new ConcurrentDictionary<string, Exception?>();

Expand Down Expand Up @@ -742,33 +746,23 @@ internal IReadOnlyList<Package> KeepUpdatesNewerThanInstalled(
IReadOnlyList<IPackage> installedPackages
)
{
var highestInstalledById = new Dictionary<string, string>();
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<Package> kept = [];
foreach (var candidate in candidates)
{
if (
!highestInstalledById.TryGetValue(
!newestInstalled.TryGetValue(
candidate.Id.ToLower(),
out string? highestInstalled
out IPackage? newestPackage
)
)
{
kept.Add(candidate);
continue;
}

string highestInstalled = newestPackage.VersionString;
bool isNewer =
CompareVersions(highestInstalled, candidate.NewVersionString)
is { } comparison
Expand All @@ -783,14 +777,41 @@ out string? highestInstalled
return kept;
}

/// <summary>
/// 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.
/// </summary>
internal Dictionary<string, IPackage> SelectNewestInstalled(
IEnumerable<IPackage> installedPackages
)
{
var newest = new Dictionary<string, IPackage>();
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;
}

/// <summary>
/// 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 <see cref="SelectNewestInstalled"/>, 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.
/// </summary>
internal static Dictionary<string, string?> BuildInstalledScopeMap(
IEnumerable<IPackage> installedPackages
Expand Down
53 changes: 53 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/PowerShell7ManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<entry><d:Id>XKCD</d:Id><d:Version>1.12.7</d:Version></entry>";
var update = Assert.Single(BaseNuGet.ParseUpdatesResponse(
xml,
new Dictionary<string, string> { [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..")
Expand Down
Loading