From 375aa2f49261b4a497d1896c9bd663cf00ad564b Mon Sep 17 00:00:00 2001 From: EmiOnFlux Date: Sun, 4 Oct 2026 21:10:36 +0200 Subject: [PATCH] add portage backend (#3676) * add portage backend * portage: add excludes --- core/cmd/dms/commands_system.go | 6 +- .../server/sysupdate/backend_portage.go | 261 ++++++++++++++++++ .../server/sysupdate/backend_portage_test.go | 115 ++++++++ core/internal/server/sysupdate/targets.go | 2 +- .../server/sysupdate/upgrade_commands_test.go | 10 + .../Modules/Settings/SoftwareUpdatesTab.qml | 4 +- quickshell/Services/SystemUpdateService.qml | 44 ++- .../tests/system-update-ignore.test.mjs | 63 +++++ 8 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 core/internal/server/sysupdate/backend_portage.go create mode 100644 core/internal/server/sysupdate/backend_portage_test.go create mode 100644 quickshell/tests/system-update-ignore.test.mjs diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go index 4db0eaccd..06ea96282 100644 --- a/core/cmd/dms/commands_system.go +++ b/core/cmd/dms/commands_system.go @@ -251,16 +251,12 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package { if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 { return pkgs } - ignored := make(map[string]bool, len(sysUpdateIgnore)) - for _, name := range sysUpdateIgnore { - ignored[name] = true - } out := pkgs[:0] for _, p := range pkgs { if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR { continue } - if ignored[p.Name] { + if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) { continue } out = append(out, p) diff --git a/core/internal/server/sysupdate/backend_portage.go b/core/internal/server/sysupdate/backend_portage.go new file mode 100644 index 000000000..870e5b17f --- /dev/null +++ b/core/internal/server/sysupdate/backend_portage.go @@ -0,0 +1,261 @@ +package sysupdate + +import ( + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" +) + +func init() { + RegisterSystemBackend(func() Backend { return &portageBackend{} }) +} + +// [ebuild U ] sys-apps/util-linux-2.40.4::gentoo [2.40.2::gentoo] USE="..." +var ( + portageUpdateLine = regexp.MustCompile(`^\s*\[(ebuild|binary)\b[^\]]*\]\s+([^\s/]+)/([^\s\[\]]+?)::([^\s\[\]]+)(?:\s+\[\s*([^\s\]]+?)(?:::[^\s\]]+)?\])?`) + ansiRegexp = regexp.MustCompile(`\x1B\[[0-9;?]*[ -/]*[@-~]`) +) + +func stripANSI(s string) string { + return ansiRegexp.ReplaceAllString(s, "") +} + +type portageBackend struct{} + +func (portageBackend) ID() string { return "portage" } +func (portageBackend) DisplayName() string { return "Portage" } +func (portageBackend) Repo() RepoKind { return RepoSystem } +func (portageBackend) NeedsAuth() bool { return true } +func (portageBackend) RunsInTerminal() bool { return false } +func (portageBackend) IsAvailable(_ context.Context) bool { return commandExists("emerge") } + +func (b portageBackend) CheckUpdates(ctx context.Context) ([]Package, error) { + out, err := capturePortageUpdates(ctx) + if err != nil { + return nil, err + } + return parsePortageUpdates(out, b.ID()), nil +} + +func (b portageBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + err := b.upgrade(ctx, opts, onLine) + if err != nil && isPortageAuthCanceled(err) { + return err + } + if err != nil && !opts.DryRun && ctx.Err() == nil { + notifyPortageFailure() + + } + return err +} + +// Notify the user that portage failed so they can check the logs and fix the issue manually +func notifyPortageFailure() { + if _, err := notify.Send(notify.Notification{ + Summary: "Portage update failed", + Body: "Open Settings > Software Updates to read the error log.", + Timeout: 10000, + }); err != nil { + log.Warnf("[sysupdate] portage failure notification: %v", err) + } +} + +func isPortageAuthCanceled(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "exit status 126") +} + +func (b portageBackend) upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + if opts.DryRun { + return Run(ctx, []string{"emerge", "--pretend", "--update", "--newuse", "--deep", "@world"}, RunOptions{OnLine: onLine}) + } + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return nil + } + + if len(opts.Targets) > 0 && hasPortageTarget(opts.Targets) && !portageIsExcluded(opts) { + if err := runPortageFirst(ctx, opts, onLine); err != nil { + return err + } + } + + ansiWrap := func(s string) { + if onLine != nil { + onLine(stripANSI(s)) + } + } + return Run(ctx, portageUpgradeArgv(opts), RunOptions{OnLine: ansiWrap, AttachStdio: opts.AttachStdio}) +} + +// emerge upgrades all of @world, so ignores are the only way to hold a package back +func portageUpgradeArgv(opts UpgradeOptions) []string { + argv := []string{"emerge", "--update", "--newuse", "--deep", "--quiet"} + for _, atom := range shellSafeNames(opts.Ignored) { + argv = append(argv, "--exclude", atom) + } + argv = append(argv, "@world") + return privilegedArgv(opts, argv...) +} + +// Portage can report names without slots, so a sloted ignore only matches slotless packages when it is slot 0. +func PackageMatchesIgnore(pkgName, ignored string) bool { + pkgBase, pkgSlot, _ := strings.Cut(pkgName, ":") + ignBase, ignSlot, _ := strings.Cut(ignored, ":") + if pkgSlot != "" && ignSlot != "" { + return strings.EqualFold(pkgBase, ignBase) && pkgSlot == ignSlot + } + if ignSlot != "" && pkgSlot == "" { + return strings.EqualFold(pkgBase, ignBase) && ignSlot == "0" + } + return strings.EqualFold(pkgBase, ignBase) +} + +// IsPackageIgnored reports whether any ignore entry holds pkgName back. +func IsPackageIgnored(pkgName string, ignored []string) bool { + for _, ign := range ignored { + if PackageMatchesIgnore(pkgName, ign) { + return true + } + } + return false +} + +// An ignored portage means the user holds it back, so the pre-update step must skip it too +func portageIsExcluded(opts UpgradeOptions) bool { + for _, ign := range opts.Ignored { + if IsPackageIgnored("sys-apps/portage", []string{ign}) { + return true + } + base, _, _ := strings.Cut(ign, ":") + if isPortagePackage(base) { + return true + } + } + return false +} + +func isPortagePackage(name string) bool { + l := strings.ToLower(name) + return l == "sys-apps/portage" || l == "portage" || strings.HasSuffix(l, "/portage") +} + +func hasPortageTarget(targets []Package) bool { + for _, p := range targets { + if p.Backend == "portage" && isPortagePackage(p.Name) { + return true + } + } + return false +} + +// Before running any other system updates, portage should always be updated first +func runPortageFirst(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + argv := []string{"emerge", "--oneshot", "sys-apps/portage"} + return Run(ctx, privilegedArgv(opts, argv...), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +// Portage needs to sync the repos first before we can check for any available updates +// This requires root, there is no good way around this +// even if this causes the user to suddenly get pkexec prompts if they enable background updates +func portageSync(ctx context.Context) (string, error) { + argv := privilegedArgv(UpgradeOptions{}, "emerge", "--sync") + var out strings.Builder + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + if err != nil && isPortageAuthCanceled(err) { + return out.String(), errors.New("authentication request was dismissed or canceled") + } + if err != nil { + return out.String(), portageRunError(err, out.String()) + } + return out.String(), nil +} + +func portageRunError(err error, output string) error { + if detail := strings.TrimSpace(output); detail != "" { + return fmt.Errorf("%w: %s", err, detail) + } + return err +} + +func capturePortageUpdates(ctx context.Context) (string, error) { + if _, err := portageSync(ctx); err != nil { + return "", err + } + + cmd := exec.CommandContext(ctx, "emerge", "--pretend", "--verbose", "--update", "--newuse", "--deep", "--color=n", "@world") + cmd.Env = append(cmd.Environ(), "TERM=dumb", "LC_ALL=C") + var out, errOut strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errOut + // Any non-zero exit means @world does not resolve, and a broken world prints no update list, so it must not read as up to date. + if err := cmd.Run(); err != nil { + return "", portageRunError(err, out.String()+errOut.String()) + } + return out.String(), nil +} + +func splitPkgVersion(pkgVer string) (pkg, ver string) { + for i := 0; i < len(pkgVer)-1; i++ { + if pkgVer[i] == '-' && pkgVer[i+1] >= '0' && pkgVer[i+1] <= '9' { + return pkgVer[:i], pkgVer[i+1:] + } + } + return pkgVer, pkgVer +} + +func extractPkgAndVersion(pkgVer string) (pkg, ver string) { + pkg, ver = splitPkgVersion(pkgVer) + if pkg == pkgVer { + return "", ver + } + return pkg, ver +} + +func parsePortageUpdates(text, backendID string) []Package { + if text == "" { + return nil + } + var pkgs []Package + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = stripANSI(line) + m := portageUpdateLine.FindStringSubmatch(line) + if m == nil { + continue + } + category := m[2] + pkgBase, newVer := splitPkgVersion(m[3]) + _, oldVer := extractPkgAndVersion(m[5]) + pkgs = append(pkgs, Package{ + Name: category + "/" + pkgBase, + Repo: portageRepoLabel(m[1], m[4]), + Backend: backendID, + FromVersion: oldVer, + ToVersion: newVer, + }) + } + return pkgs +} + +// The GUI shows repo and build type in one slot, since Package has no field for it. +func portageRepoLabel(build, repo string) RepoKind { + if repo == "" { + repo = "gentoo" + } + return RepoKind(repo + " (" + build + ")") +} diff --git a/core/internal/server/sysupdate/backend_portage_test.go b/core/internal/server/sysupdate/backend_portage_test.go new file mode 100644 index 000000000..44598dc11 --- /dev/null +++ b/core/internal/server/sysupdate/backend_portage_test.go @@ -0,0 +1,115 @@ +package sysupdate + +import ( + "reflect" + "strings" + "testing" +) + +func TestPortagePretendFailureIsNotAnEmptyUpdateList(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + writeUpdateExecutable(t, dir, "pkexec", `exec "$@"`) + writeUpdateExecutable(t, dir, "emerge", `case "$1" in +--sync) exit 0 ;; +--pretend) echo '!!! The following packages are blocked by another package:' >&2; exit 1 ;; +esac`) + + out, err := capturePortageUpdates(t.Context()) + if err == nil { + t.Fatalf("capturePortageUpdates() = %q, want error", out) + } + if out != "" { + t.Errorf("capturePortageUpdates() output = %q, want empty on failure", out) + } + if !strings.Contains(err.Error(), "blocked by another package") { + t.Errorf("error lost emerge stderr: %v", err) + } +} + +func TestParsePortageUpdates(t *testing.T) { + input := `These are the packages that would be merged, in order: + +Calculating dependencies ... done! +[ebuild U ] app-text/ansifilter-2.23::gentoo [2.22::gentoo] USE="gui -verify-sig" 0 KiB +[ebuild U ~] sys-apps/kmscon-10.0.4::gentoo [10.0.3::gentoo] USE="drm fbdev gles2 pango -debug" 0 KiB +[ebuild UD~] gui-wm/umbriel-0.0.0_pre20260924::guru [0.0.0_pre20261003::guru] USE="X%* screencast -jemalloc" 0 KiB +[binary gU ] mail-client/thunderbird-153.4.0-1:0/esr::gentoo [153.3.0:0/esr::gentoo] USE="X clang pulseaudio" 0 KiB +[binary rRg ] media-video/mpv-0.41.0-r2-21:0/2::gentoo USE="X alsa cdda cli" 0 KiB +[ebuild U ] www-client/librewolf-bin-157.0_p1::librewolf [156.0.1_p1::librewolf] USE="wayland (-selinux)" 0 KiB +[ebuild U ] dev-lang/rust-1.83.0:1.83::gentoo [1.82.0:1.82::gentoo] USE="lto rustfmt -clippy" 0 KiB +[ebuild U ] sys-libs/glibc-2.41-r3:2.2::gentoo [2.41-r2:2.2::gentoo] USE="multiarch -audit" 0 KiB + +Total: 8 packages (8 upgrades), Size of downloads: 0 KiB +` + + want := []Package{ + {Name: "app-text/ansifilter", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "2.22", ToVersion: "2.23"}, + {Name: "sys-apps/kmscon", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "10.0.3", ToVersion: "10.0.4"}, + {Name: "gui-wm/umbriel", Repo: "guru (ebuild)", Backend: "portage", FromVersion: "0.0.0_pre20261003", ToVersion: "0.0.0_pre20260924"}, + {Name: "mail-client/thunderbird", Repo: "gentoo (binary)", Backend: "portage", FromVersion: "153.3.0:0/esr", ToVersion: "153.4.0-1:0/esr"}, + {Name: "media-video/mpv", Repo: "gentoo (binary)", Backend: "portage", ToVersion: "0.41.0-r2-21:0/2"}, + {Name: "www-client/librewolf-bin", Repo: "librewolf (ebuild)", Backend: "portage", FromVersion: "156.0.1_p1", ToVersion: "157.0_p1"}, + {Name: "dev-lang/rust", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "1.82.0:1.82", ToVersion: "1.83.0:1.83"}, + {Name: "sys-libs/glibc", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "2.41-r2:2.2", ToVersion: "2.41-r3:2.2"}, + } + + got := parsePortageUpdates(input, "portage") + if !reflect.DeepEqual(got, want) { + t.Errorf("parsePortageUpdates() =\n%#v\nwant\n%#v", got, want) + } +} + +func TestPortageRepoLabelDefaultsToGentoo(t *testing.T) { + if got := portageRepoLabel("ebuild", ""); got != RepoKind("gentoo (ebuild)") { + t.Errorf("portageRepoLabel(no repo) = %q, want %q", got, "gentoo (ebuild)") + } +} + +func TestPortageIsExcluded(t *testing.T) { + tests := []struct { + name string + ignored []string + excluded bool + }{ + {name: "no ignores", ignored: nil}, + {name: "portage ignored", ignored: []string{"sys-apps/portage"}, excluded: true}, + {name: "portage slot ignored", ignored: []string{"sys-apps/portage:0"}, excluded: true}, + {name: "portage nonzero slot ignored", ignored: []string{"sys-apps/portage:2"}, excluded: true}, + {name: "other package ignored", ignored: []string{"mail-client/thunderbird", "bad;name"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := portageIsExcluded(UpgradeOptions{Ignored: tt.ignored}); got != tt.excluded { + t.Fatalf("portageIsExcluded(%v) = %v, want %v", tt.ignored, got, tt.excluded) + } + }) + } +} + +func TestPackageMatchesIgnore(t *testing.T) { + tests := []struct { + pkg string + ignored string + want bool + }{ + {pkg: "gui-wm/gamescope", ignored: "gui-wm/gamescope", want: true}, + {pkg: "gui-wm/gamescope", ignored: "gui-wm/sway"}, + {pkg: "app-misc/fastfetch:0", ignored: "app-misc/fastfetch", want: true}, + {pkg: "app-misc/fastfetch:0", ignored: "app-misc/fastfetch:0", want: true}, + {pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:0"}, + {pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:2", want: true}, + {pkg: "app-misc/fastfetch:5", ignored: "app-misc/fastfetch:5", want: true}, + {pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:0"}, + {pkg: "app-misc/fastfetch:5", ignored: "app-misc/fastfetch:0"}, + {pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:5"}, + {pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:0", want: true}, + {pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:2"}, + {pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:5"}, + } + for _, tt := range tests { + if got := PackageMatchesIgnore(tt.pkg, tt.ignored); got != tt.want { + t.Errorf("PackageMatchesIgnore(%q, %q) = %v, want %v", tt.pkg, tt.ignored, got, tt.want) + } + } +} diff --git a/core/internal/server/sysupdate/targets.go b/core/internal/server/sysupdate/targets.go index ab35a28b7..7796d2b37 100644 --- a/core/internal/server/sysupdate/targets.go +++ b/core/internal/server/sysupdate/targets.go @@ -2,7 +2,7 @@ package sysupdate import "regexp" -var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`) +var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:/.-]+$`) // shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts. func shellSafeNames(names []string) []string { diff --git a/core/internal/server/sysupdate/upgrade_commands_test.go b/core/internal/server/sysupdate/upgrade_commands_test.go index 250b4821c..4437a6873 100644 --- a/core/internal/server/sysupdate/upgrade_commands_test.go +++ b/core/internal/server/sysupdate/upgrade_commands_test.go @@ -59,6 +59,16 @@ func TestUpgradeCommandBuilders(t *testing.T) { got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}), want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"}, }, + { + name: "portage full world upgrade", + got: portageUpgradeArgv(pkexecOpts), + want: []string{"pkexec", "emerge", "--update", "--newuse", "--deep", "--quiet", "@world"}, + }, + { + name: "portage with ignored packages", + got: portageUpgradeArgv(UpgradeOptions{Ignored: []string{"sys-apps/portage", "bad;name", "mail-client/thunderbird"}}), + want: []string{"pkexec", "emerge", "--update", "--newuse", "--deep", "--quiet", "--exclude", "sys-apps/portage", "--exclude", "mail-client/thunderbird", "@world"}, + }, { name: "apt without ignored uses plain upgrade", got: aptUpgradeArgv("apt-get", UpgradeOptions{}), diff --git a/quickshell/Modules/Settings/SoftwareUpdatesTab.qml b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml index b60bc4d22..c48702eab 100644 --- a/quickshell/Modules/Settings/SoftwareUpdatesTab.qml +++ b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml @@ -759,7 +759,7 @@ Item { const name = newIgnoredPackageField.value.trim(); if (name === "") return; - errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name); + errorIsInvalidName = !SystemUpdateService.isValidIgnoredName(name); if (errorIsInvalidName) { ignoredPackageError.visible = true; return; @@ -797,7 +797,7 @@ Item { SettingsRow { id: ignoredPackageError visible: false - title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.") + title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:/- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.") titleColor: Theme.error } diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index 72a3e9d1e..07cd98579 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -93,6 +93,10 @@ Singleton { return systemHoldsAllowed || pkg.repo !== "system"; } + function isValidIgnoredName(name) { + return /^[A-Za-z0-9@._+:\/-]+$/.test(name); + } + Connections { target: DMSService function onCapabilitiesReceived() { @@ -228,14 +232,50 @@ Singleton { _maybeNotify(); } - function _filterUpdates(pkgs) { + function _packageMatchesIgnore(pkgName, ignored) { + const split = (s) => { + if (!s) + return [s || "", ""]; + const idx = s.indexOf(":"); + if (idx === -1) + return [s, ""]; + return [s.substring(0, idx), s.substring(idx + 1)]; + }; + const [pkgBase, pkgSlot] = split(pkgName); + const [ignBase, ignSlot] = split(ignored); + if (pkgSlot && ignSlot) { + if (pkgBase === ignBase && pkgSlot === ignSlot) + return true; + return false; + } + if (ignSlot && !pkgSlot) { + if (pkgBase === ignBase && ignSlot === "0") + return true; + return false; + } + if (pkgBase === ignBase) + return true; + return false; + } + + function _isIgnored(pkg) { + if (!pkg || !pkg.name) + return false; const ignored = SettingsData.updaterIgnoredPackages || []; + for (let i = 0; i < ignored.length; i++) { + if (_packageMatchesIgnore(pkg.name, ignored[i])) + return true; + } + return false; + } + + function _filterUpdates(pkgs) { return (pkgs || []).filter(p => { if (!SettingsData.updaterAllowAUR && p.repo === "aur") return false; if (!canIgnorePackage(p)) return true; - return ignored.indexOf(p.name) === -1; + return !_isIgnored(p); }); } diff --git a/quickshell/tests/system-update-ignore.test.mjs b/quickshell/tests/system-update-ignore.test.mjs new file mode 100644 index 000000000..1471d5402 --- /dev/null +++ b/quickshell/tests/system-update-ignore.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("../Services/SystemUpdateService.qml", import.meta.url), "utf8"); +const extract = (name) => { + const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m")); + if (!found) + throw new Error(`${name} not found in SystemUpdateService.qml`); + return found[0]; +}; + +const scope = vm.createContext({ + SettingsData: { updaterIgnoredPackages: [], updaterAllowAUR: true }, + pkgManager: "portage", + systemHoldsAllowed: true, +}); +for (const fn of ["isValidIgnoredName", "_packageMatchesIgnore", "_isIgnored", "canIgnorePackage", "_filterUpdates"]) + vm.runInContext(extract(fn), scope); + +test("portage atoms and other package names can be ignored", () => { + for (const name of ["sys-apps/portage", "mail-client/thunderbird:0", "docker", "org.mozilla.firefox", "bash.x86_64", "gtk+"]) + assert.equal(scope.isValidIgnoredName(name), true, name); +}); + +test("names with spaces or shell punctuation are rejected", () => { + for (const name of ["", "sys apps", "a;b", "$(reboot)", "sys-apps/portage;reboot", "sys-apps/*"]) + assert.equal(scope.isValidIgnoredName(name), false, name); +}); + +test("ignores match on base name when the ignore carries no slot", () => { + const cases = [ + ["gui-wm/gamescope", "gui-wm/gamescope", true], + ["app-misc/fastfetch:0", "app-misc/fastfetch", true], + ["mail-client/thunderbird:0", "mail-client/thunderbird", true], + ["gui-wm/gamescope", "gui-wm/sway", false], + ]; + for (const [pkg, ignored, want] of cases) + assert.equal(scope._packageMatchesIgnore(pkg, ignored), want, `${pkg} vs ${ignored}`); +}); + +test("a slot in the ignore only matches that slot, or slot 0 when the package has none", () => { + const cases = [ + ["app-misc/fastfetch:0", "app-misc/fastfetch:0", true], + ["app-misc/fastfetch:2", "app-misc/fastfetch:2", true], + ["app-misc/fastfetch:2", "app-misc/fastfetch:0", false], + ["app-misc/fastfetch", "app-misc/fastfetch:0", true], + ["app-misc/fastfetch", "app-misc/fastfetch:2", false], + ]; + for (const [pkg, ignored, want] of cases) + assert.equal(scope._packageMatchesIgnore(pkg, ignored), want, `${pkg} vs ${ignored}`); +}); + +test("portage ignores drop the update from the list", () => { + scope.SettingsData.updaterIgnoredPackages = ["gui-wm/gamescope"]; + const pkgs = [ + { name: "gui-wm/gamescope", repo: "gentoo (ebuild)", backend: "portage" }, + { name: "mail-client/thunderbird", repo: "gentoo (ebuild)", backend: "portage" }, + ]; + const kept = scope._filterUpdates(pkgs); + assert.deepEqual(kept.map(p => p.name), ["mail-client/thunderbird"]); +});