Guidance for Claude Code when working in this repository.
A single sourced shell library, auto_update_programs.sh, that runs a
user-supplied list of update commands — and fast-forwards any local git
repositories in that list — at most once every N days. It is wired into
~/.zshrc / ~/.bashrc so the check happens when a terminal opens.
There is no build, no dependency manifest, and no test runner — the repository is the script, a README, and a license.
| Path | Role |
|---|---|
auto_update_programs.sh |
The entire implementation |
README.md |
End-user documentation |
CLAUDE.md |
This file |
LICENSE |
MIT |
This code runs on every interactive shell start. Two consequences drive most of the design, and changes that ignore them will regress the script even when they look correct:
-
The early-return path must not fork. When the interval hasn't elapsed — which is the overwhelmingly common case — the function must reach its
return 0using shell builtins only.$(date +%s),$(cat file), and pipelines likeecho "$x" | awk '{print $1}'each cost a subprocess, and they land directly in the user's shell startup latency. An earlier version spent ~3.1 ms per shell start here; the builtin-only path costs ~0.07 ms.Note that a command substitution forks even when the function it calls is pure builtins. That is why
_auto_update_nowand_auto_update_read_uintassign to_AUTO_UPDATE_NOW/_AUTO_UPDATE_UINTinstead of printing —x=$(_auto_update_now)would reintroduce the fork this design removes. -
Errors are printed to a real person's terminal. Anything that reaches
stderrshows up every time they open a tab. Bad state on disk or a bad argument must produce a clear message or silence — never a rawsyntax error in expressionfrom bash arithmetic.
The file is sourced into both bash and zsh, so stick to constructs valid
in both:
- No
shopt-dependent syntax (e.g.+([0-9])extglob patterns). Usecasefor pattern tests — it needs no options and works in both shells. - Don't index arrays directly;
bashis 0-indexed andzshis 1-indexed."${arr[@]}"and${#arr[@]}are safe. - Assume users may have
set -e,set -u, orset -o pipefailactive. Reference possibly-unset variables as${VAR:-}, and never let a builtin's non-zero exit escape (a bareshifton an empty argument list returns 1 and underset -eaborted the whole function). - Never install a
trap. The functions run inside the user's interactive shell, so a trap would persist and affect everything they subsequently do. Cleanup is done explicitly on each return path, with the stale-lock timeout as the backstop for an interrupted run. - Clock access is resolved once at source time into
_AUTO_UPDATE_CLOCK:EPOCHSECONDS(bash 5+/zsh withzsh/datetime), thenprintf '%(%s)T'(bash 4.2+), thendate(macOS's stock bash 3.2). Don't probeprintf '%(%s)T'at call time — bash 3.2 emits junk into the captured value rather than failing cleanly.
An entry in the command list that names an existing directory is dispatched to
_auto_update_git_repo instead of eval. The test is [[ -d $entry ]] after
a leading ~ is expanded (entries arrive as strings, and the README teaches
quoting them, so "~/src/x" would otherwise never match). Both the directory
test and everything it leads to sit after the interval check, so the hot path
is unaffected.
The rule this implements is "update it only if that needs no merge", and the mechanism is the merge base:
merge-base of HEAD and @{upstream} |
meaning | action |
|---|---|---|
| equals HEAD | behind only | fast-forward |
| equals upstream | ahead only | skip |
| neither | diverged | skip |
| absent | unrelated histories | skip |
Details that are easy to undo by accident:
- Use
git merge --ff-only, notgit pull.pullobeys the user'spull.rebase, which would rewrite their commits unattended. - The fetch runs with
GIT_TERMINAL_PROMPT=0and an sshBatchMode=yesdefault. Without them a repository whose credentials aren't cached stops shell startup at a username prompt — verified by fetching from a 401 server under a real pty. - Only tracked-file changes count as dirty. Untracked files are normal, and a fast-forward that would clobber one is refused by git itself before it writes anything (that path is reported as a failure, not a skip).
rev-parse --git-dirreturns a path relative to the repo, so anchor it under the toplevel before probingMERGE_HEADand friends. A linked worktree reports.git/worktrees/<name>, which this handles.- The helper's return codes feed the loop's counters: 0 ran, 1 failed, 3
skipped. Read them via
if _auto_update_git_repo ...; then— a bare call followed by$?aborts the loop underset -e.
Users have these in their shell config files. Renaming or changing their
signatures breaks working setups on the next git pull:
auto_update_check [days] [command ...]auto_update_statusauto_update_reset
Helpers prefixed _auto_update_* are internal and may change freely.
State files must also stay backward compatible: ~/.auto_update_timestamp is
a bare Unix epoch, and older installs have one with no sibling
~/.auto_update_interval. Reading must tolerate its absence.
Opening several tabs, or a tmux session restoring panes, starts many shells at
once. Without a guard they all pass the interval check together and launch
concurrent brew upgrade runs that fight over package-manager locks.
~/.auto_update_lock is a directory taken with mkdir, which is atomic on
POSIX filesystems. Two rules keep it correct:
- A lock whose
started_atis missing counts as live, not stale. The winner writes that file microseconds aftermkdir; treating the gap as staleness lets every contender break straight back in — which defeats the lock entirely and is easy to reintroduce. - After acquiring the lock, re-read the timestamp. A shell that held the lock while this one waited may have just completed a run.
The timestamp is claimed before the commands run, so an interrupted update
doesn't restart on every following terminal. It is deliberately kept even when
commands fail — otherwise one permanently broken entry retries forever, once
per shell start. auto_update_reset is the escape hatch.
There is no committed test suite. Verify changes by sourcing the script under
a throwaway HOME so real state is untouched:
export HOME=$(mktemp -d)
source ./auto_update_programs.sh
auto_update_check 0 "echo hello" # interval 0 bypasses the time check
auto_update_statusCases worth re-checking after any edit — each corresponds to a bug that has been fixed here at least once:
# Corrupt / truncated state must not print bash arithmetic errors
printf '12abc' > "$HOME/.auto_update_timestamp"; auto_update_check 7 "echo x"
# A forgotten interval must give a real message, not a syntax error
auto_update_check "brew update"
# Strict mode must survive
bash -c 'set -euo pipefail; source ./auto_update_programs.sh; auto_update_check 0 "echo ok"; auto_update_status'
# Concurrent shells must produce exactly one run
export HOME=$(mktemp -d); : > "$HOME/log"
for i in $(seq 1 12); do
( source ./auto_update_programs.sh; auto_update_check 7 "echo r >> $HOME/log" ) >/dev/null 2>&1 &
done; wait; wc -l < "$HOME/log" # must be 1
# Hot path must stay fork-free
export HOME=$(mktemp -d); date +%s > "$HOME/.auto_update_timestamp"
source ./auto_update_programs.sh
time (for i in $(seq 1 500); do auto_update_check 7 "echo x"; done) # ~0.04s, not ~1.5sGit entries need a throwaway origin/clone pair to exercise. Build one with
git init -b main plus git clone, then advance the origin and check that
each state does the right thing — fast-forward, already current, dirty tree,
local commits, divergence, detached HEAD, no upstream, plain directory, bare
repository, interrupted merge, unreachable remote, a colliding untracked file,
a path inside a repository, and a quoted ~ path. In every skip case, assert
that git rev-parse HEAD is unchanged — "reported it" and "left it alone"
are separate claims and both matter:
before=$(git -C "$clone" rev-parse HEAD)
printf 'edit\n' >> "$clone/tracked-file"
auto_update_check 0 "$clone" # must skip, not merge
[ "$before" = "$(git -C "$clone" rev-parse HEAD)" ] || echo "BROKE: repo was touched"One command form that must keep working: an entry whose arguments mention a
path (pip install -r /nope/req.txt) is still a command. Only a whitespace-free
entry is eligible for the missing-path message.
The file is clean under shellcheck -s bash -S style auto_update_programs.sh
(v0.9.0) and should stay that way. One trap to know about: the clock sentinels
are assigned as quoted strings (_AUTO_UPDATE_CLOCK='date') because the bare
words date and printf trigger SC2209 — shellcheck reads them as an attempt
to capture command output.
The commands above are shell-agnostic, so run them under both interpreters:
bash suite.sh and zsh suite.sh exercise the same script through different
parsers. Both currently pass every case.
zsh users frequently set options that change parsing, so a change is not verified until it survives them:
for opt in NO_UNSET ERR_EXIT SH_WORD_SPLIT KSH_ARRAYS NULL_GLOB NOMATCH; do
zsh -c "setopt $opt; export HOME=\$(mktemp -d); source ./auto_update_programs.sh
auto_update_check 0 'echo ok' >/dev/null; auto_update_status" || echo "BROKE under $opt"
done
zsh -c "emulate sh; ..." # also worth a passKSH_ARRAYS is the one most likely to break a careless change, since it flips
zsh's array indexing to 0-based.
The end-to-end check is a real interactive shell reading an rc file, which is how the script is actually used:
H=$(mktemp -d)
printf 'source %s/auto_update_programs.sh\nauto_update_check 7 "echo RAN"\n' "$PWD" > $H/.zshrc
HOME=$H ZDOTDIR=$H zsh -i -c 'echo prompt' # first login updates
HOME=$H ZDOTDIR=$H zsh -i -c 'echo prompt' # second login is silentREADME.md is user-facing and should stay honest about the sharp edges rather
than only listing features. Three claims in particular have been wrong before
and should be re-checked whenever behavior changes:
- Command validation only inspects the leading program of an entry, so a
missing binary later in a
&&chain is not caught in advance. - The timestamp is recorded regardless of whether commands succeeded.
- Updates run synchronously and delay the prompt of the terminal that triggered them.
- Repositories are only ever fast-forwarded. The README's table of per-state outcomes is a promise about what the script will not do to someone's working tree; keep it matching the code exactly.