diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-14 08:41:16 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-14 08:41:16 -0500 |
| commit | eada697a11da5db8446108fed7573af809d222cc (patch) | |
| tree | 93eb06fcdf16496167d342502984411236b20df9 | |
| parent | 659e90ad1b85eddee4b1d64afbcc0b1e4e8eef9f (diff) | |
| download | archangel-eada697a11da5db8446108fed7573af809d222cc.tar.gz archangel-eada697a11da5db8446108fed7573af809d222cc.zip | |
feat: add --name flag to zfssnapshot rollback and delete
I added --name NAME to rollback (single name) and --name NAME[,NAME...] to delete (comma-separated for multi-select) so scripted callers can drive the wrapper without fzf. The upcoming VM verification step in scripts/test-install.sh needs this. fzf is now conditional, required only when --name is omitted.
The 10 new bats tests cover help-text mentions, parse success and failure modes (missing value, mutex with -s, unknown flag), fzf-bypass on both subcommands, and multi-name expansion on delete.
| -rwxr-xr-x | installer/zfssnapshot | 111 | ||||
| -rw-r--r-- | tests/unit/test_zfssnapshot.bats | 133 |
2 files changed, 212 insertions, 32 deletions
diff --git a/installer/zfssnapshot b/installer/zfssnapshot index 88315a9..f9dfb43 100755 --- a/installer/zfssnapshot +++ b/installer/zfssnapshot @@ -22,13 +22,19 @@ Subcommands: YYYY-MM-DD_HH-MM-SS_description. Description must be alphanumeric, hyphens, or underscores. Spaces become underscores. - rollback [-s] fzf-select a snapshot, then roll back. By + rollback [-s] [--name NAME] + fzf-select a snapshot, then roll back. By default, rolls back ALL datasets that share the selected snapshot name. -s rolls back - only the selected dataset. - delete fzf multi-select snapshots to destroy. By + only the selected dataset. --name NAME + bypasses fzf for scripted callers (mutually + exclusive with -s). + delete [--name NAME[,NAME...]] + fzf multi-select snapshots to destroy. By default, destroys the snapshot across ALL - matching datasets. + matching datasets. --name NAME[,NAME...] + bypasses fzf and destroys the named + snapshots (comma-separated for multi-select). Common options: -h, --help Show this help and exit. @@ -165,17 +171,33 @@ cmd_create() { cmd_rollback() { require_root require_zfs - require_fzf local single_mode=false - while getopts ":hs" opt; do - case ${opt} in - h) show_help; exit 0 ;; - s) single_mode=true ;; - \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; + local name_arg="" + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) show_help; exit 0 ;; + -s) single_mode=true; shift ;; + --name) + if [[ -z "${2:-}" || "$2" == -* ]]; then + echo "Error: --name requires an argument" >&2 + exit 1 + fi + name_arg="$2" + shift 2 + ;; + *) echo "Invalid option: $1" >&2; exit 1 ;; esac done + if [[ -n "$name_arg" && "$single_mode" == "true" ]]; then + echo "Error: --name and -s cannot be combined" >&2 + exit 1 + fi + + # fzf is only required for interactive selection; --name skips it. + [[ -z "$name_arg" ]] && require_fzf + local snapshots snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null) @@ -205,17 +227,21 @@ cmd_rollback() { # Multi mode: pick a snapshot NAME, roll back every dataset # carrying that name. Children rolled back before parents # (sort by dataset path length, descending). - local unique_snaps - unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru) - - snap_name=$(echo "$unique_snaps" | fzf --height=70% --reverse \ - --header="Select snapshot name to roll back ALL matching datasets (ESC to cancel)" \ - --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \ - --preview-window=down:10) - - if [ -z "$snap_name" ]; then - echo "No snapshot selected, exiting" - exit 0 + if [[ -n "$name_arg" ]]; then + snap_name="$name_arg" + else + local unique_snaps + unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru) + + snap_name=$(echo "$unique_snaps" | fzf --height=70% --reverse \ + --header="Select snapshot name to roll back ALL matching datasets (ESC to cancel)" \ + --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \ + --preview-window=down:10) + + if [ -z "$snap_name" ]; then + echo "No snapshot selected, exiting" + exit 0 + fi fi mapfile -t targets < <(echo "$snapshots" | grep "@${snap_name}$" \ @@ -305,7 +331,24 @@ cmd_rollback() { cmd_delete() { require_root require_zfs - require_fzf + + local name_arg="" + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) show_help; exit 0 ;; + --name) + if [[ -z "${2:-}" || "$2" == -* ]]; then + echo "Error: --name requires an argument" >&2 + exit 1 + fi + name_arg="$2" + shift 2 + ;; + *) echo "Invalid option: $1" >&2; exit 1 ;; + esac + done + + [[ -z "$name_arg" ]] && require_fzf local snapshots snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null) @@ -318,18 +361,22 @@ cmd_delete() { # Pick snapshot NAMES (not full dataset@name); destroy each across # every dataset that carries it. Same shape as rollback's default # so the user model is consistent. - local unique_snaps - unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru) - local selected_names - selected_names=$(echo "$unique_snaps" | fzf --height=70% --reverse --multi \ - --header="TAB to multi-select snapshots to DESTROY (ESC to cancel)" \ - --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \ - --preview-window=down:10) + if [[ -n "$name_arg" ]]; then + selected_names=$(echo "$name_arg" | tr ',' '\n') + else + local unique_snaps + unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru) - if [ -z "$selected_names" ]; then - echo "No snapshots selected, exiting" - exit 0 + selected_names=$(echo "$unique_snaps" | fzf --height=70% --reverse --multi \ + --header="TAB to multi-select snapshots to DESTROY (ESC to cancel)" \ + --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \ + --preview-window=down:10) + + if [ -z "$selected_names" ]; then + echo "No snapshots selected, exiting" + exit 0 + fi fi # Expand selected names to full dataset@name targets. diff --git a/tests/unit/test_zfssnapshot.bats b/tests/unit/test_zfssnapshot.bats index 74e13cc..dc06483 100644 --- a/tests/unit/test_zfssnapshot.bats +++ b/tests/unit/test_zfssnapshot.bats @@ -151,3 +151,136 @@ setup() { [[ "$output" == *"not-a-subcommand"* ]] [[ "$output" == *"Usage:"* ]] } + +############################# +# show_help — flag documentation +############################# + +@test "show_help documents --name for rollback" { + run show_help + [ "$status" -eq 0 ] + [[ "$output" == *"rollback"* ]] + [[ "$output" == *"--name NAME"* ]] +} + +@test "show_help documents --name for delete" { + run show_help + [ "$status" -eq 0 ] + [[ "$output" == *"delete"* ]] + [[ "$output" == *"--name NAME[,NAME...]"* ]] +} + +############################# +# cmd_rollback --name +############################# +# These tests mock require_*, fzf, and zfs to exercise the arg-parse + +# fzf-bypass path without needing a real ZFS system. The destructive +# tail (zfs rollback) is exercised in scripts/test-install.sh against a +# real VM. Confirmation always answered "no" so the test stops at the +# gate without reaching the rollback step. + +@test "cmd_rollback --name bypasses fzf and reaches the confirmation gate" { + require_root() { :; } + require_zfs() { :; } + require_fzf() { echo "FZF REQUIRED" >&2; return 1; } + fzf() { echo "FZF INVOKED" >&2; return 1; } + zfs() { + case "$1" in + list) printf 'zroot/ROOT/default@target\nzroot/home@target\n' ;; + *) echo "zfs $*" ;; + esac + } + + run cmd_rollback --name target <<< "no" + + [ "$status" -eq 0 ] + [[ "$output" != *"FZF REQUIRED"* ]] + [[ "$output" != *"FZF INVOKED"* ]] + [[ "$output" == *"@target"* ]] + [[ "$output" == *"cancelled"* ]] +} + +@test "cmd_rollback --name without a value errors out" { + require_root() { :; } + require_zfs() { :; } + run cmd_rollback --name + [ "$status" -ne 0 ] + [[ "$output" == *"requires an argument"* ]] +} + +@test "cmd_rollback --name combined with -s errors out" { + require_root() { :; } + require_zfs() { :; } + run cmd_rollback --name foo -s + [ "$status" -ne 0 ] + [[ "$output" == *"cannot be combined"* ]] +} + +@test "cmd_rollback rejects an unknown flag" { + require_root() { :; } + require_zfs() { :; } + run cmd_rollback --bogus + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid option"* ]] +} + +############################# +# cmd_delete --name +############################# + +@test "cmd_delete --name bypasses fzf and reaches the confirmation gate" { + require_root() { :; } + require_zfs() { :; } + require_fzf() { echo "FZF REQUIRED" >&2; return 1; } + fzf() { echo "FZF INVOKED" >&2; return 1; } + zfs() { + case "$1" in + list) printf 'zroot/ROOT/default@target\nzroot/home@target\n' ;; + *) echo "zfs $*" ;; + esac + } + + run cmd_delete --name target <<< "no" + + [ "$status" -eq 0 ] + [[ "$output" != *"FZF REQUIRED"* ]] + [[ "$output" != *"FZF INVOKED"* ]] + [[ "$output" == *"@target"* ]] + [[ "$output" == *"cancelled"* ]] +} + +@test "cmd_delete --name expands comma-separated names to multiple targets" { + require_root() { :; } + require_zfs() { :; } + require_fzf() { :; } + zfs() { + case "$1" in + list) printf 'zroot/ROOT/default@a\nzroot/home@a\nzroot/ROOT/default@b\n' ;; + *) echo "zfs $*" ;; + esac + } + + run cmd_delete --name a,b <<< "no" + + [ "$status" -eq 0 ] + [[ "$output" == *"zroot/ROOT/default@a"* ]] + [[ "$output" == *"zroot/home@a"* ]] + [[ "$output" == *"zroot/ROOT/default@b"* ]] + [[ "$output" == *"3 snapshot(s) will be destroyed"* ]] +} + +@test "cmd_delete --name without a value errors out" { + require_root() { :; } + require_zfs() { :; } + run cmd_delete --name + [ "$status" -ne 0 ] + [[ "$output" == *"requires an argument"* ]] +} + +@test "cmd_delete rejects an unknown flag" { + require_root() { :; } + require_zfs() { :; } + run cmd_delete --bogus + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid option"* ]] +} |
