feat(prompt): readline line editing with per-session history

When stdin is a terminal the prompt is read with bash's readline,
`IFS= read -e -r -p '> ' REPLY`, so the arrow keys no longer print escape
garbage: Left/Right, Home/End, Ctrl-A/E, Backspace/Delete edit the line,
and Up/Down walk the lines entered in this session (`history -s` for every
line with a non-blank character; empty lines are not recorded). Down past
the newest entry restores the line typed before Up, and Up at the oldest
entry or Down on the new line ring the bell, as readline does by default.
The Nx y/N answer is read the same way and never recorded.

Nothing persists: HISTFILE is unset at startup and the history is never
read or written. Verified on bash 4.3.48 and 5.2.15 that `read -e` and
`history -s` share one history list in a non-interactive shell, that
inherited HISTSIZE/HISTCONTROL/HISTIGNORE have no effect there, and that
Ctrl-C at the prompt restores the terminal. readline draws the prompt on
stderr, as bash does. Piped stdin keeps the plain `read -r` exactly,
including the echo of each line and EOF => exit 2.

New unit tests (tests/unit/lineedit.bats) drive a real pty with script(1):
history recall, the bell at both ends, the stashed line, Left-arrow
editing, the y/N answer kept out of the history, no history file under
HOME/TMPDIR, ^D => exit 2. The E2E suite gets a pty smoke test (Up
recalls 1?), skipped where the image has no script(1).
This commit is contained in:
bryanpedini committed 2026-10-10 14:01:38 +02:00
1 parent 71d7f87aa3
commit 296c77e319
7 files changed
+183 -11

No files matched your search

