Skip to content

Latest commit

 

History

History
251 lines (197 loc) · 11 KB

File metadata and controls

251 lines (197 loc) · 11 KB

CLAUDE.md

Guidance for Claude Code when working in this repository.

What this is

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.

Repository layout

Path Role
auto_update_programs.sh The entire implementation
README.md End-user documentation
CLAUDE.md This file
LICENSE MIT

The one constraint that shapes everything

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:

  1. 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 0 using shell builtins only. $(date +%s), $(cat file), and pipelines like echo "$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_now and _auto_update_read_uint assign to _AUTO_UPDATE_NOW / _AUTO_UPDATE_UINT instead of printing — x=$(_auto_update_now) would reintroduce the fork this design removes.

  2. Errors are printed to a real person's terminal. Anything that reaches stderr shows up every time they open a tab. Bad state on disk or a bad argument must produce a clear message or silence — never a raw syntax error in expression from bash arithmetic.

Shell portability rules

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). Use case for pattern tests — it needs no options and works in both shells.
  • Don't index arrays directly; bash is 0-indexed and zsh is 1-indexed. "${arr[@]}" and ${#arr[@]} are safe.
  • Assume users may have set -e, set -u, or set -o pipefail active. Reference possibly-unset variables as ${VAR:-}, and never let a builtin's non-zero exit escape (a bare shift on an empty argument list returns 1 and under set -e aborted 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 with zsh/datetime), then printf '%(%s)T' (bash 4.2+), then date (macOS's stock bash 3.2). Don't probe printf '%(%s)T' at call time — bash 3.2 emits junk into the captured value rather than failing cleanly.

Git repository entries

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, not git pull. pull obeys the user's pull.rebase, which would rewrite their commits unattended.
  • The fetch runs with GIT_TERMINAL_PROMPT=0 and an ssh BatchMode=yes default. 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-dir returns a path relative to the repo, so anchor it under the toplevel before probing MERGE_HEAD and 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 under set -e.

Public API — treat as stable

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_status
  • auto_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.

Concurrency

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_at is missing counts as live, not stale. The winner writes that file microseconds after mkdir; 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.

Testing

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_status

Cases 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.5s

Git 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.

Linting

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.

Running the suite under zsh

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 pass

KSH_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 silent

Documentation

README.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.