Skip to content

Commit 89ab953

Browse files
authored
feat: add activate/protect options
1 parent bbb964f commit 89ab953

8 files changed

Lines changed: 397 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ sudo atomic-gc --dry-run 2 # preview: keep last 2
8888
atomic-gc list # list all generations
8989
sudo atomic-gc rm 20260217-143022 # delete specific generation
9090
sudo atomic-gc rm -y 20260217-143022 20260216-235122 # delete multiple without confirmation
91+
sudo atomic-gc activate 20260217-143022 # mark as active (UEFI boots first)
92+
sudo atomic-gc deactivate 20260217-143022 # remove active marker
93+
sudo atomic-gc protect 20260217-143022 # protect from garbage collection
94+
sudo atomic-gc unprotect 20260217-143022 # remove protection
9195
sudo atomic-rebuild-uki --list # show subvolumes and UKI status
9296
sudo atomic-rebuild-uki 20250208-134725 # rebuild UKI for specific generation
9397
```
@@ -97,8 +101,9 @@ sudo atomic-rebuild-uki 20250208-134725 # rebuild UKI for specifi
97101
Tab completion is available for `atomic-gc`, `atomic-rebuild-uki`, and `atomic-upgrade` in both zsh and bash:
98102

99103
```bash
100-
atomic-gc <TAB> # → list rm
104+
atomic-gc <TAB> # → list rm activate deactivate protect unprotect
101105
atomic-gc rm <TAB> # → generation IDs from ESP
106+
atomic-gc activate <TAB> # → generation IDs from ESP
102107
atomic-rebuild-uki <TAB> # → generation IDs from ESP
103108
atomic-rebuild-uki -<TAB> # → --help --list -h -l
104109
atomic-upgrade -<TAB> # → --dry-run --tag --no-gc --separate-home ...

‎bin/atomic-gc‎

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ show_help() {
1313
atomic-gc v${VERSION} - Manage generations
1414
1515
Usage:
16-
atomic-gc [OPTIONS] [COUNT] Garbage collect (keep COUNT, default: $KEEP_GENERATIONS)
17-
atomic-gc list List all generations
16+
atomic-gc [OPTIONS] [COUNT] Garbage collect (keep COUNT, default: $KEEP_GENERATIONS)
17+
atomic-gc list List all generations
1818
atomic-gc rm [OPTIONS] GEN_ID [GEN_ID ...] Delete specific generation(s)
19+
atomic-gc activate GEN_ID Mark generation as active (UEFI boots first)
20+
atomic-gc deactivate GEN_ID Remove active mark from generation
21+
atomic-gc protect GEN_ID Protect generation from garbage collection
22+
atomic-gc unprotect GEN_ID Remove protection from generation
1923
2024
Options:
2125
-n, --dry-run Show what would be done
@@ -37,8 +41,12 @@ YES=0
3741
COMMAND="gc"
3842

3943
case "${1:-}" in
40-
list) COMMAND="list"; shift ;;
41-
rm) COMMAND="rm"; shift ;;
44+
list) COMMAND="list"; shift ;;
45+
rm) COMMAND="rm"; shift ;;
46+
activate) COMMAND="activate"; shift ;;
47+
deactivate) COMMAND="deactivate"; shift ;;
48+
protect) COMMAND="protect"; shift ;;
49+
unprotect) COMMAND="unprotect"; shift ;;
4250
esac
4351

4452
ARGS=()
@@ -70,10 +78,17 @@ case "$COMMAND" in
7078
generations=$(list_generations)
7179
[[ -z "$generations" ]] && { echo "No generations found"; exit 0; }
7280
for gen_id in $generations; do
81+
markers=""
82+
if [[ -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]]; then
83+
markers+=" (active)"
84+
fi
85+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
86+
markers+=" (protected)"
87+
fi
7388
if [[ "root-${gen_id}" == "$current_subvol" ]]; then
74-
echo " * ${gen_id} (current)"
89+
echo " * ${gen_id}${markers} (current)"
7590
else
76-
echo " ${gen_id}"
91+
echo " ${gen_id}${markers}"
7792
fi
7893
done
7994
;;
@@ -82,13 +97,18 @@ case "$COMMAND" in
8297
[[ ${#ARGS[@]} -eq 0 ]] && { echo "ERROR: Specify generation(s) to delete" >&2; exit 1; }
8398
ensure_btrfs_mounted || exit 1
8499
current_subvol=$(get_current_subvol)
85-
# Validate all targets exist before deleting any
100+
# Validate all targets exist and are not protected before deleting any
86101
for gen_id in "${ARGS[@]}"; do
87102
if [[ ! -f "${ESP}/EFI/Linux/arch-${gen_id}.efi" ]] && \
103+
[[ ! -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]] && \
88104
[[ ! -d "${BTRFS_MOUNT}/root-${gen_id}" ]]; then
89105
echo "ERROR: Generation not found: ${gen_id}" >&2
90106
exit 1
91107
fi
108+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
109+
echo "ERROR: Refuse: ${gen_id} is protected (remove protection first)" >&2
110+
exit 1
111+
fi
92112
done
93113
if [[ "$YES" -eq 0 && "$DRY_RUN" -eq 0 ]]; then
94114
echo "Will delete: ${ARGS[*]}"
@@ -111,4 +131,91 @@ case "$COMMAND" in
111131
fi
112132
garbage_collect "$KEEP_COUNT" "$DRY_RUN"
113133
;;
134+
activate)
135+
[[ ${#ARGS[@]} -eq 0 ]] && { echo "ERROR: Specify generation to activate" >&2; exit 1; }
136+
gen_id="${ARGS[0]}"
137+
acquire_lock
138+
# Validate gen_id format
139+
if [[ ! "$gen_id" =~ ^[0-9]{8}-[0-9]{6}(-.+)?$ ]]; then
140+
echo "ERROR: Invalid generation ID format: ${gen_id}" >&2
141+
exit 1
142+
fi
143+
# Check if already active
144+
if [[ -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]]; then
145+
echo "Generation ${gen_id} is already active"
146+
exit 0
147+
fi
148+
# Check if UKI exists (regular or active)
149+
if [[ ! -f "${ESP}/EFI/Linux/arch-${gen_id}.efi" ]] && \
150+
[[ ! -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]] && \
151+
[[ ! -d "${BTRFS_MOUNT}/root-${gen_id}" ]]; then
152+
echo "ERROR: Generation not found: ${gen_id}" >&2
153+
exit 1
154+
fi
155+
# Rename to add active prefix
156+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi" ]]; then
157+
mv "${ESP}/EFI/Linux/arch-${gen_id}.efi" "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi"
158+
fi
159+
echo "Activated: ${gen_id} (will boot first)"
160+
;;
161+
deactivate)
162+
[[ ${#ARGS[@]} -eq 0 ]] && { echo "ERROR: Specify generation to deactivate" >&2; exit 1; }
163+
gen_id="${ARGS[0]}"
164+
acquire_lock
165+
# Validate gen_id format
166+
if [[ ! "$gen_id" =~ ^[0-9]{8}-[0-9]{6}(-.+)?$ ]]; then
167+
echo "ERROR: Invalid generation ID format: ${gen_id}" >&2
168+
exit 1
169+
fi
170+
# Check if active
171+
if [[ ! -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]]; then
172+
echo "Generation ${gen_id} is not active"
173+
exit 0
174+
fi
175+
# Rename to remove active prefix
176+
mv "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" "${ESP}/EFI/Linux/arch-${gen_id}.efi"
177+
echo "Deactivated: ${gen_id}"
178+
;;
179+
protect)
180+
[[ ${#ARGS[@]} -eq 0 ]] && { echo "ERROR: Specify generation to protect" >&2; exit 1; }
181+
gen_id="${ARGS[0]}"
182+
acquire_lock
183+
# Validate gen_id format
184+
if [[ ! "$gen_id" =~ ^[0-9]{8}-[0-9]{6}(-.+)?$ ]]; then
185+
echo "ERROR: Invalid generation ID format: ${gen_id}" >&2
186+
exit 1
187+
fi
188+
# Check if already protected
189+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
190+
echo "Generation ${gen_id} is already protected"
191+
exit 0
192+
fi
193+
# Check if UKI exists (could be active or regular)
194+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi" ]] || \
195+
[[ -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi" ]] || \
196+
[[ -d "${BTRFS_MOUNT}/root-${gen_id}" ]]; then
197+
touch "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected"
198+
echo "Protected: ${gen_id} (won't be garbage collected)"
199+
else
200+
echo "ERROR: Generation not found: ${gen_id}" >&2
201+
exit 1
202+
fi
203+
;;
204+
unprotect)
205+
[[ ${#ARGS[@]} -eq 0 ]] && { echo "ERROR: Specify generation to unprotect" >&2; exit 1; }
206+
gen_id="${ARGS[0]}"
207+
acquire_lock
208+
# Validate gen_id format
209+
if [[ ! "$gen_id" =~ ^[0-9]{8}-[0-9]{6}(-.+)?$ ]]; then
210+
echo "ERROR: Invalid generation ID format: ${gen_id}" >&2
211+
exit 1
212+
fi
213+
# Check if protected
214+
if [[ ! -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
215+
echo "Generation ${gen_id} is not protected"
216+
exit 0
217+
fi
218+
rm -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected"
219+
echo "Unprotected: ${gen_id}"
220+
;;
114221
esac

‎completions/_atomic-gc‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ _atomic_gc_gens() {
2525

2626
esp="$(_atomic_gc_esp)"
2727

28-
for f in "$esp"/EFI/Linux/arch-*.efi(N); do
28+
for f in "$esp"/EFI/Linux/(arch-|0-active-arch-)*.efi(N); do
2929
name="${f:t}"
30+
name="${name#0-active-}"
3031
name="${name#arch-}"
3132
name="${name%.efi}"
3233
gens+=("$name")
@@ -46,14 +47,18 @@ _atomic-gc() {
4647
'1:command:((
4748
list\:"List all generations"
4849
rm\:"Delete specific generation(s)"
50+
activate\:"Mark generation as active"
51+
deactivate\:"Remove active marker"
52+
protect\:"Protect generation from GC"
53+
unprotect\:"Remove protection from generation"
4954
))' \
5055
'*::arg:->args' \
5156
&& return
5257

5358
case $state in
5459
args)
5560
case ${words[1]} in
56-
rm)
61+
rm|activate|deactivate|protect|unprotect)
5762
_arguments \
5863
'(-n --dry-run)'{-n,--dry-run}'[Show what would be done]' \
5964
'(-y --yes)'{-y,--yes}'[Skip confirmation]' \

‎completions/atomic-gc.bash‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,21 @@ _atomic_gc() {
2020
fi
2121

2222
if [[ $cword -eq 1 ]]; then
23-
COMPREPLY=( $(compgen -W "list rm -h --help -V --version -n --dry-run" -- "$cur") )
23+
COMPREPLY=( $(compgen -W "list rm activate deactivate protect unprotect -h --help -V --version -n --dry-run" -- "$cur") )
2424
return
2525
fi
2626

2727
case "${words[1]}" in
28-
rm)
28+
rm|activate|deactivate|protect|unprotect)
2929
if [[ "$cur" == -* ]]; then
30-
COMPREPLY=( $(compgen -W "-n --dry-run -y --yes" -- "$cur") )
30+
[[ "${words[1]}" == "rm" ]] && COMPREPLY=( $(compgen -W "-n --dry-run -y --yes" -- "$cur") )
3131
else
3232
local -a gens=()
3333
local f name
34-
for f in "$esp"/EFI/Linux/arch-*.efi; do
34+
for f in "$esp"/EFI/Linux/*arch-*.efi; do
3535
[[ -e "$f" ]] || continue
3636
name="${f##*/}"
37+
name="${name#0-active-}"
3738
name="${name#arch-}"
3839
name="${name%.efi}"
3940
gens+=("$name")

‎lib/atomic/common.sh‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -514,10 +514,13 @@ list_generations() {
514514
local _had_nullglob=false
515515
shopt -q nullglob && _had_nullglob=true
516516
shopt -s nullglob
517-
for f in "${ESP}/EFI/Linux/arch-"*.efi; do
517+
for f in "${ESP}/EFI/Linux/"*arch-*.efi; do
518+
[[ -f "$f" ]] || continue
518519
local name="${f##*/}"
520+
name="${name#0-active-}"
519521
name="${name#arch-}"
520522
name="${name%.efi}"
523+
[[ "$name" == *.protected ]] && continue
521524
gens+=("$name")
522525
done
523526
if $_had_nullglob; then
@@ -646,6 +649,11 @@ garbage_collect() {
646649
continue
647650
fi
648651

652+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
653+
to_keep+=("$gen_id (protected)")
654+
continue
655+
fi
656+
649657
count=$((count + 1))
650658
if [[ $count -le $keep ]]; then
651659
to_keep+=("$gen_id")
@@ -680,7 +688,16 @@ garbage_collect() {
680688
local gen="${name#root-}"
681689
[[ "$name" == "$current_subvol" ]] && continue
682690
[[ "$gen" =~ ^[0-9]{8}-[0-9]{6} ]] || continue
683-
if [[ ! -f "${ESP}/EFI/Linux/arch-${gen}.efi" ]]; then
691+
local uki_exists=0
692+
if [[ -f "${ESP}/EFI/Linux/arch-${gen}.efi" ]] || \
693+
[[ -f "${ESP}/EFI/Linux/0-active-arch-${gen}.efi" ]]; then
694+
uki_exists=1
695+
fi
696+
if [[ $uki_exists -eq 0 ]]; then
697+
if [[ -f "${ESP}/EFI/Linux/arch-${gen}.efi.protected" ]]; then
698+
echo " REFUSE: ${gen} is protected (remove protection first)"
699+
continue
700+
fi
684701
echo " Orphan: ${name} (no UKI)"
685702
if [[ "$dry_run" -eq 0 ]]; then
686703
btrfs subvolume delete "$d" 2>/dev/null ||
@@ -689,9 +706,10 @@ garbage_collect() {
689706
fi
690707
done
691708

692-
for uki in "${ESP}/EFI/Linux/arch-"*.efi; do
709+
for uki in "${ESP}/EFI/Linux/"{arch-,0-active-arch-}*.efi; do
693710
[[ -e "$uki" ]] || continue
694711
local uki_name="${uki##*/}"
712+
uki_name="${uki_name#0-active-}"
695713
uki_name="${uki_name#arch-}"; uki_name="${uki_name%.efi}"
696714
[[ "root-${uki_name}" == "$current_subvol" ]] && continue
697715
[[ "$uki_name" =~ ^[0-9]{8}-[0-9]{6} ]] || continue
@@ -793,13 +811,20 @@ delete_generation() {
793811
return 1
794812
fi
795813

814+
if [[ -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected" ]]; then
815+
echo " REFUSE: ${gen_id} is protected (remove protection first)" >&2
816+
return 1
817+
fi
818+
796819
if [[ "$dry_run" -eq 1 ]]; then
797820
echo " Would delete: ${gen_id}"
798821
return 0
799822
fi
800823

801824
echo " Deleting: ${gen_id}"
802825
rm -f "${ESP}/EFI/Linux/arch-${gen_id}.efi"
826+
rm -f "${ESP}/EFI/Linux/0-active-arch-${gen_id}.efi"
827+
rm -f "${ESP}/EFI/Linux/arch-${gen_id}.efi.protected"
803828
if [[ -d "${BTRFS_MOUNT}/root-${gen_id}" ]]; then
804829
btrfs subvolume delete "${BTRFS_MOUNT}/root-${gen_id}" 2>/dev/null || {
805830
echo " WARN: Failed to delete subvolume root-${gen_id}" >&2

‎man/atomic-gc.8‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ atomic\-gc \[em] manage atomic\-upgrade generations
1212
\f[B]atomic\-gc\f[R] \f[B]rm\f[R] [\f[B]\-n\f[R]|\f[B]\-\-dry\-run\f[R]]
1313
[\f[B]\-y\f[R]|\f[B]\-\-yes\f[R]] \f[I]GEN_ID\f[R]
1414
[\f[I]GEN_ID\f[R]\&...]
15+
.PP
16+
\f[B]atomic\-gc\f[R] \f[B]activate\f[R] \f[I]GEN_ID\f[R]
17+
.PP
18+
\f[B]atomic\-gc\f[R] \f[B]deactivate\f[R] \f[I]GEN_ID\f[R]
19+
.PP
20+
\f[B]atomic\-gc\f[R] \f[B]protect\f[R] \f[I]GEN_ID\f[R]
21+
.PP
22+
\f[B]atomic\-gc\f[R] \f[B]unprotect\f[R] \f[I]GEN_ID\f[R]
1523
.SH DESCRIPTION
1624
\f[B]atomic\-gc\f[R] manages generations created by
1725
\f[B]atomic\-upgrade\f[R](8).
@@ -51,9 +59,32 @@ Does not require root.
5159
\f[B]rm\f[R] \f[I]GEN_ID\f[R] [\f[I]GEN_ID\f[R]\&...]
5260
Delete specific generation(s).
5361
Removes both the UKI file and the Btrfs subvolume.
54-
Refuses to delete the currently booted generation.
62+
Refuses to delete the currently booted or protected generation.
5563
Prompts for confirmation unless \f[B]\-y\f[R] is given.
5664
Requires root.
65+
.TP
66+
\f[B]activate\f[R] \f[I]GEN_ID\f[R]
67+
Mark a generation as active by adding a \f[CR]0\-active\-\f[R] prefix to
68+
its UKI file.
69+
The UEFI firmware loads files alphabetically, so this forces the
70+
generation to boot first.
71+
Requires root.
72+
.TP
73+
\f[B]deactivate\f[R] \f[I]GEN_ID\f[R]
74+
Remove the active marker from a generation.
75+
Renames the UKI file by removing the \f[CR]0\-active\-\f[R] prefix.
76+
Requires root.
77+
.TP
78+
\f[B]protect\f[R] \f[I]GEN_ID\f[R]
79+
Protect a generation from garbage collection by creating a sidecar file
80+
(\f[CR]arch\-GEN_ID.efi.protected\f[R]).
81+
Protected generations are skipped during garbage collection.
82+
Requires root.
83+
.TP
84+
\f[B]unprotect\f[R] \f[I]GEN_ID\f[R]
85+
Remove protection from a generation by deleting its
86+
\f[CR].protected\f[R] sidecar file.
87+
Requires root.
5788
.SH OPTIONS
5889
.TP
5990
\f[B]\-n\f[R], \f[B]\-\-dry\-run\f[R]
@@ -106,6 +137,30 @@ Delete multiple generations without confirmation:
106137
.EX
107138
sudo atomic\-gc rm \-y 20260217\-143022 20260216\-235122
108139
.EE
140+
.PP
141+
Mark a generation as active (boots first):
142+
.IP
143+
.EX
144+
sudo atomic\-gc activate 20260217\-143022
145+
.EE
146+
.PP
147+
Remove active marker:
148+
.IP
149+
.EX
150+
sudo atomic\-gc deactivate 20260217\-143022
151+
.EE
152+
.PP
153+
Protect a generation from GC:
154+
.IP
155+
.EX
156+
sudo atomic\-gc protect 20260217\-143022
157+
.EE
158+
.PP
159+
Remove protection:
160+
.IP
161+
.EX
162+
sudo atomic\-gc unprotect 20260217\-143022
163+
.EE
109164
.SH SEE ALSO
110165
\f[B]atomic\-upgrade\f[R](8), \f[B]atomic.conf\f[R](5),
111166
\f[B]btrfs\-subvolume\f[R](8)

0 commit comments

Comments
 (0)