+16 -2
View File
@@ -147,6 +147,18 @@ replacements only the pacman transaction (`-Sup`) shows, packages dnf installs t
others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list
says "0 to review, but ... reports pending changes" and `<enter>` still runs the upgrade. says "0 to review, but ... reports pending changes" and `<enter>` still runs the upgrade.
### Line editing and history
When stdin is a terminal, the prompt is read with bash's readline and its defaults: Left/Right
move the cursor, Home/End or Ctrl-A/Ctrl-E jump to the ends, Backspace/Delete edit, and Up/Down
walk the lines entered in this session. Down past the newest entry brings back what you had typed
before pressing Up; Up at the oldest entry, and Down when already back on the new line, ring the
terminal bell. Empty lines and the `y/N` answer of `Nx` are not recorded. The history lives in
memory only and is never read from or written to a file (`HISTFILE` is ignored), so every run
starts with an empty one. As in bash, readline draws the prompt and the line on stderr and honours
your `~/.inputrc`. Piped or redirected stdin keeps the plain line reader: every line is echoed
after the prompt, and end of input aborts (exit 2).
### Dry run ### Dry run
`--dry-run` prints every mutating command as `+ <command>` instead of running it: `--dry-run` prints every mutating command as `+ <command>` instead of running it:
@@ -265,7 +277,8 @@ signal during the exit handler, the holds lock kept while a restore failed, jour
zypper configuration replayed only when no other run holds their lock, the pacman pre-check / zypper configuration replayed only when no other run holds their lock, the pacman pre-check /
database install / `-Su` sequence and stale throwaway databases, statefile locking and merge, database install / `-Su` sequence and stale throwaway databases, statefile locking and merge,
the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the
sudo re-exec of the file itself and its loop guard). sudo re-exec of the file itself and its loop guard), and the prompt on a real pty (`script`):
cursor editing, Up/Down history recall, the stashed line, the bell at both ends, no history file.
About 30 seconds. About 30 seconds.
### End-to-end tests (`tests/e2e/`) ### End-to-end tests (`tests/e2e/`)
@@ -288,7 +301,8 @@ excluding `su-dummy-b` (with and without a pre-existing hold/lock/IgnorePkg/excl
`su-dummy-c`, which must survive byte for byte), trusting re-including an excluded `su-dummy-c`, which must survive byte for byte), trusting re-including an excluded
dependency, full upgrade, `Nx` (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a dependency, full upgrade, `Nx` (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a
taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving), taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving),
purging everything, `q`, end of input, `--dry-run`, the read-only commands as an unprivileged purging everything, `q`, end of input, a session on a pty where Up recalls the previous line
(skipped where the image has no `script`), `--dry-run`, the read-only commands as an unprivileged
user, plus per backend: pacman never touching the real sync database outside the upgrade and user, plus per backend: pacman never touching the real sync database outside the upgrade and
installing exactly the reviewed database at `<enter>` (same hash, a package published after the installing exactly the reviewed database at `<enter>` (same hash, a package published after the
review is not installed), apt review is not installed), apt
+12
View File
@@ -114,6 +114,18 @@ prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts w
upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty 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). line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0).
Line input. When stdin is a terminal every line is read with bash's readline,
`IFS= read -e -r -p PROMPT`, with readline's defaults and nothing more: cursor movement and editing
(Left/Right, Home/End, Ctrl-A/E, Backspace/Delete) and Up/Down over an in-memory history of this
session's command lines. A line with a non-blank character is added with `history -s` (empty
lines, the proceed command, are not); Down past the newest entry restores the line typed before Up
(readline's saved line); Up at the oldest entry and Down on the new line ring the bell. The `Nx`
`y/N` answer is read the same way and never added. Nothing persists: `HISTFILE` is unset at
startup and `history -r`/`-w`/`-a` are never used (a non-interactive bash neither loads nor saves
history by itself). readline draws the prompt and the line on stderr, as bash does. Any other
stdin keeps the plain `IFS= read -r` with the prompt on stdout and each line echoed after it, so
piped runs and their logs are unchanged.
Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt
a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of
`apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`). `apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`).
+17 -9
View File
@@ -19,6 +19,9 @@ fi
set -euo pipefail set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive export LC_ALL=C DEBIAN_FRONTEND=noninteractive
# On a terminal the prompt uses readline with a history of this session's lines only: in memory,
# 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.1.0
readonly SU_PROG=skeptical-updater readonly SU_PROG=skeptical-updater
@@ -2185,14 +2188,21 @@ eof_abort() {
exit 2 exit 2
} }
read_line() { # read_line -> $REPLY; EOF aborts (exit 2) # read_line PROMPT [history] -> $REPLY; EOF aborts (exit 2). On a terminal the line is read with
# readline (bash's defaults: arrows, Home/End, ^A/^E, ...; the prompt is drawn on stderr) and, with
# "history", a non-empty line joins this session's in-memory history that Up/Down walk. Otherwise
# a plain read, and the input is echoed after the prompt so logs stay readable.
read_line() {
REPLY= REPLY=
if ! IFS= read -r REPLY; then if [[ -t 0 ]]; then
[[ -n $REPLY ]] || eof_abort IFS= read -e -r -p "$1" REPLY || [[ -n $REPLY ]] || eof_abort
if [[ ${2:-} == history && $REPLY == *[![:space:]]* ]]; then history -s -- "$REPLY"; fi
return 0
fi fi
printf '%s' "$1" 2>/dev/null || true
IFS= read -r REPLY || [[ -n $REPLY ]] || eof_abort
REPLY=${REPLY%$'\r'} REPLY=${REPLY%$'\r'}
# echo the input when it does not come from a terminal, so logs stay readable say "$REPLY"
[[ -t 0 ]] || say "$REPLY"
} }
num_row() { # num_row N -> $REPLY = row index of a listed row, rc 1 if N is not listed num_row() { # num_row N -> $REPLY = row index of a listed row, rc 1 if N is not listed
@@ -2250,8 +2260,7 @@ session_purge() {
fi fi
prompt="Purge $name now?" prompt="Purge $name now?"
((${#others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${others[@]}")" ((${#others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${others[@]}")"
printf '%s [y/N] ' "$prompt" 2>/dev/null || true read_line "$prompt [y/N] "
read_line
case $REPLY in case $REPLY in
y | Y | yes | YES | Yes) ;; y | Y | yes | YES | Yes) ;;
*) *)
@@ -2415,8 +2424,7 @@ cmd_interactive() {
say '' say ''
say "$SU_PROMPT_HELP" say "$SU_PROMPT_HELP"
while ((!SESSION_PROCEED)); do while ((!SESSION_PROCEED)); do
printf '> ' 2>/dev/null || true read_line '> ' history
read_line
session_line "$REPLY" session_line "$REPLY"
done done
for ((i = 0; i < ${#R_NAME[@]}; i++)); do for ((i = 0; i < ${#R_NAME[@]}; i++)); do
+17
View File
@@ -207,6 +207,23 @@ pacman_unpublish() {
repo-add --quiet su-dummy.db.tar.gz ./su-dummy-a-2.0-1-any.pkg.tar.zst ./su-dummy-b-2.0-1-any.pkg.tar.zst ./su-dummy-c-2.0-1-any.pkg.tar.zst) repo-add --quiet su-dummy.db.tar.gz ./su-dummy-a-2.0-1-any.pkg.tar.zst ./su-dummy-b-2.0-1-any.pkg.tar.zst ./su-dummy-c-2.0-1-any.pkg.tar.zst)
} }
# pty_session INPUT ARGS...: run skeptical-updater ARGS on a pseudo-terminal (script(1) of
# util-linux) with INPUT typed in one go (readline consumes it as it reads: no timing involved);
# sets status, output and lines like `run` (without the pty's CRs; NO_COLOR as stdout is a terminal)
# and PTY_RAW, the raw transcript. Skips the test when the image has no script(1).
# shellcheck disable=SC2034 # status/lines are read by bats-assert
pty_session() {
local input=$1 cmd
shift
command -v script >/dev/null 2>&1 || skip "script(1) (util-linux) not available in this image: no pty to drive"
PTY_RAW=$BATS_TEST_TMPDIR/pty.raw
printf -v cmd '%q ' env TERM=dumb NO_COLOR=1 skeptical-updater "$@"
status=0
printf '%s' "$input" | timeout 120 script -qfec "$cmd" /dev/null >"$PTY_RAW" 2>&1 || status=$?
output=$(tr -d '\r' <"$PTY_RAW")
mapfile -t lines <<<"$output"
}
# session_start ARGS...: run skeptical-updater ARGS in the background with a FIFO as stdin and wait # session_start ARGS...: run skeptical-updater ARGS in the background with a FIFO as stdin and wait
# for its prompt; sets SESSION_PID and SESSION_FD (write the input to it; fd 3 belongs to bats) # for its prompt; sets SESSION_PID and SESSION_FD (write the input to it; fd 3 belongs to bats)
session_start() { session_start() {
+8
View File
@@ -219,6 +219,14 @@ zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbl
[[ ! -e $STATE_FILE ]] [[ ! -e $STATE_FILE ]]
} }
@test "on a terminal, Up recalls the previous line (readline, history of this session only)" {
pty_session $'1?\r\e[A\rq\r' --no-refresh
assert_success
[[ $(grep -c '^su-dummy-a: ' <<<"$output") -eq 2 ]] || fail "1? should have run twice (Up + Enter)"
assert_line 'Quit: nothing was upgraded.'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
}
@test "--dry-run: enter prints the native upgrade command and changes nothing" { @test "--dry-run: enter prints the native upgrade command and changes nothing" {
before=$(holds_snapshot) before=$(holds_snapshot)
run skeptical-updater --dry-run <<<'' run skeptical-updater --dry-run <<<''
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Line editing and the per-session history of the prompt when stdin is a terminal: the applet runs
# on a real pty (script(1)) and gets the keys as escape sequences - Up \e[A, Down \e[B, Right \e[C,
# Left \e[D, Enter \r - typed in one go. apt backend, fixture rows [1] su-dummy-a [2] su-dummy-b
# [3] su-dummy-c. The piped (non-TTY) prompt is covered by interactive.bats.
load test_helper
setup() { common_setup; }
UP=$'\e[A' DOWN=$'\e[B' LEFT=$'\e[D' CR=$'\r'
DESC_A='su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
@test "pty: Up recalls the previous line, Enter runs it again" {
pty_skup "1?${CR}${UP}${CR}q${CR}" --no-refresh
assert_success
[[ $(count_lines "$DESC_A") -eq 2 ]] || fail "1? should have run twice"
assert_line 'Quit: nothing was upgraded.'
[[ $(pty_bells) -eq 0 ]] || fail "unexpected bell"
}
@test "pty: Up at the oldest entry rings the bell and stays there" {
pty_skup "1?${CR}${UP}${UP}${CR}q${CR}" --no-refresh
assert_success
[[ $(pty_bells) -ge 1 ]] || fail "no BEL in the pty output"
[[ $(count_lines "$DESC_A") -eq 2 ]] || fail "the oldest entry (1?) should have run again"
}
@test "pty: a line typed before Up is stashed and comes back with Down (readline's saved line)" {
pty_skup "1?${CR}3-${UP}${DOWN}${CR}${CR}" --no-refresh
assert_success
assert_line 'excluded from this run: su-dummy-c'
[[ $(count_lines "$DESC_A") -eq 1 ]] || fail "the recalled 1? must not have run"
assert_line 'Excluded from this run: su-dummy-c'
log_has 'apt-mark hold su-dummy-c'
[[ $(pty_bells) -eq 0 ]] || fail "unexpected bell"
}
@test "pty: Down on the new line rings the bell, also once the stashed line is back" {
pty_skup "1?${CR}${DOWN}q${CR}" --no-refresh
assert_success
[[ $(pty_bells) -ge 1 ]] || fail "no BEL for Down on the new line"
assert_line 'Quit: nothing was upgraded.'
pty_skup "1?${CR}3-${UP}${DOWN}${DOWN}${CR}q${CR}" --no-refresh
assert_success
[[ $(pty_bells) -ge 1 ]] || fail "no BEL for Down on the restored line"
assert_line 'excluded from this run: su-dummy-c'
}
@test "pty: Left moves the cursor: 2? Left Left 1 Enter is the command 12?" {
pty_skup "2?${LEFT}${LEFT}1${CR}q${CR}" --no-refresh
assert_success
assert_output --partial 'no package [12] in the list (l to redraw)'
refute_output --partial 'su-dummy-b: dummy package'
}
@test "pty: empty lines and the y/N answer stay out of the history" {
# history: 3x only - Up shows 3x, a second Up rings the bell (y was not added); 3x again is
# rejected because [3] was purged
pty_skup "3x${CR}y${CR}${UP}${UP}${CR}q${CR}" --no-refresh
assert_success
assert_line 'purged: su-dummy-c'
assert_output --partial 'no package [3] in the list (l to redraw)'
[[ $(pty_bells) -ge 1 ]] || fail "y must not be in the history"
log_has 'apt-get -y purge su-dummy-c'
}
@test "pty: the history is never written to or read from a file (HISTFILE is ignored)" {
export HOME=$BATS_TEST_TMPDIR/home TMPDIR=$BATS_TEST_TMPDIR/tmp
mkdir -p "$HOME" "$TMPDIR"
export HISTFILE=$HOME/.bash_history HISTCONTROL=ignoredups HISTSIZE=0
pty_skup "1?${CR}${UP}${CR}${UP}${CR}${CR}" --no-refresh
assert_success
[[ $(count_lines "$DESC_A") -eq 3 ]] || fail "the history did not work (inherited HISTSIZE/HISTCONTROL?)"
assert_line 'Upgrade finished.'
run find "$HOME" "$TMPDIR" -mindepth 1
assert_success
assert_output ''
}
@test "pty: end of input (^D on an empty line) still aborts with exit 2" {
pty_skup "1-${CR}"$'\x04' --no-refresh
assert_failure 2
assert_line 'excluded from this run: su-dummy-a'
assert_output --partial 'end of input: aborted, nothing was upgraded'
log_lacks '^apt-get -y'
}
+22
View File
@@ -50,6 +50,28 @@ use_fakes() {
# skup ARGS...: run the applet (use as `run skup ...`, stdin is passed through) # skup ARGS...: run the applet (use as `run skup ...`, stdin is passed through)
skup() { env PATH="$TEST_PATH" "$SU_BIN" "$@"; } skup() { env PATH="$TEST_PATH" "$SU_BIN" "$@"; }
# pty_skup INPUT ARGS...: run the applet on a pseudo-terminal (script(1), util-linux/bsdutils) with
# INPUT typed in one go - readline consumes it as it reads, so no timing is involved; end INPUT with
# a line that ends the session (q or an empty line). Sets status, output and lines like `run`
# (output without the pty's CRs; NO_COLOR, as stdout is a terminal); the raw transcript stays in
# $PTY_RAW. Skips without script(1).
# shellcheck disable=SC2034 # status/lines are read by bats-assert
pty_skup() {
local input=$1 cmd
shift
command -v script >/dev/null 2>&1 || skip "script(1) (util-linux/bsdutils) not available: no pty to drive"
PTY_RAW=$BATS_TEST_TMPDIR/pty.raw
printf -v cmd '%q ' env PATH="$TEST_PATH" TERM=dumb NO_COLOR=1 "$SU_BIN" "$@"
status=0
printf '%s' "$input" | timeout 60 script -qfec "$cmd" /dev/null >"$PTY_RAW" 2>&1 || status=$?
output=$(tr -d '\r' <"$PTY_RAW")
mapfile -t lines <<<"$output"
}
# pty_bells: number of BEL bytes (readline's bell) in the raw transcript of the last pty_skup
pty_bells() { tr -cd '\a' <"$PTY_RAW" | wc -c; }
# count_lines LINE: number of output lines that are exactly LINE
count_lines() { grep -cxF -- "$1" <<<"$output" || true; }
# shellcheck disable=SC2034 # used by the .bats files # shellcheck disable=SC2034 # used by the .bats files
HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)' HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
# shellcheck disable=SC2034 # shellcheck disable=SC2034