-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauto_update_programs.sh
More file actions
482 lines (437 loc) · 19.5 KB
/
Copy pathauto_update_programs.sh
File metadata and controls
482 lines (437 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#!/bin/bash
#
# Auto-update helper: runs a list of update commands at most once every N days,
# triggered when a new interactive shell starts.
#
# This file is meant to be SOURCED from ~/.zshrc or ~/.bashrc, not executed:
# source /path/to/auto_update_programs.sh
# auto_update_check 7 "brew update && brew upgrade" "npm update -g"
#
# An entry that names an existing directory is treated as a local git
# repository instead of a command, and is fast-forwarded when that is possible
# without a merge:
# auto_update_check 7 "brew update" ~/Projects/dotfiles "$HOME/src/notes"
#
# Public functions:
# auto_update_check [days] [command|repo ...] run updates if the interval elapsed
# auto_update_status show last / next update times
# auto_update_reset clear state so the next shell updates
#
# Environment:
# AUTO_UPDATE_DISABLE=1 skip the check entirely (CI, scripts, remote shells)
# AUTO_UPDATE_INTERVAL=N fallback interval for auto_update_status
# AUTO_UPDATE_LOCK_TIMEOUT seconds before a stale lock is broken (default 21600)
# State files. Kept as separate single-purpose files so that an existing
# ~/.auto_update_timestamp from an older version stays readable.
: "${AUTO_UPDATE_STATE_TIMESTAMP:=$HOME/.auto_update_timestamp}"
: "${AUTO_UPDATE_STATE_INTERVAL:=$HOME/.auto_update_interval}"
: "${AUTO_UPDATE_STATE_LOCK:=$HOME/.auto_update_lock}"
# Current time as a Unix epoch, preferring shell builtins over forking `date`.
# This runs on every shell startup, so the fork actually shows up in shell
# start latency. EPOCHSECONDS covers bash 5.0+ and zsh (via zsh/datetime);
# printf '%(%s)T' covers bash 4.2+; `date` is the last resort (e.g. the
# bash 3.2 that ships with macOS).
if [ -n "${ZSH_VERSION:-}" ]; then
zmodload zsh/datetime 2>/dev/null
fi
# Pick the fastest available clock once, at source time, rather than probing
# on every call. Probing `printf '%(%s)T'` at call time is not safe: bash 3.2
# does not understand that conversion and would emit junk into the captured
# value.
if [ -n "${EPOCHSECONDS:-}" ]; then
_AUTO_UPDATE_CLOCK='epochseconds'
elif [ -n "${BASH_VERSINFO:-}" ] && { [ "${BASH_VERSINFO[0]}" -gt 4 ] ||
{ [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; }; then
_AUTO_UPDATE_CLOCK='printf'
else
_AUTO_UPDATE_CLOCK='date'
fi
# Sets _AUTO_UPDATE_NOW rather than printing, because `x=$(_auto_update_now)`
# would fork a subshell and undo the point of avoiding `date`.
_auto_update_now() {
case $_AUTO_UPDATE_CLOCK in
epochseconds) _AUTO_UPDATE_NOW=$EPOCHSECONDS ;;
printf) printf -v _AUTO_UPDATE_NOW '%(%s)T' -1 ;;
*) _AUTO_UPDATE_NOW=$(date +%s) ;;
esac
}
# Read a non-negative integer from a state file into _AUTO_UPDATE_UINT (again
# avoiding a command substitution on the startup path). Returns non-zero
# unless the file holds exactly one run of digits, so callers never feed junk
# into $(( )) — a truncated or hand-edited state file used to abort with a raw
# bash "syntax error in expression" on every single shell start.
_auto_update_read_uint() {
local file=$1 value
_AUTO_UPDATE_UINT=""
[[ -f $file ]] || return 1
read -r value < "$file" 2>/dev/null || return 1
case $value in
"" | *[!0-9]*) return 1 ;;
esac
_AUTO_UPDATE_UINT=$value
}
# Format a Unix epoch as a human-readable date across BSD/macOS (`date -r`)
# and GNU/Linux (`date -d @`). Falls back to the raw epoch rather than
# printing an empty string if neither form works.
_auto_update_format_date() {
local epoch=$1 formatted
formatted=$(date -r "$epoch" 2>/dev/null) ||
formatted=$(date -d "@$epoch" 2>/dev/null) ||
formatted="epoch $epoch"
[[ -n $formatted ]] || formatted="epoch $epoch"
printf '%s' "$formatted"
}
# Extract the binary a command string will invoke, skipping any leading
# VAR=value environment assignments (otherwise `FOO=1 brew update` is read as
# a program literally named "FOO=1" and silently skipped).
_auto_update_command_name() {
local cmd=$1 word=""
while [[ -n $cmd ]]; do
word=${cmd%% *}
# Stop at the first word that isn't a VAR=value assignment. A word
# containing a slash is a path, not an assignment.
if [[ $word != *=* || $word == *[/\\]* ]]; then
break
fi
if [[ $cmd != *' '* ]]; then
word="" # trailing assignment with no command after it
break
fi
cmd=${cmd#* }
done
_AUTO_UPDATE_CMD_NAME=$word
}
# Expand a leading "~" into $HOME. Entries reach us as ordinary strings, and
# the README teaches quoting them, so "~/Projects/dotfiles" arrives unexpanded
# and would otherwise never match the directory test. Sets _AUTO_UPDATE_PATH
# rather than printing, for the same no-fork reason as the helpers above.
_auto_update_expand_home() {
local path=$1
# The tildes are backslash-escaped rather than quoted: both forms match a
# literal "~", but the quoted one trips shellcheck's SC2088.
case $path in
\~ | \~/*)
if [[ -n ${HOME:-} ]]; then
path=${HOME}${path#\~}
fi
;;
esac
_AUTO_UPDATE_PATH=$path
}
# Update one local git repository, but only when that can be done as a
# fast-forward — i.e. without a merge that could raise conflicts. Every other
# state (dirty tree, detached HEAD, local-only commits, real divergence) is
# reported and then left completely untouched; this runs unattended at shell
# startup, so it must never leave a repository half-merged for the user to
# discover later.
#
# Returns 0 when the repo was advanced or was already current, 1 when an
# operation genuinely failed, and 3 when the repo was deliberately skipped.
_auto_update_git_repo() {
local dir=$1
local top git_dir branch upstream remote dirty local_head remote_head base ahead
if ! command -v git >/dev/null 2>&1; then
echo "⏭️ Skipping: $dir (git is not installed)"
return 3
fi
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || top=""
if [[ -z $top ]]; then
if [[ $(git -C "$dir" rev-parse --is-bare-repository 2>/dev/null) == true ]]; then
echo "⏭️ Skipping: $dir (bare repository — no work tree to update)"
else
echo "⏭️ Skipping: $dir (not a git repository)"
fi
return 3
fi
branch=$(git -C "$top" symbolic-ref --quiet --short HEAD 2>/dev/null) || branch=""
if [[ -z $branch ]]; then
echo "⏭️ Skipping: $top (detached HEAD — no branch to fast-forward)"
return 3
fi
# An interrupted merge/rebase/cherry-pick leaves state that a further
# merge would trip over. `rev-parse --git-dir` answers relative to the
# repo when it can, so anchor it before testing the marker files.
git_dir=$(git -C "$top" rev-parse --git-dir 2>/dev/null) || git_dir=""
case $git_dir in
"" | /*) ;;
*) git_dir=$top/$git_dir ;;
esac
if [[ -n $git_dir ]] &&
{ [[ -e $git_dir/MERGE_HEAD ]] || [[ -e $git_dir/CHERRY_PICK_HEAD ]] ||
[[ -e $git_dir/REVERT_HEAD ]] || [[ -d $git_dir/rebase-merge ]] ||
[[ -d $git_dir/rebase-apply ]]; }; then
echo "⏭️ Skipping: $top (a merge, rebase or cherry-pick is in progress)"
return 3
fi
# Tracked-file changes only: untracked files are common (build output,
# editor scratch) and a fast-forward that would clobber one is refused by
# git itself before anything is written.
dirty=$(git -C "$top" status --porcelain --untracked-files=no 2>/dev/null) || dirty=""
if [[ -n $dirty ]]; then
echo "⏭️ Skipping: $top (uncommitted changes — commit or stash them first)"
return 3
fi
upstream=$(git -C "$top" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) || upstream=""
if [[ -z $upstream ]]; then
echo "⏭️ Skipping: $top (branch '$branch' tracks no upstream)"
return 3
fi
remote=$(git -C "$top" config --get "branch.$branch.remote" 2>/dev/null) || remote=""
if [[ -n $remote && $remote != "." ]]; then
echo "▶️ Fetching: $top ($remote)"
# A repository whose credentials aren't cached would otherwise sit at
# a username prompt with the user's shell still starting up.
# GIT_TERMINAL_PROMPT and ssh BatchMode turn that hang into a fast,
# reportable failure; an explicit GIT_SSH_COMMAND is left alone.
if ! GIT_TERMINAL_PROMPT=0 \
GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}" \
git -C "$top" fetch --quiet "$remote"; then
echo "❌ Failed: $top (could not fetch from '$remote')"
return 1
fi
fi
local_head=$(git -C "$top" rev-parse HEAD 2>/dev/null) || local_head=""
remote_head=$(git -C "$top" rev-parse '@{upstream}' 2>/dev/null) || remote_head=""
if [[ -z $local_head || -z $remote_head ]]; then
echo "⏭️ Skipping: $top (could not resolve '$branch' or '$upstream')"
return 3
fi
if [[ $local_head == "$remote_head" ]]; then
echo "✅ Already current: $top ($branch)"
return 0
fi
# The merge base decides whether this is a fast-forward. Only the case
# "base == local HEAD" replays remote commits onto an unchanged local
# branch; everything else needs a merge commit, which is exactly what an
# unattended update must not attempt.
base=$(git -C "$top" merge-base HEAD '@{upstream}' 2>/dev/null) || base=""
if [[ $base != "$local_head" ]]; then
if [[ -z $base ]]; then
echo "⏭️ Skipping: $top ('$branch' and '$upstream' share no history)"
elif [[ $base == "$remote_head" ]]; then
echo "⏭️ Skipping: $top ('$branch' is ahead of '$upstream' — nothing to pull)"
else
echo "⏭️ Skipping: $top ('$branch' has diverged from '$upstream' — merge it yourself)"
fi
return 3
fi
ahead=$(git -C "$top" rev-list --count HEAD..'@{upstream}' 2>/dev/null) || ahead=""
case $ahead in
"" | *[!0-9]*) ahead="" ;;
esac
# --ff-only rather than `git pull`: it ignores a pull.rebase setting, and
# it refuses (without touching the work tree) if local files would be
# overwritten.
if git -C "$top" merge --ff-only --quiet '@{upstream}'; then
if [[ -n $ahead ]]; then
echo "✅ Updated: $top ($branch fast-forwarded $ahead commit(s) from $upstream)"
else
echo "✅ Updated: $top ($branch fast-forwarded to $upstream)"
fi
return 0
fi
echo "❌ Failed: $top (fast-forward of '$branch' did not apply)"
return 1
}
auto_update_check() {
if [[ -n "${AUTO_UPDATE_DISABLE:-}" ]]; then
return 0
fi
# Take the interval only when an argument was actually supplied. A bare
# `shift` on an empty argument list returns non-zero, which aborted the
# whole function (and could exit the shell) under `set -e`.
# Written as a plain `if` rather than `[[ -n $1 ]] && interval_days=$1`,
# because that idiom yields a non-zero status when the test fails and its
# interaction with `set -e` varies between shells.
local interval_days=7
if (( $# > 0 )); then
if [[ -n $1 ]]; then
interval_days=$1
fi
shift
fi
# Reject a non-numeric interval with a real message. Previously this hit
# bash arithmetic and produced "syntax error in expression", which is
# exactly what `auto_update_check "brew update"` (forgotten interval)
# printed on every new terminal.
case $interval_days in
*[!0-9]* | "")
echo "auto_update_check: interval must be a whole number of days, got '$interval_days'" >&2
echo "auto_update_check: usage: auto_update_check [days] \"command\" ..." >&2
return 2
;;
esac
_auto_update_now
local current_time=$_AUTO_UPDATE_NOW
local interval_seconds=$((interval_days * 24 * 3600))
local AUTO_UPDATE_COMMANDS=("$@")
if [[ ${#AUTO_UPDATE_COMMANDS[@]} -eq 0 ]]; then
AUTO_UPDATE_COMMANDS=(
"conda update -n base -c defaults conda -y"
"brew update && brew upgrade"
"npm update -g"
)
fi
# Fast path: bail out before doing any other work. This is the branch
# taken on virtually every shell start, so it stays free of subprocesses.
if _auto_update_read_uint "$AUTO_UPDATE_STATE_TIMESTAMP"; then
if (( current_time - _AUTO_UPDATE_UINT < interval_seconds )); then
return 0
fi
fi
# Serialize across shells. Opening several tabs or a tmux session at login
# otherwise starts one `brew upgrade` per shell simultaneously, which then
# fight over the same package-manager locks. mkdir is atomic on POSIX
# filesystems, so exactly one shell wins.
local lock_timeout=${AUTO_UPDATE_LOCK_TIMEOUT:-21600}
if ! mkdir "$AUTO_UPDATE_STATE_LOCK" 2>/dev/null; then
# Another shell holds the lock. Only reclaim it when it is provably
# old: a lock whose started_at is not written yet belongs to a shell
# that acquired it microseconds ago, so it counts as live. Treating
# "no started_at" as stale would let every contender break straight
# back in, which is the race this lock exists to prevent.
if ! _auto_update_read_uint "$AUTO_UPDATE_STATE_LOCK/started_at" ||
(( current_time - _AUTO_UPDATE_UINT < lock_timeout )); then
return 0
fi
# Genuinely stale: a previous run died before it could clean up.
rm -rf "$AUTO_UPDATE_STATE_LOCK" 2>/dev/null
mkdir "$AUTO_UPDATE_STATE_LOCK" 2>/dev/null || return 0
fi
printf '%s\n' "$current_time" > "$AUTO_UPDATE_STATE_LOCK/started_at" 2>/dev/null
# Re-check under the lock: a shell that held it while we were waiting may
# have just finished a run, in which case the interval has been satisfied.
if _auto_update_read_uint "$AUTO_UPDATE_STATE_TIMESTAMP" &&
(( current_time - _AUTO_UPDATE_UINT < interval_seconds )); then
rm -rf "$AUTO_UPDATE_STATE_LOCK" 2>/dev/null
return 0
fi
# Claim the interval up front. Recording it only after the commands finish
# meant an interrupted run (Ctrl-C during a long `brew upgrade`) left no
# timestamp, so every following shell started the whole thing again.
if ! printf '%s\n' "$current_time" > "$AUTO_UPDATE_STATE_TIMESTAMP" 2>/dev/null; then
echo "⚠️ Could not write $AUTO_UPDATE_STATE_TIMESTAMP; updates will run again next shell." >&2
fi
# Record the interval too, so auto_update_status can report the real next
# run instead of assuming 7 days.
printf '%s\n' "$interval_days" > "$AUTO_UPDATE_STATE_INTERVAL" 2>/dev/null
echo "🔄 Running auto-update (interval: ${interval_days} days)..."
local cmd entry cmd_name outcome failed=0 ran=0 skipped=0
for cmd in "${AUTO_UPDATE_COMMANDS[@]}"; do
_auto_update_expand_home "$cmd"
entry=$_AUTO_UPDATE_PATH
# An entry naming an existing directory is a git repository to
# fast-forward, not a command to eval. No command line is also a
# directory, so the test can't misfire on one.
if [[ -d $entry ]]; then
# Captured through `if` so that a non-zero return doesn't abort
# the loop under `set -e`.
if _auto_update_git_repo "$entry"; then
outcome=0
else
outcome=$?
fi
if (( outcome == 0 )); then
ran=$((ran + 1))
echo
elif (( outcome == 1 )); then
failed=$((failed + 1))
echo
else
skipped=$((skipped + 1))
fi
continue
fi
# A single word containing a slash that exists nowhere on disk is a
# mistyped repository path; "command not found" would send the user
# looking in the wrong place. Entries with whitespace are command
# lines, whose arguments legitimately mention paths.
if [[ ! -e $entry ]]; then
case $entry in
*[[:space:]]*) ;;
*/*)
echo "⏭️ Skipping: $cmd (no such file or directory)"
skipped=$((skipped + 1))
continue
;;
esac
fi
_auto_update_command_name "$cmd"
cmd_name=$_AUTO_UPDATE_CMD_NAME
# Only the leading binary of an entry is checked; a command further
# along a `&&` chain that is missing will surface as a normal failure.
if [[ -z $cmd_name ]] || ! command -v "$cmd_name" >/dev/null 2>&1; then
echo "⏭️ Skipping: $cmd (command '${cmd_name:-?}' not found)"
skipped=$((skipped + 1))
continue
fi
echo "▶️ Executing: $cmd"
if eval "$cmd"; then
echo "✅ Completed: $cmd"
ran=$((ran + 1))
else
echo "❌ Failed: $cmd"
failed=$((failed + 1))
fi
echo
done
# The timestamp was claimed before the loop, so it stands whether or not
# individual commands succeeded. Retrying a permanently broken command on
# every single shell start would be far more disruptive than waiting for
# the next interval; `auto_update_reset` is the escape hatch.
rm -rf "$AUTO_UPDATE_STATE_LOCK" 2>/dev/null
if (( failed > 0 )); then
echo "✨ Auto-update finished: ${ran} succeeded, ${failed} failed, ${skipped} skipped. Next run in ${interval_days} days."
else
echo "✨ Auto-update completed. Next run in ${interval_days} days."
fi
return 0
}
# Clear all state so the next shell runs updates immediately.
auto_update_reset() {
rm -f "$AUTO_UPDATE_STATE_TIMESTAMP" "$AUTO_UPDATE_STATE_INTERVAL"
rm -rf "$AUTO_UPDATE_STATE_LOCK"
echo "Auto-update timestamp reset. Next terminal start will trigger updates."
}
# Report when updates last ran and when they are next due.
auto_update_status() {
local last_run
if _auto_update_read_uint "$AUTO_UPDATE_STATE_TIMESTAMP"; then
last_run=$_AUTO_UPDATE_UINT
else
if [[ -e $AUTO_UPDATE_STATE_TIMESTAMP ]]; then
echo "Auto-update state file $AUTO_UPDATE_STATE_TIMESTAMP is unreadable or corrupt."
echo "Run 'auto_update_reset' to clear it."
return 1
fi
echo "Auto-update has never been run."
return 0
fi
echo "Last auto-update: $(_auto_update_format_date "$last_run")"
# Prefer the interval actually used by the last run, then an explicitly
# exported override, then the documented default.
local interval_days
if _auto_update_read_uint "$AUTO_UPDATE_STATE_INTERVAL"; then
interval_days=$_AUTO_UPDATE_UINT
else
interval_days=${AUTO_UPDATE_INTERVAL:-7}
case $interval_days in
*[!0-9]* | "") interval_days=7 ;;
esac
fi
local next_run=$((last_run + interval_days * 24 * 3600))
echo "Next auto-update: $(_auto_update_format_date "$next_run") (every ${interval_days} days)"
if [[ -d $AUTO_UPDATE_STATE_LOCK ]]; then
echo "Note: an update is currently in progress (or a stale lock remains at $AUTO_UPDATE_STATE_LOCK)."
fi
}
# Examples for .zshrc/.bashrc:
# auto_update_check 7 "conda update -n base -c defaults conda -y" "brew update && brew upgrade"
#
# Or with an array:
# MY_UPDATE_COMMANDS=(
# "conda update -n base -c defaults conda -y"
# "brew update && brew upgrade"
# "npm update -g"
# )
# auto_update_check 7 "${MY_UPDATE_COMMANDS[@]}"