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 48c6347..3b65a0c 100755
--- a/functions.sh
+++ b/functions.sh
@@ -153,6 +153,47 @@ 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
+ local current_repo_path=""
+ name="$(basename "$target")"
+
+ 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)"
+ 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..29aa0a5
--- /dev/null
+++ b/scripts/test-repoint-dangling-launchagents.sh
@@ -0,0 +1,162 @@
+#!/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
+
+# --- 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
+
+# --- 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 ==="
diff --git a/symlinks.sh b/symlinks.sh
index c15a211..c786f55 100755
--- a/symlinks.sh
+++ b/symlinks.sh
@@ -33,6 +33,7 @@ link_directory_contents config
# 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