diff --git a/README.md b/README.md index 644126d..ddb234e 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ other command suffixes. | `N-` | exclude from this run (toggle; `N-` again re-includes); shown with a `-` marker | | `Nx` | purge now, with its now-unneeded dependencies: the purge is simulated first and the prompt lists everything it removes (`Purge su-dummy-a now? This also removes: su-dummy-b [y/N]`); the answer is read from the same stdin; the rows of every removed package leave the list. `x` refuses when other installed packages depend on the target, like `pacman -Rns`; purge the dependents first (`not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first`). Rows with old version `-` (new dependencies, not installed) cannot be purged | | `N+` | trust forever: write it and its installed dependency closure to the statefile; the row leaves the list. Rows this hides are re-included if they were excluded (a trusted package is upgraded) | +| `N++` | follow installed dependents recursively and trust each topmost package and its installed dependencies, even when a topmost package is not in the update list | | `*+` / `*-` | `+` / `-` for every listed package (`*-` is an all-or-nothing toggle: when all are excluded it re-includes them) | | `a` | show packages trusted during this session again, marked with `=`; packages already trusted before the session remain hidden | | `l` | redraw the list | diff --git a/skeptical-updater b/skeptical-updater index 6609ebe..a8dad03 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.5.0 +# Version: 0.6.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.5.0 +readonly SU_VERSION=0.6.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' @@ -120,6 +120,7 @@ interactive commands (one line, whitespace-separated tokens, N = number in brack 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) + N++ trust the topmost installed dependents and their dependency closures *+ / *- apply + / - to every listed package a show packages trusted during this session again l redraw the list @@ -148,6 +149,7 @@ session_help() { pacman -Rns; purge the dependents first consecutive Nx commands are purged together after one confirmation N+ trust N (and its installed dependencies) forever + N++ trust the topmost installed dependents of N and their dependencies *+ *- trust / exclude every listed package (*- again re-includes them) a show packages trusted during this session again l redraw the list (a '*' after the number marks an AUR package) @@ -2785,6 +2787,11 @@ session_trust() { # session_trust IDX... trust_pkg "${R_NAME[i]}" "$now" R_EXCL[i]=0 done + session_finish_trust +} + +session_finish_trust() { + local i for ((i = 0; i < ${#R_NAME[@]}; i++)); do if ((R_NUM[i] > 0 && !R_GONE[i])) && is_trusted "${R_NAME[i]}"; then SESSION_TRUSTED[${R_NAME[i]}]=1; fi done @@ -2798,6 +2805,62 @@ session_trust() { # session_trust IDX... state_save } +session_trust_dependents() { + local i=$1 node dep out root other candidate now head blocked + local -a queue=() nodes=() roots=() walk=() deps=() + local -A seen=() edges=() reach=() visited=() + if [[ ${R_OLD[i]} == - ]]; then + say "${R_NAME[i]} is not installed: cannot find installed dependents." + return 0 + fi + queue=("${R_NAME[i]}") + seen[${R_NAME[i]}]=1 + for ((head = 0; head < ${#queue[@]}; head++)); do + node=${queue[head]} + nodes+=("$node") + if ! out=$(be rdepends "$node"); then + err "cannot find installed dependents of $node; nothing was trusted" + return 0 + fi + while IFS= read -r dep; do + [[ -n $dep ]] || continue + edges[$node]+=" $dep" + if [[ -z ${seen[$dep]+x} ]]; then seen[$dep]=1; queue+=("$dep"); fi + done <<<"$out" + done + for root in "${nodes[@]}"; do + walk=("$root") + visited=(["$root"]=1) + for ((head = 0; head < ${#walk[@]}; head++)); do + node=${walk[head]} + read -r -a deps <<<"${edges[$node]-}" + for dep in "${deps[@]}"; do + [[ -z ${visited[$dep]+x} ]] || continue + visited[$dep]=1 + walk+=("$dep") + done + done + for node in "${walk[@]}"; do reach["$root,$node"]=1; done + done + mapfile -t nodes < <(printf '%s\n' "${nodes[@]}" | sort -u) + for root in "${nodes[@]}"; do + blocked=0 + for other in "${nodes[@]}"; do + [[ $root != "$other" && -n ${reach[$root,$other]+x} ]] || continue + if [[ -z ${reach[$other,$root]+x} || $other < $root ]]; then blocked=1; break; fi + done + ((blocked)) || roots+=("$root") + done + now=$(now_utc) + for candidate in "${roots[@]}"; do + trust_pkg "$candidate" "$now" + for ((i = 0; i < ${#R_NAME[@]}; i++)); do + [[ ${R_NAME[i]} != "$candidate" ]] || R_EXCL[i]=0 + done + done + session_finish_trust +} + session_toggle_all() { local i all=1 local -a idx=() @@ -2833,7 +2896,7 @@ session_line() { local tok n op i name local -a toks=() batch=() purge_batch=() local -A initial=() named=() - local re='^([0-9]{1,6})(\?\?|\?|-|x|\+)$' + 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 @@ -2893,6 +2956,7 @@ session_line() { continue fi case $op in + '++') session_trust_dependents "$i" ;; '?') print_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;; '??') print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;; -) diff --git a/tests/unit/interactive.bats b/tests/unit/interactive.bats index 8c13cb1..b307a62 100644 --- a/tests/unit/interactive.bats +++ b/tests/unit/interactive.bats @@ -555,3 +555,47 @@ UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--forc assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' refute_output --partial '=[3] su-dummy-c' } + +@test "N++ trusts the topmost installed dependent and its dependencies" { + run skup --no-refresh <<<$'2++\nq' + assert_success + assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)' + run cut -f2,3 "$STATE_FILE" + assert_line "su-dummy-a${T}user" + assert_line "su-dummy-b${T}dep:su-dummy-a" +} + +@test "N++ trusts all topmost branches including packages outside the list" { + local fixture_data="$BATS_TEST_TMPDIR/data" + cp -R "$FAKE_DATA" "$fixture_data" + printf 'su-dummy-b\nReverse Depends:\n su-dummy-a\n su-dummy-c\n' >"$fixture_data/apt/rdepends/su-dummy-b" + printf 'su-dummy-a\nReverse Depends:\n su-dummy-w\n' >"$fixture_data/apt/rdepends/su-dummy-a" + sed -i 's/su-virtual | su-dummy-alt/su-dummy-a/; s/^ii \(.*su-dummy-c\)\t\t\t\t/ii \1\t\tsu-dummy-b\t/' "$fixture_data/apt/dpkg-status.tsv" + FAKE_DATA="$fixture_data" run skup --no-refresh <<<$'2++\nq' + assert_success + assert_output --partial 'trusted su-dummy-c' + assert_output --partial 'trusted su-dummy-w' + run cut -f2,3 "$STATE_FILE" + assert_line "su-dummy-c${T}user" + assert_line "su-dummy-w${T}user" + assert_line "su-dummy-a${T}dep:su-dummy-w" +} + +@test "N++ terminates on a dependency cycle and chooses one root" { + local fixture_data="$BATS_TEST_TMPDIR/data" + cp -R "$FAKE_DATA" "$fixture_data" + printf 'su-dummy-a\nReverse Depends:\n su-dummy-b\n' >"$fixture_data/apt/rdepends/su-dummy-a" + sed -i 's/^ii \(.*su-dummy-b\)\t\t\t\t/ii \1\t\tsu-dummy-a\t/' "$fixture_data/apt/dpkg-status.tsv" + FAKE_DATA="$fixture_data" run skup --no-refresh <<<$'2++\nq' + assert_success + assert_output --partial 'trusted su-dummy-a' + run cut -f2,3 "$STATE_FILE" + assert_line "su-dummy-a${T}user" + assert_line "su-dummy-b${T}dep:su-dummy-a" +} + +@test "N++ trusts the package itself when it has no installed dependents" { + run skup --no-refresh <<<$'3++\nq' + assert_success + assert_line 'trusted su-dummy-c' +}