diff --git a/CHANGELOG.md b/CHANGELOG.md index d775df0..69b9518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ follow [SemVer](https://semver.org/). cask also clears the Gatekeeper quarantine attribute, which these un-notarized binaries would otherwise trip over on first run. +### Fixed + +- **Upgrade conflicts (`X`, apt)** now also reports packages held back by + Ubuntu's phased-update rollout, not just dependency-based holdbacks: + these showed up as plain "upgradable" with no indication that "upgrade + all" (`U`, dist-upgrade) won't actually touch them until the rollout + reaches this machine (see + [#9](https://github.com/padovanl/pkgtui/issues/9)). + ### Changed - The `P` screen manages **third-party sources generally**, not just apt diff --git a/README.md b/README.md index 1f2c3fa..e098b2e 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,10 @@ own: something else first — distinct from an explicit hold, which already has its own `◆` marker. "Upgrade all" (`U`) already resolves most of these on its own (it uses `dist-upgrade`); this is what tells you - *which* ones and *why*. + *which* ones and *why*. Also surfaces packages Ubuntu's phased update + rollout is holding back from this machine, which show up as plainly + "upgradable" everywhere else but that neither a plain upgrade nor + `dist-upgrade` will actually touch until the rollout reaches you. - **Action log (`L`)**: every privileged action run this session, all backends together, with a timestamp and success/failure — a running record of what you actually did, without digging through shell @@ -265,7 +268,7 @@ pkgtui | `O` | Backend overlap view: packages installed more than once, stale snaps | | `V` | Install a specific version of the selected package / downgrade (apt); revert to the previous revision (snap) | | `M` | Metrics dashboard: installed packages ranked by disk usage | -| `X` | Upgrade conflicts: packages a plain upgrade would keep back (apt) | +| `X` | Upgrade conflicts: packages a plain upgrade would keep back, or Ubuntu's phased rollout is holding back (apt) | | `L` | Action log: what's run this session, and whether it succeeded | | `y` / `n` | Confirm / cancel an action | | `esc` | Go back | diff --git a/docs/index.html b/docs/index.html index 57c031e..62603ae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -544,7 +544,7 @@

Beyond wrapping the package managers

Upgrade conflictsX
-

Packages a conservative apt-get upgrade would leave behind because it needs to install or remove something else first — distinct from an explicit hold. "Upgrade all" already resolves most of these; this shows which, and why.

+

Packages a conservative apt-get upgrade would leave behind because it needs to install or remove something else first — distinct from an explicit hold. "Upgrade all" already resolves most of these; this shows which, and why. Also catches packages Ubuntu's phased update rollout is holding back, which neither a plain upgrade nor "Upgrade all" will touch until the rollout reaches this machine.

@@ -649,7 +649,7 @@

Every key, one dashboard

OBackend overlap: packages installed more than once, stale snaps VInstall a specific version / downgrade (apt); revert (snap) MMetrics dashboard: installed packages ranked by disk usage - XUpgrade conflicts: packages a plain upgrade would keep back (apt) + XUpgrade conflicts: packages a plain upgrade would keep back, or Ubuntu's phased rollout is holding back (apt) LAction log: what's run this session, and whether it succeeded y / nConfirm / cancel a pending action escGo back diff --git a/internal/apt/apt.go b/internal/apt/apt.go index f366281..c1a9144 100644 --- a/internal/apt/apt.go +++ b/internal/apt/apt.go @@ -287,14 +287,11 @@ func (m *Manager) InstallVersionCmd(name, version string) []string { return pkg.MaybeSudo([]string{"apt-get", "install", "-y", name + "=" + version}) } -// parseKeptBackOutput extracts package names from "apt-get upgrade -s"'s -// "The following packages have been kept back:" section: packages with an -// upgrade available that a conservative upgrade won't perform because it -// would need to install or remove something else first. The section is a -// run of " "-indented lines (names wrapped across several lines for a -// long list), terminated by the first line that isn't indented that way. -func parseKeptBackOutput(out string) []string { - const marker = "The following packages have been kept back:" +// parseNamesUnderMarker extracts the names listed under a section header +// line starting with marker, as "apt-get upgrade -s" prints them: a run of +// " "-indented lines (names wrapped across several lines for a long +// list), terminated by the first line that isn't indented that way. +func parseNamesUnderMarker(out, marker string) []string { var names []string inSection := false for _, line := range strings.Split(out, "\n") { @@ -313,17 +310,41 @@ func parseKeptBackOutput(out string) []string { return names } -// UpgradeConflicts reports packages a conservative "apt-get upgrade" would -// leave behind (as opposed to "upgrade all" in this UI, which already uses -// dist-upgrade and resolves most of these on its own — see UpgradeCmd). -// Implements pkg.ConflictReporter. +// parseKeptBackOutput extracts package names from "apt-get upgrade -s"'s +// "The following packages have been kept back:" section: packages with an +// upgrade available that a conservative upgrade won't perform because it +// would need to install or remove something else first. +func parseKeptBackOutput(out string) []string { + return parseNamesUnderMarker(out, "The following packages have been kept back:") +} + +// parsePhasedOutput extracts package names from "apt-get upgrade -s"'s +// "The following upgrades have been deferred due to phasing:" section +// (apt >= 21.04, ubiquitous since Ubuntu 24.04): packages held back by +// Ubuntu's gradual update rollout rather than a dependency conflict. This +// is orthogonal to dist-upgrade vs. plain upgrade — "upgrade all" (which +// uses dist-upgrade, see UpgradeCmd) does not bypass phasing either, so +// these would otherwise show as plainly "upgradable" (apt list +// --upgradable lists them regardless of phasing) without ever actually +// being picked up by "upgrade all". +func parsePhasedOutput(out string) []string { + return parseNamesUnderMarker(out, "The following upgrades have been deferred due to phasing:") +} + +// UpgradeConflicts reports packages "upgrade all" (dist-upgrade) won't +// actually touch despite showing up as upgradable elsewhere: a +// conservative "apt-get upgrade" simulation surfaces both dependency-based +// holdbacks (which dist-upgrade resolves on its own — see UpgradeCmd) and +// phased-rollout holdbacks (which it doesn't). Implements +// pkg.ConflictReporter. func (m *Manager) UpgradeConflicts() ([]pkg.UpgradeConflict, error) { out, err := exec.Command("apt-get", "upgrade", "-s").Output() if err != nil { return nil, fmt.Errorf("apt-get upgrade -s: %w", err) } - names := parseKeptBackOutput(string(out)) - if len(names) == 0 { + keptBack := parseKeptBackOutput(string(out)) + phased := parsePhasedOutput(string(out)) + if len(keptBack) == 0 && len(phased) == 0 { return nil, nil } held := map[string]bool{} @@ -332,14 +353,17 @@ func (m *Manager) UpgradeConflicts() ([]pkg.UpgradeConflict, error) { held[name] = e.Held } } - items := make([]pkg.UpgradeConflict, 0, len(names)) - for _, name := range names { + items := make([]pkg.UpgradeConflict, 0, len(keptBack)+len(phased)) + for _, name := range keptBack { reason := "needs a dependency change (install/remove something else) a plain upgrade won't perform on its own" if held[name] { reason = "held (apt-mark hold)" } items = append(items, pkg.UpgradeConflict{Name: name, Reason: reason}) } + for _, name := range phased { + items = append(items, pkg.UpgradeConflict{Name: name, Reason: "held back by Ubuntu's phased rollout, not yet offered to this machine (upgrading it by name bypasses phasing)"}) + } return items, nil } diff --git a/internal/apt/apt_test.go b/internal/apt/apt_test.go index 468baa2..0c20b53 100644 --- a/internal/apt/apt_test.go +++ b/internal/apt/apt_test.go @@ -301,6 +301,29 @@ func TestParseKeptBackOutputNothingKeptBack(t *testing.T) { } } +func TestParsePhasedOutput(t *testing.T) { + out := `Reading package lists... +Building dependency tree... +Reading state information... +Calculating upgrade... +The following upgrades have been deferred due to phasing: + libglapi-mesa libgl1-mesa-dri +0 upgraded, 0 newly installed, 0 to remove and 2 not upgraded. +` + got := parsePhasedOutput(out) + want := []string{"libglapi-mesa", "libgl1-mesa-dri"} + if !reflect.DeepEqual(got, want) { + t.Errorf("parsePhasedOutput() = %v, want %v", got, want) + } +} + +func TestParsePhasedOutputNothingPhased(t *testing.T) { + out := "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n" + if got := parsePhasedOutput(out); len(got) != 0 { + t.Errorf("parsePhasedOutput() = %v, want empty", got) + } +} + func TestParseUnattendedUpgradesLogNoMatches(t *testing.T) { ts, pkgs := parseUnattendedUpgradesLog("nothing relevant here\n") if ts != "" || pkgs != nil {