diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewSourceHelper.cs index 7919bff5d7..bf193d2116 100644 --- a/src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewSourceHelper.cs @@ -19,7 +19,7 @@ public HomebrewSourceHelper(Homebrew manager) protected override IReadOnlyList GetSources_UnSafe() { - var sources = new List(); + var tapLines = new List(); using var p = new Process { @@ -32,8 +32,31 @@ protected override IReadOnlyList GetSources_UnSafe() while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - var name = line.Trim(); + tapLines.Add(line); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + return BuildSourceList(tapLines); + } + + /// + /// Homebrew 4 and later serve homebrew/core and homebrew/cask from the API, so `brew tap` does not + /// print them and `brew tap homebrew/core` is refused. The built-in sources are therefore always + /// listed, followed by every other tap. + /// + internal IReadOnlyList BuildSourceList(IEnumerable tapLines) + { + var sources = new List(Manager.Properties.KnownSources); + + foreach (string rawLine in tapLines) + { + var name = rawLine.Trim(); if (name.Length == 0) continue; + if (name.Equals(CoreTap, StringComparison.OrdinalIgnoreCase) + || name.Equals(CaskTap, StringComparison.OrdinalIgnoreCase)) + continue; // Build a best-effort URL: "org/repo" → "https://github.com/org/homebrew-repo" Uri url; @@ -60,19 +83,33 @@ protected override IReadOnlyList GetSources_UnSafe() } } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); return sources; } // ── Add / remove ─────────────────────────────────────────────────────── + internal const string CoreTap = "homebrew/core"; + internal const string CaskTap = "homebrew/cask"; + + /// + /// The tap name brew expects for a source: the built-in "Homebrew" and "Homebrew Cask" sources map to + /// homebrew/core and homebrew/cask; any other source is named after its tap already. + /// + internal static string GetTapName(IManagerSource source) => source.Name switch + { + "Homebrew" => CoreTap, + "Homebrew Cask" => CaskTap, + _ => source.Name, + }; + public override string[] GetAddSourceParameters(IManagerSource source) - => ["tap", source.Name, source.Url.ToString()]; + { + string tap = GetTapName(source); + return tap == source.Name ? ["tap", tap, source.Url.ToString()] : ["tap", tap]; + } public override string[] GetRemoveSourceParameters(IManagerSource source) - => ["untap", source.Name]; + => ["untap", GetTapName(source)]; protected override OperationVeredict _getAddSourceOperationVeredict( IManagerSource source, int ReturnCode, string[] Output) diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs index 5797ccbe51..8dd3e02393 100644 --- a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs @@ -69,11 +69,7 @@ public Homebrew() InstallVerb = "install", UpdateVerb = "upgrade", UninstallVerb = "uninstall", - KnownSources = - [ - new HomebrewSource(this, "Homebrew", new Uri("https://github.com/Homebrew/homebrew-core")), - new HomebrewSource(this, "Homebrew Cask", new Uri("https://github.com/Homebrew/homebrew-cask")), - ], + KnownSources = CreateBuiltInSources(this, OperatingSystem.IsMacOS()), DefaultSource = new HomebrewSource(this, "Homebrew", new Uri("https://github.com/Homebrew/homebrew-core")), }; @@ -82,6 +78,19 @@ public Homebrew() OperationHelper = new HomebrewPkgOperationHelper(this); } + /// + /// The sources Homebrew serves from its API without a tap: formulae everywhere, casks on macOS + /// only (Homebrew on Linux has no casks). + /// + internal static IManagerSource[] CreateBuiltInSources(Homebrew manager, bool isMacOS) + { + var formulae = new HomebrewSource(manager, "Homebrew", new Uri("https://github.com/Homebrew/homebrew-core")); + if (!isMacOS) + return [formulae]; + + return [formulae, new HomebrewSource(manager, "Homebrew Cask", new Uri("https://github.com/Homebrew/homebrew-cask"))]; + } + // ── Executable discovery ─────────────────────────────────────────────── public override IReadOnlyList FindCandidateExecutableFiles() diff --git a/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs index 55bb324b29..10f284ba29 100644 --- a/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs @@ -1,5 +1,6 @@ using UniGetUI.Core.Data; using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Classes.Manager; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Managers.HomebrewManager; using UniGetUI.PackageEngine.PackageClasses; @@ -103,6 +104,73 @@ public void ParseAvailableUpdatesDetectsBothFormulaAndCaskUpdates() ); } + // Issue #5219: on Homebrew 4 and later `brew tap` prints nothing for homebrew/core, so the + // sources page was empty and the default "Homebrew" source was reported as not configured. + [Fact] + public void SourcesListTheBuiltInSourcesWhenBrewTapPrintsNothing() + { + var manager = new Homebrew(); + var helper = (HomebrewSourceHelper)manager.SourcesHelper; + + IReadOnlyList sources = helper.BuildSourceList([]); + + Assert.Equal(manager.Properties.KnownSources, sources); + Assert.Contains(sources, source => source.Name == "Homebrew"); + } + + [Fact] + public void SourcesListOtherTapsOnceAndSkipTheBuiltInTaps() + { + var manager = new Homebrew(); + var helper = (HomebrewSourceHelper)manager.SourcesHelper; + + IReadOnlyList sources = helper.BuildSourceList( + ["homebrew/core", "hashicorp/tap", "", " ", "Homebrew/cask"] + ); + + Assert.Equal(manager.Properties.KnownSources.Length + 1, sources.Count); + Assert.Equal(manager.Properties.KnownSources, sources.Take(manager.Properties.KnownSources.Length)); + IManagerSource tap = sources[^1]; + Assert.Equal("hashicorp/tap", tap.Name); + Assert.Equal(new Uri("https://github.com/hashicorp/homebrew-tap"), tap.Url); + } + + [Fact] + public void CasksAreABuiltInSourceOnMacOsOnly() + { + var manager = new Homebrew(); + + Assert.Equal(["Homebrew"], Homebrew.CreateBuiltInSources(manager, isMacOS: false).Select(s => s.Name)); + Assert.Equal( + ["Homebrew", "Homebrew Cask"], + Homebrew.CreateBuiltInSources(manager, isMacOS: true).Select(s => s.Name) + ); + Assert.Equal( + OperatingSystem.IsMacOS() ? 2 : 1, + manager.Properties.KnownSources.Length + ); + } + + // brew rejects "Homebrew" and "Homebrew Cask" ("Error: Invalid tap name: 'Homebrew'"); the + // parameters must name the tap. + [Theory] + [InlineData("Homebrew", "https://github.com/Homebrew/homebrew-core", "tap homebrew/core", "untap homebrew/core")] + [InlineData("Homebrew Cask", "https://github.com/Homebrew/homebrew-cask", "tap homebrew/cask", "untap homebrew/cask")] + [InlineData( + "hashicorp/tap", + "https://github.com/hashicorp/homebrew-tap", + "tap hashicorp/tap https://github.com/hashicorp/homebrew-tap", + "untap hashicorp/tap" + )] + public void AddAndRemoveParametersUseTheTapName(string name, string url, string add, string remove) + { + var manager = new Homebrew(); + var source = new ManagerSource(manager, name, new Uri(url)); + + Assert.Equal(add, string.Join(' ', manager.SourcesHelper.GetAddSourceParameters(source))); + Assert.Equal(remove, string.Join(' ', manager.SourcesHelper.GetRemoveSourceParameters(source))); + } + private static string[] ReadFixtureLines(string relativePath) { return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n');