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
46 changes: 33 additions & 13 deletions src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public HomebrewSource(IPackageManager manager, string name, Uri url)
}
}

public class Homebrew : PackageManager
public partial class Homebrew : PackageManager
{
// Standard Homebrew installation paths, in priority order
private static readonly string[] BREW_PATHS =
Expand Down Expand Up @@ -226,26 +226,49 @@ private IReadOnlyList<Package> ListInstalledByType(string typeFlag, string sourc
return packages;
}

// "brew outdated --verbose" prints a different comparison operator per package type:
// Formula: "name (old_version) < new_version" (versions are comparable)
// Cask: "name (old_version) != new_version" (versions are opaque strings)
// Both operators must be accepted, otherwise cask updates are silently dropped.
[GeneratedRegex(@"^(\S+)\s+\(([^)]+)\)\s+(?:<|!=)\s+(.+)$")]
private static partial Regex OutdatedLineRegex();

protected override IReadOnlyList<Package> GetAvailableUpdates_UnSafe()
{
var packages = new List<Package>();

// Build a lookup of installed packages to retrieve their sources
Dictionary<string, IPackage> installed = [];
foreach (var pkg in GetInstalledPackages())
installed.TryAdd(pkg.Id, pkg);
IReadOnlyList<IPackage> installed = GetInstalledPackages();

using var p = new Process { StartInfo = MakeBrewStartInfo("outdated --verbose") };
IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p);
p.Start();

// Format: "name (old_version) < new_version"
var pattern = new Regex(@"^(\S+)\s+\(([^)]+)\)\s+<\s+(.+)$");
var lines = new List<string>();
string? line;
while ((line = p.StandardOutput.ReadLine()) is not null)
{
logger.AddToStdOut(line);
var m = pattern.Match(line);
lines.Add(line);
}

logger.AddToStdErr(p.StandardError.ReadToEnd());
p.WaitForExit();
logger.Close(p.ExitCode);

return ParseAvailableUpdates(lines, installed);
}

internal IReadOnlyList<Package> ParseAvailableUpdates(
IEnumerable<string> lines,
IEnumerable<IPackage> installedPackages)
{
// Build a lookup of installed packages to retrieve their sources (Formula vs Cask)
Dictionary<string, IPackage> installed = [];
foreach (var pkg in installedPackages)
installed.TryAdd(pkg.Id, pkg);

var packages = new List<Package>();
foreach (var line in lines)
{
var m = OutdatedLineRegex().Match(line);
if (!m.Success) continue;

var id = m.Groups[1].Value.Trim();
Expand All @@ -265,9 +288,6 @@ protected override IReadOnlyList<Package> GetAvailableUpdates_UnSafe()
this));
}

logger.AddToStdErr(p.StandardError.ReadToEnd());
p.WaitForExit();
logger.Close(p.ExitCode);
return packages;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fontconfig (2.17.1) < 2.18.2
shaderc (2026.2) < 2026.3
python@3.14 (3.14.3_1) < 3.14.6
firefox (152.0.5) != 152.0.6
visual-studio-code (1.90.0) != 1.91.0
110 changes: 110 additions & 0 deletions src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using UniGetUI.Core.Data;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.PackageEngine.Interfaces;
using UniGetUI.PackageEngine.Managers.HomebrewManager;
using UniGetUI.PackageEngine.PackageClasses;
using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions;
using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers;

namespace UniGetUI.PackageEngine.Tests;

[CollectionDefinition("Homebrew manager tests", DisableParallelization = true)]
public sealed class HomebrewManagerTestCollection
{
public const string Name = "Homebrew manager tests";
}

[Collection(HomebrewManagerTestCollection.Name)]
public sealed class HomebrewManagerTests : IDisposable
{
private readonly string _testRoot = Path.Combine(
AppContext.BaseDirectory,
nameof(HomebrewManagerTests),
Guid.NewGuid().ToString("N")
);

public HomebrewManagerTests()
{
Directory.CreateDirectory(_testRoot);
CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data");
Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory);
Settings.ResetSettings();
}

public void Dispose()
{
Settings.ResetSettings();
CoreData.TEST_DataDirectoryOverride = null;
if (Directory.Exists(_testRoot))
{
Directory.Delete(_testRoot, recursive: true);
}
}

// Regression test for issue #5127: "brew outdated --verbose" prints "<" for outdated
// Formulae but "!=" for outdated Casks. The parser used to match only "<", so Cask
// updates were silently dropped. Both operators must produce update packages.
[Fact]
public void ParseAvailableUpdatesDetectsBothFormulaAndCaskUpdates()
{
var manager = new Homebrew();
IManagerSource formulaSource = manager.SourcesHelper.Factory.GetSourceOrDefault("Homebrew");
IManagerSource caskSource = manager.SourcesHelper.Factory.GetSourceOrDefault("Homebrew Cask");

// Installed packages carry the source (Formula vs Cask) that updates should inherit.
var installed = new List<IPackage>
{
new Package("Fontconfig", "fontconfig", "2.17.1", formulaSource, manager),
new Package("Shaderc", "shaderc", "2026.2", formulaSource, manager),
new Package("Python@3.14", "python@3.14", "3.14.3_1", formulaSource, manager),
new Package("Firefox", "firefox", "152.0.5", caskSource, manager),
new Package("Visual Studio Code", "visual-studio-code", "1.90.0", caskSource, manager),
};

var updates = manager.ParseAvailableUpdates(
ReadFixtureLines(Path.Combine("Homebrew", "outdated-verbose.txt")),
installed
);

Assert.Collection(
updates,
package =>
{
PackageAssert.Matches(package, "Fontconfig", "fontconfig", "2.17.1", "2.18.2");
PackageAssert.BelongsTo(package, manager, formulaSource);
},
package =>
{
PackageAssert.Matches(package, "Shaderc", "shaderc", "2026.2", "2026.3");
PackageAssert.BelongsTo(package, manager, formulaSource);
},
package =>
{
PackageAssert.Matches(package, "Python@3.14", "python@3.14", "3.14.3_1", "3.14.6");
PackageAssert.BelongsTo(package, manager, formulaSource);
},
// The two Casks below (matched via "!=") were the ones dropped by the old parser.
package =>
{
PackageAssert.Matches(package, "Firefox", "firefox", "152.0.5", "152.0.6");
PackageAssert.BelongsTo(package, manager, caskSource);
},
package =>
{
PackageAssert.Matches(
package,
"Visual Studio Code",
"visual-studio-code",
"1.90.0",
"1.91.0"
);
PackageAssert.BelongsTo(package, manager, caskSource);
}
);
}

private static string[] ReadFixtureLines(string relativePath)
{
return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<ProjectReference Include="..\UniGetUI.PackageEngine.Managers.Npm\UniGetUI.PackageEngine.Managers.Npm.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.Managers.Snap\UniGetUI.PackageEngine.Managers.Snap.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.Managers.Flatpak\UniGetUI.PackageEngine.Managers.Flatpak.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.Managers.Homebrew\UniGetUI.PackageEngine.Managers.Homebrew.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.Interfaces\UniGetUI.PackageEngine.Interfaces.csproj" />
<ProjectReference Include="..\UniGetUI.Interface.Enums\UniGetUI.Interface.Enums.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.Enums\UniGetUI.PackageEngine.Structs.csproj" />
Expand Down
Loading