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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ <h2>Beyond wrapping the package managers</h2>
<div class="feature-window">
<div class="term-titlebar"><span>Upgrade conflicts</span><span class="chip"><span class="k">X</span></span></div>
<div class="feature-window-body">
<p>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.</p>
<p>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.</p>
</div>
</div>

Expand Down Expand Up @@ -649,7 +649,7 @@ <h2>Every key, one dashboard</h2>
<tr><td><kbd>O</kbd></td><td>Backend overlap: packages installed more than once, stale snaps</td></tr>
<tr><td><kbd>V</kbd></td><td>Install a specific version / downgrade (apt); revert (snap)</td></tr>
<tr><td><kbd>M</kbd></td><td>Metrics dashboard: installed packages ranked by disk usage</td></tr>
<tr><td><kbd>X</kbd></td><td>Upgrade conflicts: packages a plain upgrade would keep back (apt)</td></tr>
<tr><td><kbd>X</kbd></td><td>Upgrade conflicts: packages a plain upgrade would keep back, or Ubuntu's phased rollout is holding back (apt)</td></tr>
<tr><td><kbd>L</kbd></td><td>Action log: what's run this session, and whether it succeeded</td></tr>
<tr><td><kbd>y</kbd> / <kbd>n</kbd></td><td>Confirm / cancel a pending action</td></tr>
<tr><td><kbd>esc</kbd></td><td>Go back</td></tr>
Expand Down
56 changes: 40 additions & 16 deletions internal/apt/apt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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{}
Expand All @@ -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
}

Expand Down
23 changes: 23 additions & 0 deletions internal/apt/apt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading