@@ -1909,6 +1909,137 @@ else
19091909 fail " User settings lost during unconfigure"
19101910fi
19111911
1912+ # --- Test 7e: Stale-path replacement (Nix/NixOS regression) ---
1913+ #
1914+ # On Nix/NixOS each rebuild produces a new /nix/store hash. The old
1915+ # contains()-based check would see "yes, a claude-session-track hook
1916+ # exists" and skip reinstall, leaving a stale (garbage-collected) path.
1917+ # The fix compares on exact path equality and replaces stale entries.
1918+
1919+ echo " "
1920+ echo " === Test 7e: Stale-path replacement (Nix/NixOS regression) ==="
1921+ echo " "
1922+
1923+ # Start fresh
1924+ rm -f " $HOME /.claude/settings.json"
1925+ echo ' {}' > " $HOME /.claude/settings.json"
1926+
1927+ # Inject hooks pointing at a fake old path (simulates a previous Nix derivation)
1928+ stale_track=" bash '/nix/store/old-hash-abc123/hooks/claude-session-track.sh'"
1929+ stale_cleanup=" bash '/nix/store/old-hash-abc123/hooks/claude-session-cleanup.sh'"
1930+ tmp_stale=$( mktemp)
1931+ jq --arg track " $stale_track " --arg cleanup " $stale_cleanup " '
1932+ .hooks = {
1933+ "SessionStart": [{
1934+ "matcher": "",
1935+ "hooks": [{"type": "command", "command": $track}]
1936+ }],
1937+ "SessionEnd": [{
1938+ "matcher": "",
1939+ "hooks": [{"type": "command", "command": $cleanup}]
1940+ }]
1941+ }
1942+ ' " $HOME /.claude/settings.json" > " $tmp_stale " && mv " $tmp_stale " " $HOME /.claude/settings.json"
1943+
1944+ # Verify stale hooks are in place
1945+ stale_before=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' " $HOME /.claude/settings.json" )
1946+ assert_eq " Stale: old-path hook present before reinstall" " 1" " $stale_before "
1947+
1948+ # Run the plugin — should replace the stale path with the current one
1949+ bash " $REPO_DIR /tmux-assistant-resurrect.tmux"
1950+
1951+ # Exactly 1 SessionStart hook, not 2 (no duplicate)
1952+ stale_start_count=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' " $HOME /.claude/settings.json" )
1953+ assert_eq " Stale: exactly 1 SessionStart hook after reinstall" " 1" " $stale_start_count "
1954+
1955+ # The hook must point at the CURRENT path, not the old one
1956+ stale_start_cmd=$( jq -r ' .hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track")) | .command' " $HOME /.claude/settings.json" )
1957+ expected_track_cmd=" bash '${REPO_DIR} /hooks/claude-session-track.sh'"
1958+ assert_eq " Stale: SessionStart hook updated to current path" " $expected_track_cmd " " $stale_start_cmd "
1959+
1960+ # Same for SessionEnd
1961+ stale_end_count=$( jq ' [.hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup"))] | length' " $HOME /.claude/settings.json" )
1962+ assert_eq " Stale: exactly 1 SessionEnd hook after reinstall" " 1" " $stale_end_count "
1963+
1964+ stale_end_cmd=$( jq -r ' .hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup")) | .command' " $HOME /.claude/settings.json" )
1965+ expected_cleanup_cmd=" bash '${REPO_DIR} /hooks/claude-session-cleanup.sh'"
1966+ assert_eq " Stale: SessionEnd hook updated to current path" " $expected_cleanup_cmd " " $stale_end_cmd "
1967+
1968+ # Run again — should be idempotent (still exactly 1)
1969+ bash " $REPO_DIR /tmux-assistant-resurrect.tmux"
1970+ stale_idem_count=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' " $HOME /.claude/settings.json" )
1971+ assert_eq " Stale: idempotent after path replacement" " 1" " $stale_idem_count "
1972+
1973+ # --- Test 7f: Stale path with other hooks preserved ---
1974+ #
1975+ # When a stale hook sits alongside an unrelated hook in the same entry,
1976+ # only the stale hook should be removed; the unrelated one must survive.
1977+
1978+ echo " "
1979+ echo " === Test 7f: Stale path replacement preserves unrelated hooks ==="
1980+ echo " "
1981+
1982+ rm -f " $HOME /.claude/settings.json"
1983+ echo ' {}' > " $HOME /.claude/settings.json"
1984+
1985+ # Inject a SessionStart entry with BOTH a stale track hook and a user's custom hook
1986+ tmp_mixed=$( mktemp)
1987+ jq --arg stale " $stale_track " '
1988+ .hooks = {
1989+ "SessionStart": [{
1990+ "matcher": "",
1991+ "hooks": [
1992+ {"type": "command", "command": $stale},
1993+ {"type": "command", "command": "echo my-custom-hook"}
1994+ ]
1995+ }]
1996+ }
1997+ ' " $HOME /.claude/settings.json" > " $tmp_mixed " && mv " $tmp_mixed " " $HOME /.claude/settings.json"
1998+
1999+ bash " $REPO_DIR /tmux-assistant-resurrect.tmux"
2000+
2001+ # The custom hook must survive
2002+ mixed_custom=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select(.command == "echo my-custom-hook")] | length' " $HOME /.claude/settings.json" )
2003+ assert_eq " Mixed: unrelated hook preserved after stale replacement" " 1" " $mixed_custom "
2004+
2005+ # Our hook is present with the current path
2006+ mixed_track=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' " $HOME /.claude/settings.json" )
2007+ assert_eq " Mixed: exactly 1 track hook after stale replacement" " 1" " $mixed_track "
2008+
2009+ # --- Test 7g: Stale path with null/missing hooks field ---
2010+ #
2011+ # An entry with "hooks": null or no hooks field at all must not crash
2012+ # the jq cleanup filter (.hooks |= map(...) would fail without null-coalescing).
2013+
2014+ echo " "
2015+ echo " === Test 7g: Stale path cleanup tolerates null hooks field ==="
2016+ echo " "
2017+
2018+ rm -f " $HOME /.claude/settings.json"
2019+ cat > " $HOME /.claude/settings.json" << 'NULLEOF '
2020+ {
2021+ "hooks": {
2022+ "SessionStart": [
2023+ {"matcher": "", "hooks": null},
2024+ {"matcher": "", "hooks": [{"type": "command", "command": "bash '/nix/store/old-hash/hooks/claude-session-track.sh'"}]}
2025+ ],
2026+ "SessionEnd": [
2027+ {"matcher": ""}
2028+ ]
2029+ }
2030+ }
2031+ NULLEOF
2032+
2033+ null_exit=0
2034+ bash " $REPO_DIR /tmux-assistant-resurrect.tmux" 2>&1 || null_exit=$?
2035+ assert_eq " Null hooks: install doesn't crash" " 0" " $null_exit "
2036+
2037+ null_track=$( jq ' [.hooks.SessionStart[]?.hooks[]? | select((.command // "") | contains("claude-session-track"))] | length' " $HOME /.claude/settings.json" )
2038+ assert_eq " Null hooks: exactly 1 track hook installed" " 1" " $null_track "
2039+
2040+ null_end=$( jq ' [.hooks.SessionEnd[]?.hooks[]? | select((.command // "") | contains("claude-session-cleanup"))] | length' " $HOME /.claude/settings.json" )
2041+ assert_eq " Null hooks: exactly 1 cleanup hook installed" " 1" " $null_end "
2042+
19122043# --- Test 8: strip_assistant_pane_contents() ---
19132044
19142045suite " strip_pane_contents"
0 commit comments