4 Commits
7 changed files with 386 additions and 85 deletions

No files matched your search

+14 -4
View File
@@ -59,7 +59,7 @@ options:
none (default: paru, else yay, when installed; other backends ignore it)
--no-aur same as --aur-helper=none
-h, --help this help
-V, --version print the version
-v, --version print the version
```
Exit status: `0` ok, `1` usage or backend error, `2` aborted (end of input),
@@ -130,8 +130,13 @@ package (pacman backend with paru or yay, see below) and a space on every other
One line, whitespace-separated tokens; `N` is the number in brackets. Numbers are
stable for the whole session (rows that leave the list leave a gap). Every token
of a line is validated first: a line with an unknown token or a number that is not
listed prints an error, runs nothing, and the prompt comes back.
of a line is checked for syntax first: an unknown token rejects the line. A number
or package name that is not listed prints an error and the remaining commands still run.
For `?` and `??`, a listed package name can replace the number, for example
`su-dummy-a?` or `su-dummy-a??`. A digits-only reference is interpreted as a number.
Names are restricted to these information commands to avoid unnecessary parsing
complexity: package names may contain `-` or end in `x`, which conflicts with the
other command suffixes.
| token | action |
|-------|--------|
@@ -147,7 +152,12 @@ listed prints an error, runs nothing, and the prompt comes back.
| `h`, `?` | help |
| end of input | abort without upgrading (exit 2) |
Several tokens may share a line (`1? 3- 4+`); they run left to right. With `--all`
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`
trusted packages are listed too, marked with `=`. When purges leave nothing to upgrade the
session ends by itself (exit 0).
+10 -2
View File
@@ -125,6 +125,14 @@ Refreshing package lists... done.
```
Command grammar (one line, whitespace-separated tokens, each `N` is the number in brackets):
Unknown tokens reject the line; nonexistent numbers report an error and are skipped.
For `?` and `??`, the exact name of a listed package may replace `N`; a digits-only
reference is treated as a number. Missing or no longer listed names are reported and
skipped. Other commands accept only numbers: package names can contain `-` or end in
`x`, and limiting name references to information commands avoids unnecessary parsing
complexity.
Consecutive `N+` commands and `*+` trust the selected dependency roots, recording
other selected packages in their closures as dependencies regardless of input order.
| token | action |
|--------|---------------------------------------------------------------------------|
@@ -153,8 +161,8 @@ On AUR rows `N?`, `N+` and `Nx` act through pacman like on any installed package
`pacman -Qi` with `<helper> -Sia PKG`; `N-` excludes the row through the helper's `--ignore`.
Numbers are stable for the whole session (removed rows leave a gap, never renumber).
All tokens of a line are validated first: an unknown token or a number that is not listed
prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts without
Syntax is validated first; an unknown token rejects the line. Numbers that are not listed
print an error and are skipped while the remaining commands run. EOF on stdin aborts without
upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty
line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0).
+173 -66
View File
@@ -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.1.0
# 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.1.0
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='<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever'
@@ -105,7 +105,7 @@ options:
none (default: paru, else yay, when installed; other backends ignore it)
--no-aur same as --aur-helper=none
-h, --help this help
-V, --version print the version
-v, --version print the version
interactive commands (one line, whitespace-separated tokens, N = number in brackets):
<empty line> proceed: run the native upgrade with the current exclusions
@@ -114,9 +114,11 @@ interactive commands (one line, whitespace-separated tokens, N = number in brack
that depend on it - installed packages only
N?? full native info: the installed record; lines starting with '*' come
from the upstream candidate (differing values, fields it adds)
For N? and N??, a listed package name may be used instead of N.
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
@@ -138,10 +140,12 @@ session_help() {
<enter> upgrade now, skipping the rows marked '-'
N? what is it: description, install reason + installed packages that need it
N?? full native info: installed record, '*' lines from the upstream candidate
A listed package name may replace N for ? and ?? (e.g. okular?).
N- exclude N from this run (toggle)
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)
@@ -197,7 +201,7 @@ parse_args() {
usage
exit 0
;;
-V | --version)
-v | --version)
printf '%s %s\n' "$SU_PROG" "$SU_VERSION"
exit 0
;;
@@ -970,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 2>/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" </dev/null 2>&1) || {
sim=$(apt-get -s purge --auto-remove "$@" </dev/null 2>&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[@]}" </dev/null 2>&1) || {
printf '%s\n' "$sim" >&2
@@ -1505,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" </dev/null 2>&1) || rc=$?
out=$(COLUMNS=0 pacman -Rs --print --print-format '%n' -- "$@" </dev/null 2>&1) || rc=$?
if ((rc)); then
printf '%s\n' "$out" >&2
return "$rc"
@@ -1906,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" </dev/null 2>&1) || true
out=$("$DNF" --assumeno remove "$@" </dev/null 2>&1) || true
if [[ $out != *'Transaction Summary'* ]]; then
if [[ $out == *'Nothing to do'* ]]; then return 0; fi
printf '%s\n' "$out" >&2
@@ -2089,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" </dev/null 2>&1) || rc=$?
xml=$(zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps "$@" </dev/null 2>&1) || rc=$?
if ((rc)); then
[[ $rc != 104 ]] || say "zypper: no installed package provides '$1' (exit 104)" >&2
((rc == 104)) || printf '%s\n' "$xml" >&2
@@ -2629,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=()
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."
return 0
rejected[$name]=1
continue
fi
rd=$(be rdepends "$name") || rd=''
rd=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ')
rd=${rd% }
if [[ -n $rd ]]; then
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"
return 0
rejected[$name]=1
changed=1
break
fi
out=$(be purge_plan "$name" | sort -u) || rc=$?
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
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 $name: $(be label) would also remove $(join_by ', ' "${extra[@]}"), which $name does not depend on (dependents of it?); nothing was removed"
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
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
@@ -2712,9 +2754,28 @@ session_purge() {
}
session_trust() { # session_trust IDX...
local i now
now=$(now_utc)
local i j d now covered
local -a selected=() roots=()
local -A seen=() reach=()
for i in "$@"; do
[[ -z ${seen[$i]+x} ]] || continue
seen[$i]=1
selected+=("$i")
done
for i in "${selected[@]}"; do
closure_of "${R_NAME[i]}"
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do reach["$i,$d"]=1; done
done
for i in "${selected[@]}"; do
covered=0
for j in "${selected[@]}"; do
[[ $i != "$j" && -n ${reach[$j,${R_NAME[i]}]+x} ]] || continue
if [[ -z ${reach[$i,${R_NAME[j]}]+x} ]] || ((j < i)); then covered=1; break; fi
done
((covered)) || roots+=("$i")
done
now=$(now_utc)
for i in "${roots[@]}"; do
trust_pkg "${R_NAME[i]}" "$now"
R_EXCL[i]=0
done
@@ -2747,24 +2808,31 @@ session_toggle_all() {
SESSION_PROCEED=0
UPGRADE_FAILED_MSG= # set by <be>_upgrade when the failing step is not the package manager's own
# session_line LINE: validate every token first (a typo never executes half a line), then run them
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
local -a toks=()
local tok n op i name
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_+.@:~-]*)(\?\?|\?)$'
read -r -a toks <<<"$1" || true
if ((${#toks[@]} == 0)); then
SESSION_PROCEED=1
return 0
fi
for tok in "${toks[@]}"; do
if [[ $tok =~ $re ]]; then
n=$((10#${BASH_REMATCH[1]}))
if ! num_row "$n"; then
err "no package [$n] in the list (l to redraw)"
return 0
fi
else
if [[ ! $tok =~ $re && ! $tok =~ $name_re ]]; then
case $tok in
'*+' | '*-' | l | q | h | '?') ;;
*)
@@ -2774,15 +2842,47 @@ session_line() {
esac
fi
done
for ((n = 1; n <= N_MAX; n++)); do
if num_row "$n"; then
initial[$n]=$REPLY
named[${R_NAME[REPLY]}]=$REPLY
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]}
if ! num_row "$n"; then
err "package [$n] has left the list; ignoring the rest of the line"
return 0
if [[ -z ${initial[$n]+x} ]] || ((R_GONE[${initial[$n]:-0}])); then
err "no package [$n] in the list (l to redraw)"
continue
fi
i=${initial[$n]}
if [[ $op == + ]]; then
batch+=("$i")
continue
fi
if [[ $op == x ]]; then
purge_batch+=("$i")
continue
fi
i=$REPLY
case $op in
'?') print_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;;
'??') print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;;
@@ -2795,15 +2895,20 @@ 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
;;
+) session_trust "$i" ;;
esac
elif [[ $tok =~ $name_re ]]; then
name=${BASH_REMATCH[1]}
op=${BASH_REMATCH[2]}
if [[ -z ${named[$name]+x} ]] || ((R_GONE[${named[$name]:-0}])); then
err "no package '$name' in the list (l to redraw)"
continue
fi
i=${named[$name]}
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
case $tok in
'*+')
@@ -2823,6 +2928,8 @@ session_line() {
esac
fi
done
if ((${#batch[@]})); then session_trust "${batch[@]}"; fi
session_flush_purges purge_batch
}
cmd_interactive() {
+10
View File
@@ -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'
}
+6 -4
View File
@@ -24,12 +24,14 @@ setup() { common_setup; }
assert_line 'usage: skeptical-updater [options] [command]'
}
@test "--version and -V print the version" {
@test "--version and -v print the version" {
run skup --version
assert_success
assert_output 'skeptical-updater 0.1.0'
run skup -V
assert_output 'skeptical-updater 0.1.0'
assert_output --regexp '^skeptical-updater [0-9]+\.[0-9]+\.[0-9]+(\+(alpha|beta|rc)[.-]?[0-9]+)?$'
local version=$output
run skup -v
assert_success
assert_output "$version"
}
@test "an unknown option is a usage error (exit 1)" {
+6 -7
View File
@@ -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
for tg in "${targets[@]}"; do
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
;;
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)
+167 -2
View File
@@ -101,6 +101,54 @@ UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--forc
log_has 'apt-cache show --no-all-versions su-dummy-a'
}
@test "listed package names support both information commands alongside numeric commands" {
run skup --no-refresh <<<$'su-dummy-a? 2? su-dummy-b?? 3-\nq'
assert_success
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
assert_line ' Package: su-dummy-b'
assert_line 'excluded from this run: su-dummy-c'
log_has 'dpkg-query -s -- su-dummy-b'
}
@test "unknown or hidden package names report an error and do not stop later commands" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh <<<$'missing? su-dummy-a?? 2?\nq'
assert_success
assert_output --partial "no package 'missing' in the list"
assert_output --partial "no package 'su-dummy-a' in the list"
assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests'
}
@test "names work for trusted rows shown with --all and new dependencies" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh --all <<<$'su-dummy-a?\nq'
assert_success
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
FAKE_APT_SIM=real run skup --no-refresh <<<$'su-dummy-newdep? su-dummy-newdep??\nq'
assert_success
assert_line 'reason: not installed'
log_lacks '^dpkg-query -s -- su-dummy-newdep'
log_has 'apt-cache show --no-all-versions su-dummy-newdep'
}
@test "a package removed during a line cannot be queried by name" {
run skup --no-refresh <<<$'3x su-dummy-c? 2?\ny\nq'
assert_success
assert_output --partial "no package 'su-dummy-c' in the list"
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
}
@test "package names are not accepted for trust, exclude or purge" {
for token in su-dummy-a+ su-dummy-a- su-dummy-ax; do
run skup --no-refresh <<<"$token"$'\nq'
assert_success
assert_output --partial "unknown command '$token'"
[[ ! -e $STATE_FILE ]]
log_lacks '^apt-get -y purge'
done
}
@test "N?? on a new dependency (old version -) never looks for an installed record" {
FAKE_APT_SIM=real run skup --no-refresh <<<$'9??\nq'
assert_success
@@ -292,11 +340,49 @@ UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--forc
run skup --no-refresh <<<$'*+\nl\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line 'trusted su-dummy-b'
assert_line 'trusted su-dummy-c'
assert_line '3 upgradable, 3 trusted (hidden, use --all to show), 0 to review:'
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-b${T}user"
assert_line "su-dummy-b${T}dep:su-dummy-a"
}
@test "consecutive trust commands keep dependencies under their selected root in either order" {
for line in '1+ 2+ 3+' '2+ 3+ 1+'; do
rm -rf "$STATE"
run skup --no-refresh <<<"$line"$'\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line 'trusted su-dummy-c'
refute_output --partial 'no package ['
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-a${T}user"
assert_line "su-dummy-b${T}dep:su-dummy-a"
assert_line "su-dummy-c${T}user"
done
}
@test "invalid numbers do not stop other commands on the line" {
run skup --no-refresh <<<$'9+ 2- 0? 3+\nq'
assert_success
assert_output --partial 'no package [9] in the list'
assert_output --partial 'no package [0] in the list'
assert_line 'excluded from this run: su-dummy-b'
assert_line 'trusted su-dummy-c'
}
@test "a package removed by a purge does not stop later commands" {
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x 2? 3-\ny\nq'
assert_success
assert_output --partial 'no package [2] in the list'
assert_line 'excluded from this run: su-dummy-c'
}
@test "trusting a dependency and its root with --all keeps the dependency implicit" {
run skup --no-refresh --all <<<$'2+ 1+\nq'
assert_success
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-a${T}user"
assert_line "su-dummy-b${T}dep:su-dummy-a"
}
@test "*- excludes every listed package; *- again re-includes them" {
@@ -371,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'
}