From 57a3dc97d67cb57c97080b7a18da32aa641c6ca9 Mon Sep 17 00:00:00 2001 From: Bryan Joshua Pedini Date: Mon, 2 Nov 2026 20:25:38 +0100 Subject: [PATCH] feat: purge selected packages in one transaction --- README.md | 2 + skeptical-updater | 179 ++++++++++++++++++++++----------- tests/unit/backends.bats | 10 ++ tests/unit/fakes/pacman/pacman | 19 ++-- tests/unit/interactive.bats | 79 +++++++++++++++ 5 files changed, 222 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 2e5bfdd..a0b88c8 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ other command suffixes. | end of input | abort without upgrading (exit 2) | Several tokens may share a line (`1? 3- 4+`); they run left to right. Consecutive +`Nx` commands such as `5x 30x 41x` are simulated and confirmed together. Another +command ends the group; a dependent can be included with its dependency. Consecutive `N+` commands are processed together: selected packages required by another selected package are recorded as dependencies, regardless of their order. `*+` works the same way. With `--all` diff --git a/skeptical-updater b/skeptical-updater index 571e766..f176b95 100755 --- a/skeptical-updater +++ b/skeptical-updater @@ -6,7 +6,7 @@ # and zypper: list what would be upgraded, interrogate every package, remember the # ones you trust, then run the native upgrade (with per-run exclusions). # -# Version: 0.3.1 +# Version: 0.4.0 # # Specification: docs/DESIGN.md. Single file, bash >= 4.3, no runtime dependency # beyond the distro's package manager and coreutils/sed/grep (awk only on the apt @@ -23,7 +23,7 @@ export LC_ALL=C DEBIAN_FRONTEND=noninteractive # never read from or written to a file (no HISTFILE; `history -r`/`-w`/`-a` are never used). unset HISTFILE -readonly SU_VERSION=0.3.1 +readonly SU_VERSION=0.4.0 readonly SU_PROG=skeptical-updater readonly SU_STATE_HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)' readonly SU_PROMPT_HELP=' upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever' @@ -118,6 +118,7 @@ interactive commands (one line, whitespace-separated tokens, N = number in brack N- exclude from this run (toggle); shown with a '-' marker Nx purge it now with its now-unneeded dependencies (shown, asks y/N); refused when other installed packages depend on it, like pacman -Rns + consecutive Nx commands are purged together after one confirmation N+ trust it forever (with its installed dependency closure) *+ / *- apply + / - to every listed package l redraw the list @@ -144,6 +145,7 @@ session_help() { Nx purge N now: lists every package the purge removes, then asks y/N. x refuses when other installed packages depend on the target, like pacman -Rns; purge the dependents first + consecutive Nx commands are purged together after one confirmation N+ trust N (and its installed dependencies) forever *+ *- trust / exclude every listed package (*- again re-includes them) l redraw the list (a '*' after the number marks an AUR package) @@ -972,20 +974,21 @@ apt_closure() { # is system-wide (it also takes pre-existing orphans, e.g. old kernels: verified), so the # orphans that were already autoremovable before are subtracted and the rest named explicitly. apt_purge_plan() { - local pkg=$1 pre sim w name rest - local -A before=() - local -a args=("$pkg") + local pre sim w name rest + local -A before=() requested=() + local -a args=("$@") + for name in "$@"; do requested[$name]=1; done pre=$(apt-get -s autoremove /dev/null) || pre='' while read -r w name rest; do [[ $w == Remv || $w == Purg ]] && before[$name]=1 done <<<"$pre" - sim=$(apt-get -s purge --auto-remove "$pkg" &1) || { + sim=$(apt-get -s purge --auto-remove "$@" &1) || { printf '%s\n' "$sim" >&2 return 1 } while read -r w name rest; do [[ $w == Remv || $w == Purg ]] || continue - [[ -z ${before[$name]+x} && $name != "$pkg" ]] && args+=("$name") + [[ -z ${before[$name]+x} && -z ${requested[$name]+x} ]] && args+=("$name") done <<<"$sim" sim=$(apt-get -s purge "${args[@]}" &1) || { printf '%s\n' "$sim" >&2 @@ -1507,7 +1510,7 @@ pacman_closure() { # On refusal pacman explains on stdout (":: removing X breaks dependency ..."): show all of it. pacman_purge_plan() { local out rc=0 l - out=$(COLUMNS=0 pacman -Rs --print --print-format '%n' -- "$1" &1) || rc=$? + out=$(COLUMNS=0 pacman -Rs --print --print-format '%n' -- "$@" &1) || rc=$? if ((rc)); then printf '%s\n' "$out" >&2 return "$rc" @@ -1908,7 +1911,7 @@ dnf_purge_plan() { local out line in=0 wrapped=0 local -a f=() local arch_re='^(noarch|x86_64|x86_64_v[0-9]|aarch64|i[3-6]86|ppc64le|ppc64|s390x|armv7hl|armv7l|riscv64|loongarch64|src)$' - out=$("$DNF" --assumeno remove "$1" &1) || true + out=$("$DNF" --assumeno remove "$@" &1) || true if [[ $out != *'Transaction Summary'* ]]; then if [[ $out == *'Nothing to do'* ]]; then return 0; fi printf '%s\n' "$out" >&2 @@ -2091,7 +2094,7 @@ zypper_reason() { } zypper_purge_plan() { local xml rc=0 sec name rest - xml=$(zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps "$1" &1) || rc=$? + xml=$(zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps "$@" &1) || rc=$? if ((rc)); then [[ $rc != 104 ]] || say "zypper: no installed package provides '$1' (exit 104)" >&2 ((rc == 104)) || printf '%s\n' "$xml" >&2 @@ -2631,66 +2634,103 @@ num_row() { # num_row N -> $REPLY = row index of a listed row, rc 1 if N is not REPLY=${N_IDX[n]} } -# session_purge IDX: pacman's -Rns principle on every backend - a package that other installed +# session_purge IDX...: pacman's -Rns principle on every backend - a package that other installed # packages still depend on is never purged (they are listed, as N? does; nothing cascades); # otherwise the package goes with the dependencies it alone needed, as the y/N prompt lists. session_purge() { - local i=$1 name=${R_NAME[$1]} rc=0 out line p prompt rd d - local -a plan=() others=() extra=() - local -A still=() gone=() mine=() - if [[ ${R_OLD[i]} == - ]]; then - say "$name is not installed (the upgrade would install it as a new dependency): nothing to purge; use $((R_NUM[i]))- to exclude it from this run." - return 0 - fi - rd=$(be rdepends "$name") || rd='' - rd=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ') - rd=${rd% } - if [[ -n $rd ]]; then - say "not purged: $name is required by: ${rd// /, } - purge the dependents first" - return 0 - fi - out=$(be purge_plan "$name" | sort -u) || rc=$? + local i name rd out rc=0 p prompt idx dep changed + local -a targets=() target_names=() total_plan=() total_others=() extra=() rdeps=() + local -A purge_selected=() rejected=() reverse=() gone=() allowed=() still=() + local line + + for idx in "$@"; do purge_selected[${R_NAME[idx]}]=$idx; done + mapfile -t targets < <(printf '%s\n' "${purge_selected[@]}" | sort -n) + for i in "${targets[@]}"; do + name=${R_NAME[i]} + if [[ ${R_OLD[i]} == - ]]; then + say "$name is not installed (the upgrade would install it as a new dependency): nothing to purge; use $((R_NUM[i]))- to exclude it from this run." + rejected[$name]=1 + continue + fi + rd=$(be rdepends "$name") || rd='' + reverse[$name]=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ') + done + # A refused dependent also blocks its dependencies, including through a chain. + changed=1 + while ((changed)); do + changed=0 + for i in "${targets[@]}"; do + name=${R_NAME[i]} + [[ -z ${rejected[$name]+x} ]] || continue + read -r -a rdeps <<<"${reverse[$name]}" + for dep in "${rdeps[@]}"; do + if [[ -z ${purge_selected[$dep]+x} || -n ${rejected[$dep]+x} ]]; then + rd=${reverse[$name]% } + say "not purged: $name is required by: ${rd// /, } - purge the dependents first" + rejected[$name]=1 + changed=1 + break + fi + done + done + done + for i in "${targets[@]}"; do + name=${R_NAME[i]} + [[ -z ${rejected[$name]+x} ]] || continue + target_names+=("$name") + allowed[$name]=1 + closure_of "$name" + for dep in ${CLOSURE[@]+"${CLOSURE[@]}"}; do allowed[$dep]=1; done + done + ((${#target_names[@]})) || return 0 + out=$(be purge_plan "${target_names[@]}" | sort -u) || rc=$? if ((rc)); then - err "cannot purge $name: $(be label) refuses (see above); nothing was removed" + err "cannot purge ${target_names[*]}: $(be label) refuses (see above); nothing was removed" return 0 fi while IFS= read -r p; do - [[ -n $p && -z ${gone[$p]+x} ]] || continue + [[ -n $p ]] || continue + total_plan+=("$p") gone[$p]=1 - plan+=("$p") - [[ $p == "$name" ]] || others+=("$p") + [[ -n ${purge_selected[$p]+x} && -z ${rejected[$p]+x} ]] || total_others+=("$p") + [[ -n ${allowed[$p]+x} && -z ${rejected[$p]+x} ]] || extra+=("$p") done <<<"$out" - if ((${#plan[@]} == 0)); then - say "$name is not installed: nothing to purge." + if ((${#total_plan[@]} == 0)); then + say "${target_names[*]} is not installed: nothing to purge." return 0 fi - # belt and braces: the plan may only hold the target and its own (installed) dependencies; - # anything else means a dependent the reverse-dependency query missed (e.g. through a virtual) - if ((${#others[@]})); then - closure_of "$name" - for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do mine[$d]=1; done - for p in "${others[@]}"; do [[ -n ${mine[$p]+x} ]] || extra+=("$p"); done - if ((${#extra[@]})); then - err "cannot purge $name: $(be label) would also remove $(join_by ', ' "${extra[@]}"), which $name does not depend on (dependents of it?); nothing was removed" + for name in "${target_names[@]}"; do + if [[ -z ${gone[$name]+x} ]]; then + err "cannot purge $name: $(be label) did not include it in the plan; nothing was removed" return 0 fi + done + # belt and braces: no package outside the targets and their dependencies may be removed. + if ((${#extra[@]})); then + err "cannot purge ${target_names[*]}: $(be label) would also remove $(join_by ', ' "${extra[@]}"), which ${target_names[*]} does not depend on (dependents of it?); nothing was removed" + return 0 fi - prompt="Purge $name now?" - ((${#others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${others[@]}")" + prompt="Purge ${target_names[*]} now?" + ((${#total_others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${total_others[@]}")" read_line "$prompt [y/N] " case $REPLY in y | Y | yes | YES | Yes) ;; *) - say "not purged: $name" + say "not purged: ${target_names[*]}" return 0 ;; esac - be purge "${plan[@]}" || rc=$? + rc=0 + be purge "${total_plan[@]}" || rc=$? if ((rc)); then - err "purge of $name failed ($(be label) exited $rc); the row stays in the list" + if ((${#target_names[@]} == 1)); then + err "purge of ${target_names[0]} failed ($(be label) exited $rc); the row stays in the list" + else + err "purge of ${target_names[*]} failed ($(be label) exited $rc); the rows stay in the list" + fi return 0 fi - say "purged: ${plan[*]}" + say "purged: ${total_plan[*]}" for ((i = 0; i < ${#R_NAME[@]}; i++)); do if [[ -n ${gone[${R_NAME[i]}]+x} ]]; then R_GONE[i]=1 @@ -2768,9 +2808,21 @@ session_toggle_all() { SESSION_PROCEED=0 UPGRADE_FAILED_MSG= # set by _upgrade when the failing step is not the package manager's own +session_flush_purges() { + local -n pb=$1 + if ((${#pb[@]})); then + session_purge "${pb[@]}" + pb=() + if (($(rows_left) == 0 && ${#R_NOTES[@]} == 0)); then + say "Nothing left to upgrade." + exit 0 + fi + fi +} + session_line() { local tok n op i name - local -a toks=() batch=() + local -a toks=() batch=() purge_batch=() local -A initial=() named=() local re='^([0-9]{1,6})(\?\?|\?|-|x|\+)$' local name_re='^([A-Za-z0-9_+@][A-Za-z0-9_+.@:~-]*)(\?\?|\?)$' @@ -2797,6 +2849,24 @@ session_line() { fi done for tok in "${toks[@]}"; do + if [[ $tok =~ $re ]]; then + op=${BASH_REMATCH[2]} + if [[ $op == x ]]; then + if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi + elif [[ $op == + ]]; then + session_flush_purges purge_batch + else + if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi + session_flush_purges purge_batch + fi + elif [[ $tok =~ $name_re ]]; then + if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi + session_flush_purges purge_batch + else + if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi + session_flush_purges purge_batch + fi + if [[ $tok =~ $re ]]; then n=$((10#${BASH_REMATCH[1]})) op=${BASH_REMATCH[2]} @@ -2809,7 +2879,10 @@ session_line() { batch+=("$i") continue fi - if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi + if [[ $op == x ]]; then + purge_batch+=("$i") + continue + fi case $op in '?') print_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;; '??') print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;; @@ -2822,13 +2895,6 @@ session_line() { say "excluded from this run: ${R_NAME[i]}" fi ;; - x) - session_purge "$i" - if (($(rows_left) == 0 && ${#R_NOTES[@]} == 0)); then - say "Nothing left to upgrade." - exit 0 - fi - ;; esac elif [[ $tok =~ $name_re ]]; then name=${BASH_REMATCH[1]} @@ -2838,14 +2904,12 @@ session_line() { continue fi i=${named[$name]} - if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi if [[ $op == '?' ]]; then print_info "${R_NAME[i]}" "${R_OLD[i]}" || true else print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true fi else - if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi case $tok in '*+') local -a idx=() @@ -2865,6 +2929,7 @@ session_line() { fi done if ((${#batch[@]})); then session_trust "${batch[@]}"; fi + session_flush_purges purge_batch } cmd_interactive() { diff --git a/tests/unit/backends.bats b/tests/unit/backends.bats index d9159af..5180608 100644 --- a/tests/unit/backends.bats +++ b/tests/unit/backends.bats @@ -785,3 +785,13 @@ zypp_conf() { # a "real" zypp.conf + locks file with one user lock, in the test refute_output --partial 'skeptical-updater.' [[ ! -e $STATE/zypper-private ]] } + +@test "pacman: dependent and dependency are simulated and purged together" { + use_fakes pacman + run skup --no-refresh <<<$'2x 1x\ny\nq' + assert_success + assert_line 'Purge su-dummy-a su-dummy-b now? [y/N] y' + assert_line 'purged: su-dummy-a su-dummy-b' + log_has 'pacman -Rs --print --print-format %n -- su-dummy-a su-dummy-b' + log_has 'pacman -Rns --noconfirm -- su-dummy-a su-dummy-b' +} diff --git a/tests/unit/fakes/pacman/pacman b/tests/unit/fakes/pacman/pacman index aeab990..8884d45 100755 --- a/tests/unit/fakes/pacman/pacman +++ b/tests/unit/fakes/pacman/pacman @@ -137,21 +137,20 @@ case $op in -Rs) ((print)) || exit 98 tg=${targets[0]} - if [[ -n ${FAKE_PACMAN_RS_REFUSE:-} || $tg == su-dummy-b ]]; then + if [[ -n ${FAKE_PACMAN_RS_REFUSE:-} || " ${targets[*]} " == *' su-dummy-b '* && " ${targets[*]} " != *' su-dummy-a '* ]]; then echo "checking dependencies..." echo ":: removing $tg breaks dependency '$tg' required by su-dummy-a" echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2 exit 1 fi - case $tg in - su-dummy-a) printf '%s\n' su-dummy-a su-dummy-b ;; # b was installed as a dependency of a - su-dummy-c | glib2 | expat | ca-certificates-mozilla | pfetch | su-dummy-aur) echo "$tg" ;; # (the last two: foreign, AUR) - *) - echo "error: target not found: $tg" >&2 - exit 1 - ;; - esac - for r in ${FAKE_PACMAN_RS_EXTRA:-}; do [[ ${r%%:*} == "$tg" ]] && echo "${r#*:}"; done + for tg in "${targets[@]}"; do + case $tg in + su-dummy-a) printf '%s\n' su-dummy-a su-dummy-b ;; + su-dummy-b | su-dummy-c | glib2 | expat | ca-certificates-mozilla | pfetch | su-dummy-aur) echo "$tg" ;; + *) echo "error: target not found: $tg" >&2; exit 1 ;; + esac + for r in ${FAKE_PACMAN_RS_EXTRA:-}; do [[ ${r%%:*} == "$tg" ]] && echo "${r#*:}"; done + done ;; -Rns) rc=$(fake_rc FAKE_PACMAN_RNS_RC 0) diff --git a/tests/unit/interactive.bats b/tests/unit/interactive.bats index 1654ac1..fb63b6c 100644 --- a/tests/unit/interactive.bats +++ b/tests/unit/interactive.bats @@ -457,3 +457,82 @@ UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--forc assert_line ' [3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]' assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests' } + +@test "Nx: consecutive purges use one transaction including orphans" { + FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x 3x\ny\nq' + assert_success + assert_line 'Purge su-dummy-a su-dummy-c now? This also removes: su-dummy-b [y/N] y' + assert_line 'purged: su-dummy-a su-dummy-b su-dummy-c' + assert_line 'Nothing left to upgrade.' + log_has 'apt-get -y purge su-dummy-a su-dummy-b su-dummy-c' +} + +@test "Nx: other commands flush the batch before resolving removed rows" { + FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x 2? 3x\ny\ny\nq' + assert_success + assert_line 'purged: su-dummy-a su-dummy-b' + assert_output --partial 'no package [2] in the list' + assert_line 'Purge su-dummy-c now? [y/N] y' + assert_line 'purged: su-dummy-c' +} + +@test "Nx: a trust command cannot act on a removed row" { + FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x 2+\ny\nq' + assert_success + assert_output --partial 'no package [2] in the list' + assert_line 'purged: su-dummy-a su-dummy-b' +} + +@test "Nx: duplicate targets are purged once" { + run skup --no-refresh <<<$'3x 3x\ny\nq' + assert_success + assert_line 'Purge su-dummy-c now? [y/N] y' + assert_line 'purged: su-dummy-c' + log_has 'apt-get -y purge su-dummy-c' +} + +@test "Nx: a refused target does not prevent other purges" { + run skup --no-refresh <<<$'2x 3x\ny\nl\nq' + assert_success + assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first' + assert_line 'purged: su-dummy-c' + log_has 'apt-get -y purge su-dummy-c' +} + +@test "Nx: invalid numbers do not interrupt a batch" { + FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x 9x 3x\ny\nq' + assert_success + assert_output --partial 'no package [9] in the list' + assert_line 'purged: su-dummy-a su-dummy-b su-dummy-c' + log_has 'apt-get -y purge su-dummy-a su-dummy-b su-dummy-c' +} + +@test "Nx: rejecting the prompt purges nothing" { + run skup --no-refresh <<<$'1x 3x\nn\nl\nq' + assert_success + assert_line 'not purged: su-dummy-a su-dummy-c' + assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:' + log_lacks '^apt-get -y purge' +} + +@test "Nx: failed transaction keeps all rows" { + FAKE_APT_PURGE_RC=100 run skup --no-refresh <<<$'1x 3x\nyes\nl\nq' + assert_success + assert_output --partial 'purge of su-dummy-a su-dummy-c failed (apt exited 100); the rows stay in the list' + assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:' +} + +@test "Nx: a dependent and dependency are planned together" { + FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'2x 1x\ny\nq' + assert_success + assert_line 'Purge su-dummy-a su-dummy-b now? [y/N] y' + assert_line 'purged: su-dummy-a su-dummy-b' + log_has 'apt-get -y purge su-dummy-a su-dummy-b' +} + +@test "Nx: unexpected removals never leak into another target's purge" { + FAKE_APT_SIM_EXTRA=su-dummy-a:su-dummy-c run skup --no-refresh <<<$'1x 2x\ny\nq' + assert_success + assert_output --partial 'would also remove su-dummy-c' + log_lacks '^apt-get -y purge' +}