From edc265f5afe51f7a0f0aca6e9c0373b090806cc7 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Wed, 29 Jul 2026 11:27:49 -0400 Subject: [PATCH 1/4] symlinks.sh: detect and repoint dangling LaunchAgents symlinks (gt-ap4x) When a repo plist moves (e.g. a platform-gating move into arm64-macos/), the existing ~/Library/LaunchAgents symlink is left dangling and launchd keeps running the stale in-memory job until the next reboot's re-scan -- which can itself silently fail on a still-dangling symlink. Proactively scan for dangling LaunchAgents symlinks, repoint them to the current repo plist location using the same two-tier lookup as the existing linking loops, and force launchctl unload/load so the fix takes effect immediately. --- symlinks.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/symlinks.sh b/symlinks.sh index c15a211..850bc10 100755 --- a/symlinks.sh +++ b/symlinks.sh @@ -30,9 +30,49 @@ link_directory_contents home mkdir -p "$HOME/.config" link_directory_contents config +# Detect ~/Library/LaunchAgents symlinks that are dangling because their repo +# plist moved (e.g. a platform-gating move like LaunchAgents/foo.plist -> +# LaunchAgents/arm64-macos/foo.plist). Repoint them and force launchd to +# reload, since launchd runs an already-loaded job from memory and won't +# notice the plist moved until the next full re-scan (reboot) -- and per bean +# gt-ap4x, that re-scan can silently fail on a still-dangling symlink. +repoint_dangling_launchagents() { + local agents_dir="$HOME/Library/LaunchAgents" + [ -d "$agents_dir" ] || return 0 + + for target in "$agents_dir"/*.plist; do + [ -e "$target" ] && continue # not dangling (valid symlink, real file, or no match) + [ -L "$target" ] || continue # unmatched glob literal / not a symlink at all + + local name current_repo_path + name="$(basename "$target")" + + current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" + if [ -z "$current_repo_path" ] && running_arm64_macos; then + current_repo_path="$(find "$DIR/LaunchAgents/arm64-macos" -maxdepth 1 -name "$name" -type f)" + fi + + if [ -z "$current_repo_path" ]; then + echo "⚠️ $target -> dangling, no applicable repo plist found (skipping)" + continue + fi + + local relative="${current_repo_path#"$DIR"/}" + echo "🔧 $target -> dangling, repo plist now at $relative" + link "$relative" "$target" + + if [ "$(readlink "$target")" = "$current_repo_path" ]; then + echo "🔄 reloading $name" + launchctl unload "$target" 2> /dev/null || true + launchctl load "$target" 2> /dev/null || true + fi + done +} + # Link LaunchAgents if on macOS if running_macos; then mkdir -p "$HOME/Library/LaunchAgents" + repoint_dangling_launchagents echo "🚀 linking LaunchAgents" for agent in LaunchAgents/*.plist; do if [ -f "$agent" ]; then From 1ff8bd26513fda6df4e13b1c602852f4ded4535f Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Wed, 29 Jul 2026 11:39:10 -0400 Subject: [PATCH 2/4] fix: test repoint_dangling_launchagents, drop bean citation from comment Moves repoint_dangling_launchagents() from symlinks.sh into functions.sh (alongside the link() helper it composes with) so it can be sourced and exercised in isolation, then adds scripts/test-repoint-dangling-launchagents.sh following the scripts/test-link.sh manual-harness pattern: a temp $HOME/Library/LaunchAgents and a temp fake $DIR/LaunchAgents, launchctl stubbed out, asserting a dangling symlink gets repointed (top-level and arm64-macos-fallback cases) and that an unmatched dangling symlink is left alone with a warning rather than touched. Also drops the "per bean gt-ap4x" citation from the code comment -- this repo cites ADRs in code, not beans, since beans are archived once done. The commit that introduced the function already carries the bean ID (gt-ap4x) in its message, which is the right place for it. Co-Authored-By: Claude Sonnet 5 --- functions.sh | 38 +++++++ scripts/test-repoint-dangling-launchagents.sh | 99 +++++++++++++++++++ symlinks.sh | 39 -------- 3 files changed, 137 insertions(+), 39 deletions(-) create mode 100755 scripts/test-repoint-dangling-launchagents.sh diff --git a/functions.sh b/functions.sh index 48c6347..d5338ac 100755 --- a/functions.sh +++ b/functions.sh @@ -153,6 +153,44 @@ link() { fi } +# Detect ~/Library/LaunchAgents symlinks that are dangling because their repo +# plist moved (e.g. a platform-gating move like LaunchAgents/foo.plist -> +# LaunchAgents/arm64-macos/foo.plist). Repoint them and force launchd to +# reload, since launchd runs an already-loaded job from memory and won't +# notice the plist moved until the next full re-scan (reboot). +repoint_dangling_launchagents() { + local agents_dir="$HOME/Library/LaunchAgents" + [ -d "$agents_dir" ] || return 0 + + for target in "$agents_dir"/*.plist; do + [ -e "$target" ] && continue # not dangling (valid symlink, real file, or no match) + [ -L "$target" ] || continue # unmatched glob literal / not a symlink at all + + local name current_repo_path + name="$(basename "$target")" + + current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" + if [ -z "$current_repo_path" ] && running_arm64_macos; then + current_repo_path="$(find "$DIR/LaunchAgents/arm64-macos" -maxdepth 1 -name "$name" -type f)" + fi + + if [ -z "$current_repo_path" ]; then + echo "⚠️ $target -> dangling, no applicable repo plist found (skipping)" + continue + fi + + local relative="${current_repo_path#"$DIR"/}" + echo "🔧 $target -> dangling, repo plist now at $relative" + link "$relative" "$target" + + if [ "$(readlink "$target")" = "$current_repo_path" ]; then + echo "🔄 reloading $name" + launchctl unload "$target" 2> /dev/null || true + launchctl load "$target" 2> /dev/null || true + fi + done +} + brew_bundle() { echo "🍻 running brew bundle" cat Brewfile "Brewfile.${DOTPICKLES_ROLE}" 2> /dev/null | brew bundle --file=- 2>&1 | sed 's/^/ → /' diff --git a/scripts/test-repoint-dangling-launchagents.sh b/scripts/test-repoint-dangling-launchagents.sh new file mode 100755 index 0000000..20ecd1c --- /dev/null +++ b/scripts/test-repoint-dangling-launchagents.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Manual verification for repoint_dangling_launchagents() (functions.sh). +# +# Uses a temp $HOME/Library/LaunchAgents and a temp fake $DIR (repo root) so no +# real LaunchAgents state is touched. Stubs launchctl for the same reason. +# +# Usage: +# ./scripts/test-repoint-dangling-launchagents.sh + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Auto-yes: this harness is non-interactive and link() prompts on a +# repointed/wrong symlink. +export DOTPICKLES_YES=1 + +# Stub launchctl so the harness never touches real launchd state. +launchctl() { + echo " (stub) launchctl $*" + return 0 +} + +# shellcheck source=../functions.sh +source "$REPO_ROOT/functions.sh" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT + +echo "=== Test directory: $TEST_DIR ===" +echo + +# --- Test 1: dangling symlink, matching plist still at the top level -> repointed --- +echo "--- Test 1: dangling symlink, plist found at top level ---" +export DIR="$TEST_DIR/repo1" +export HOME="$TEST_DIR/home1" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.foo.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.foo.plist" "$HOME/Library/LaunchAgents/com.example.foo.plist" + +repoint_dangling_launchagents + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.foo.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/com.example.foo.plist" ]; then + echo "PASS: dangling symlink repointed to current repo plist" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/com.example.foo.plist, got $resolved" +fi +echo + +# --- Test 2: dangling symlink, plist only under arm64-macos/ -> repointed via fallback --- +echo "--- Test 2: dangling symlink, plist found via arm64-macos fallback ---" +export DIR="$TEST_DIR/repo2" +export HOME="$TEST_DIR/home2" +mkdir -p "$DIR/LaunchAgents/arm64-macos" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/arm64-macos/com.example.bar.plist" +ln -s "$DIR/LaunchAgents/com.example.bar.plist" "$HOME/Library/LaunchAgents/com.example.bar.plist" + +# Force the arm64 branch regardless of the machine actually running this +# harness -- running_arm64_macos shells out to `uname -m`, which we can't +# fake portably, so override the function itself in a subshell, scoped to +# this one test only. +( + # shellcheck disable=SC2329 # invoked indirectly, from repoint_dangling_launchagents + running_arm64_macos() { return 0; } + repoint_dangling_launchagents +) + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.bar.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/arm64-macos/com.example.bar.plist" ]; then + echo "PASS: dangling symlink repointed via arm64-macos fallback" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/arm64-macos/com.example.bar.plist, got $resolved" +fi +echo + +# --- Test 3: dangling symlink, no matching plist anywhere -> left alone --- +echo "--- Test 3: dangling symlink, no matching repo plist ---" +export DIR="$TEST_DIR/repo3" +export HOME="$TEST_DIR/home3" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +ln -s "$DIR/LaunchAgents/gone/com.example.baz.plist" "$HOME/Library/LaunchAgents/com.example.baz.plist" + +output="$(repoint_dangling_launchagents 2>&1)" + +if [ -L "$HOME/Library/LaunchAgents/com.example.baz.plist" ] && [ ! -e "$HOME/Library/LaunchAgents/com.example.baz.plist" ]; then + echo "PASS: unmatched dangling symlink left in place (still dangling, not deleted)" +else + echo "FAIL: unmatched dangling symlink was modified or removed" +fi + +if echo "$output" | grep -q "no applicable repo plist found"; then + echo "PASS: warning printed for unmatched dangling symlink" +else + echo "FAIL: expected warning about no applicable repo plist, got: $output" +fi +echo + +echo "=== Done ===" diff --git a/symlinks.sh b/symlinks.sh index 850bc10..c786f55 100755 --- a/symlinks.sh +++ b/symlinks.sh @@ -30,45 +30,6 @@ link_directory_contents home mkdir -p "$HOME/.config" link_directory_contents config -# Detect ~/Library/LaunchAgents symlinks that are dangling because their repo -# plist moved (e.g. a platform-gating move like LaunchAgents/foo.plist -> -# LaunchAgents/arm64-macos/foo.plist). Repoint them and force launchd to -# reload, since launchd runs an already-loaded job from memory and won't -# notice the plist moved until the next full re-scan (reboot) -- and per bean -# gt-ap4x, that re-scan can silently fail on a still-dangling symlink. -repoint_dangling_launchagents() { - local agents_dir="$HOME/Library/LaunchAgents" - [ -d "$agents_dir" ] || return 0 - - for target in "$agents_dir"/*.plist; do - [ -e "$target" ] && continue # not dangling (valid symlink, real file, or no match) - [ -L "$target" ] || continue # unmatched glob literal / not a symlink at all - - local name current_repo_path - name="$(basename "$target")" - - current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" - if [ -z "$current_repo_path" ] && running_arm64_macos; then - current_repo_path="$(find "$DIR/LaunchAgents/arm64-macos" -maxdepth 1 -name "$name" -type f)" - fi - - if [ -z "$current_repo_path" ]; then - echo "⚠️ $target -> dangling, no applicable repo plist found (skipping)" - continue - fi - - local relative="${current_repo_path#"$DIR"/}" - echo "🔧 $target -> dangling, repo plist now at $relative" - link "$relative" "$target" - - if [ "$(readlink "$target")" = "$current_repo_path" ]; then - echo "🔄 reloading $name" - launchctl unload "$target" 2> /dev/null || true - launchctl load "$target" 2> /dev/null || true - fi - done -} - # Link LaunchAgents if on macOS if running_macos; then mkdir -p "$HOME/Library/LaunchAgents" From 5e77dd242392fb4c65fe777971b2d4b2495b82c4 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Wed, 29 Jul 2026 14:43:04 -0400 Subject: [PATCH 3/4] fix: match arm64-macos precedence in repoint_dangling_launchagents symlinks.sh links top-level LaunchAgents first, then arm64-macos/ plists on top -- letting arm64-macos win on a basename collision. repoint_dangling_launchagents() checked top-level first instead, which would have picked the wrong plist for a moved/duplicated agent. Flip the lookup order to match, add a test case that pins the precedence, and document the repoint/reload behavior in LaunchAgents/README.md. Co-Authored-By: Claude Sonnet 5 --- LaunchAgents/README.md | 2 ++ functions.sh | 6 +++-- scripts/test-repoint-dangling-launchagents.sh | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/LaunchAgents/README.md b/LaunchAgents/README.md index 528c136..15aa3a2 100644 --- a/LaunchAgents/README.md +++ b/LaunchAgents/README.md @@ -94,6 +94,8 @@ Agents that only make sense on a specific platform live in a subdirectory whose To add a new gate, add a predicate to `functions.sh` and a matching loop in `symlinks.sh`. Drop plists in the corresponding subdirectory; nothing else changes. +Moving a plist between gates (e.g. top-level -> `arm64-macos/`) leaves behind a dangling `~/Library/LaunchAgents` symlink pointing at the old path. `symlinks.sh` detects these and repoints them to the plist's new location, then force-reloads via `launchctl unload`/`load`. This reload is necessary because a reboot alone isn't enough: launchd caches the already-loaded job in memory and won't notice the plist moved until its next full re-scan, which can itself silently fail if the symlink is still dangling at that point. + Manual installation: ```bash diff --git a/functions.sh b/functions.sh index d5338ac..7d9d6cf 100755 --- a/functions.sh +++ b/functions.sh @@ -169,10 +169,12 @@ repoint_dangling_launchagents() { local name current_repo_path name="$(basename "$target")" - current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" - if [ -z "$current_repo_path" ] && running_arm64_macos; then + if running_arm64_macos && [ -d "$DIR/LaunchAgents/arm64-macos" ]; then current_repo_path="$(find "$DIR/LaunchAgents/arm64-macos" -maxdepth 1 -name "$name" -type f)" fi + if [ -z "$current_repo_path" ]; then + current_repo_path="$(find "$DIR/LaunchAgents" -maxdepth 1 -name "$name" -type f)" + fi if [ -z "$current_repo_path" ]; then echo "⚠️ $target -> dangling, no applicable repo plist found (skipping)" diff --git a/scripts/test-repoint-dangling-launchagents.sh b/scripts/test-repoint-dangling-launchagents.sh index 20ecd1c..3f3559d 100755 --- a/scripts/test-repoint-dangling-launchagents.sh +++ b/scripts/test-repoint-dangling-launchagents.sh @@ -96,4 +96,28 @@ else fi echo +# --- Test 4: dangling symlink, plist exists at BOTH top level and arm64-macos/ -> arm64-macos wins --- +echo "--- Test 4: dangling symlink, plist in both locations, arm64-macos takes precedence ---" +export DIR="$TEST_DIR/repo4" +export HOME="$TEST_DIR/home4" +mkdir -p "$DIR/LaunchAgents/arm64-macos" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.qux.plist" +echo "" > "$DIR/LaunchAgents/arm64-macos/com.example.qux.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.qux.plist" "$HOME/Library/LaunchAgents/com.example.qux.plist" + +# Force the arm64 branch, same technique as Test 2. +( + # shellcheck disable=SC2329 # invoked indirectly, from repoint_dangling_launchagents + running_arm64_macos() { return 0; } + repoint_dangling_launchagents +) + +resolved="$(readlink "$HOME/Library/LaunchAgents/com.example.qux.plist")" +if [ "$resolved" = "$DIR/LaunchAgents/arm64-macos/com.example.qux.plist" ]; then + echo "PASS: dangling symlink repointed to arm64-macos plist (matches symlinks.sh link order)" +else + echo "FAIL: expected repoint to $DIR/LaunchAgents/arm64-macos/com.example.qux.plist, got $resolved" +fi +echo + echo "=== Done ===" From 6165eaae940586a46fc4e9ef427bfb52a422e8ef Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Wed, 29 Jul 2026 14:51:43 -0400 Subject: [PATCH 4/4] fix: reset current_repo_path each loop iteration in repoint_dangling_launchagents Bare `local name current_repo_path` re-declares without assigning, so bash preserves current_repo_path's value across iterations of the for loop. When a later dangling symlink has no matching repo plist, it silently inherited the previous iteration's resolved path and got repointed to the wrong target instead of being correctly reported as unmatched. Explicitly assign `local current_repo_path=""` to reset it every iteration. Adds a regression test (Test 5) with two dangling symlinks in one call -- one matched, one unmatched, in the exact order that exposed the bug -- to close the coverage gap where all prior tests only ever had a single dangling plist per invocation. --- functions.sh | 3 +- scripts/test-repoint-dangling-launchagents.sh | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/functions.sh b/functions.sh index 7d9d6cf..3b65a0c 100755 --- a/functions.sh +++ b/functions.sh @@ -166,7 +166,8 @@ repoint_dangling_launchagents() { [ -e "$target" ] && continue # not dangling (valid symlink, real file, or no match) [ -L "$target" ] || continue # unmatched glob literal / not a symlink at all - local name current_repo_path + local name + local current_repo_path="" name="$(basename "$target")" if running_arm64_macos && [ -d "$DIR/LaunchAgents/arm64-macos" ]; then diff --git a/scripts/test-repoint-dangling-launchagents.sh b/scripts/test-repoint-dangling-launchagents.sh index 3f3559d..29aa0a5 100755 --- a/scripts/test-repoint-dangling-launchagents.sh +++ b/scripts/test-repoint-dangling-launchagents.sh @@ -120,4 +120,43 @@ else fi echo +# --- Test 5: two dangling symlinks in one invocation, matched then unmatched --- +# Regression test for a bug where `local name current_repo_path` (bare +# re-declaration, no assignment) failed to reset current_repo_path between +# loop iterations, so the second (unmatched) symlink silently inherited the +# first (matched) symlink's resolved path instead of being reported as +# unmatched. Glob order matters here: com.example.aaa sorts before +# com.example.zzz, so aaa (matched) is processed first and zzz (unmatched) +# second -- the exact order that exposed the bug. +echo "--- Test 5: multiple dangling symlinks, matched then unmatched, in one call ---" +export DIR="$TEST_DIR/repo5" +export HOME="$TEST_DIR/home5" +mkdir -p "$DIR/LaunchAgents" "$HOME/Library/LaunchAgents" +echo "" > "$DIR/LaunchAgents/com.example.aaa.plist" +ln -s "$DIR/LaunchAgents/old-location/com.example.aaa.plist" "$HOME/Library/LaunchAgents/com.example.aaa.plist" +ln -s "$DIR/LaunchAgents/gone/com.example.zzz.plist" "$HOME/Library/LaunchAgents/com.example.zzz.plist" + +output="$(repoint_dangling_launchagents 2>&1)" + +resolved_aaa="$(readlink "$HOME/Library/LaunchAgents/com.example.aaa.plist")" +if [ "$resolved_aaa" = "$DIR/LaunchAgents/com.example.aaa.plist" ]; then + echo "PASS: matched symlink (aaa) repointed to its own repo plist" +else + echo "FAIL: expected aaa repoint to $DIR/LaunchAgents/com.example.aaa.plist, got $resolved_aaa" +fi + +resolved_zzz="$(readlink "$HOME/Library/LaunchAgents/com.example.zzz.plist")" +if [ "$resolved_zzz" = "$DIR/LaunchAgents/gone/com.example.zzz.plist" ] && [ ! -e "$HOME/Library/LaunchAgents/com.example.zzz.plist" ]; then + echo "PASS: unmatched symlink (zzz) left dangling, not repointed to aaa's target" +else + echo "FAIL: unmatched symlink (zzz) was repointed (got $resolved_zzz), expected to remain dangling at its original target" +fi + +if echo "$output" | grep -q "com.example.zzz.plist -> dangling, no applicable repo plist found"; then + echo "PASS: warning printed for the unmatched symlink (zzz)" +else + echo "FAIL: expected warning about zzz having no applicable repo plist, got: $output" +fi +echo + echo "=== Done ==="