From c2806b25a8ecbaa5a64a015915023afe2cacf45b Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 10:30:16 +0100 Subject: [PATCH 01/15] feat(shell): open project_open and completion for any project under ~/Code --- shell/aliases | 139 ++++++++++++++++++++++++++++++------- tests/project_open_test.sh | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 tests/project_open_test.sh diff --git a/shell/aliases b/shell/aliases index b96c8e3..8c2d290 100644 --- a/shell/aliases +++ b/shell/aliases @@ -268,15 +268,109 @@ delete_terms() { set_posts_statuses() { wp post update "$(wp post list --post_type="${1}" --post_status="${2}" --format=ids)" --post_status="${3}" } +_project_open_root() { + printf '%s\n' "${PROJECT_OPEN_ROOT:-${HOME}/Code}" +} + +# Recursively list outermost project dirs under a root. A directory is a +# project if it contains .git, bedrock, or site; once matched we do not +# descend into it, so vendored submodules inside a repo are excluded. +# find (not globs) is used so empty dirs don't trip zsh NOMATCH. +_project_open_scan() { + local root="${1:-$(_project_open_root)}" + local dir + # Pipe (not <()) so the loop body never runs inside a process + # substitution: under zsh, a <() that calls a function nested in $() + # can lose $PATH, which breaks find/cat. Pipes keep PATH intact. + find "${root}" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r dir; do + if [[ -e "${dir}/.git" || -d "${dir}/bedrock" || -d "${dir}/site" ]]; then + printf '%s\n' "${dir}" + else + _project_open_scan "${dir}" + fi + done +} + +_project_open_cache_file() { + printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects" +} + +# Rebuild the cache atomically. Skips when the cache was rebuilt within the +# last few seconds so a burst of shells (e.g. tmux session restore) does one +# build, not many. Safe to run backgrounded. +_project_open_build_cache() { + local cache stamp dir tmp now last ttl + cache="$(_project_open_cache_file)" + stamp="${cache}.ts" + ttl="${PROJECT_OPEN_CACHE_TTL:-3}" + now="$(date +%s)" + last="$(cat "${stamp}" 2>/dev/null || echo 0)" + (( now - last < ttl )) && return 0 + + dir="${cache%/*}" + mkdir -p "${dir}" 2>/dev/null || return 1 + tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 + _project_open_scan >"${tmp}" + mv -f "${tmp}" "${cache}" + printf '%s\n' "${now}" >"${stamp}" +} + +_project_open_read_cache() { + local cache line + cache="$(_project_open_cache_file)" + if [[ -f "${cache}" ]]; then + # Builtin read, not cat: this runs inside completion's nested + # command substitution where zsh may have emptied $PATH. + while IFS= read -r line || [[ -n "${line}" ]]; do + printf '%s\n' "${line}" + done < "${cache}" + return 0 + fi + _project_open_scan +} + +po_refresh() { + local cache + cache="$(_project_open_cache_file)" + rm -f "${cache}" "${cache}.ts" + _project_open_build_cache +} + +# Map a bare project name to its absolute path. Names are unique across +# ~/Code, so the first match wins. Falls back to a live scan when the cache +# is stale (e.g. a just-added project). +_project_open_resolve() { + local name="${1}" cache line scanned + [[ -n "${name}" ]] || return 1 + # Builtin file read (cache) then here-string (live scan): no <() so + # $PATH survives under zsh's nested completion subshells, and the + # match's `return 0` fires in the function, not a pipe subshell. + cache="$(_project_open_cache_file)" + if [[ -f "${cache}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } + done < "${cache}" + fi + scanned="$(_project_open_scan)" + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } + done <<< "${scanned}" + return 1 +} + project_open() { - WP_PATH="${HOME}/Code/wordpress" + CODE_PATH="$(_project_open_root)" if [ -z "${1}" ]; then - NEW_PATH="${WP_PATH}" + NEW_PATH="${CODE_PATH}" tmux setw automatic-rename else - NEW_PATH="${WP_PATH}/${1}" + NEW_PATH="$(_project_open_resolve "${1}")" + if [ -z "${NEW_PATH}" ]; then + echo "No project named ${1} under ${CODE_PATH}" >&2 + return 1 + fi tmux rename-window "${1}" fi @@ -311,29 +405,21 @@ project_open() { alias po='project_open' _project_open_projects() { - local wp_path="${HOME}/Code/wordpress" - local project_dir - - [[ -d "${wp_path}" ]] || return 0 - - for project_dir in "${wp_path}"/*; do - [[ -d "${project_dir}" ]] || continue - [[ -d "${project_dir}/bedrock" || -d "${project_dir}/site" ]] || continue - printf '%s\n' "${project_dir##*/}" + # Not `path`: under zsh the last pipe stage runs in the current shell, + # and `path` is the special array tied to $PATH; reading into it would + # clobber $PATH. A local non-special name is safe in both shells. + local proj + _project_open_read_cache | while IFS= read -r proj; do + printf '%s\n' "${proj##*/}" done } _project_open_targets() { - local wp_path="${HOME}/Code/wordpress" - local project="${1}" - local project_path="${wp_path}/${project}" - local target_dir - - [[ -n "${project}" ]] || return 0 - [[ -d "${project_path}" ]] || return 0 - - for target_dir in "${project_path}"/*; do - [[ -d "${target_dir}" ]] || continue + local name="${1}" project_path target_dir + [[ -n "${name}" ]] || return 0 + project_path="$(_project_open_resolve "${name}")" || return 0 + [[ -n "${project_path}" ]] || return 0 + find "${project_path}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | while IFS= read -r target_dir; do printf '%s\n' "${target_dir##*/}" done } @@ -343,8 +429,8 @@ _project_open_complete_bash() { current="${COMP_WORDS[COMP_CWORD]}" COMPREPLY=() - # while-read + manual prefix match keeps this working on Bash 3.2 (macOS), - # avoids COMP_WORDS[-1] when COMP_CWORD is 0, and preserves names with spaces. + # while-read + manual prefix match avoids COMP_WORDS[-1] when COMP_CWORD is 0 + # and preserves names with spaces. if (( COMP_CWORD == 1 )); then while IFS= read -r candidate; do [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") @@ -366,6 +452,8 @@ _project_open_complete_bash() { if [[ -n "${BASH_VERSION:-}" ]]; then complete -F _project_open_complete_bash project_open complete -F _project_open_complete_bash po + # Rebuild the project cache in the background, detached so no job message prints. + ( _project_open_build_cache & ) >/dev/null 2>&1 fi if [[ -n "${ZSH_VERSION:-}" ]]; then @@ -407,7 +495,8 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then _project_open_register_zsh else add-zsh-hook precmd _project_open_register_zsh - fi' + fi + _project_open_build_cache &!' fi project_edit() { diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh new file mode 100644 index 0000000..cb6e267 --- /dev/null +++ b/tests/project_open_test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Standalone tests for project_open discovery/cache/resolution. +# Run: bash tests/project_open_test.sh +# No `set -u`: project_open references ${2} unguarded (matching the original), +# which is fine in a normal shell but would throw under nounset. + +FAILS=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "${expected}" == "${actual}" ]]; then + printf 'ok - %s\n' "${desc}" + return 0 + fi + printf 'FAIL - %s\n expected: %q\n actual: %q\n' "${desc}" "${expected}" "${actual}" + FAILS=$((FAILS + 1)) +} + +ROOT="" +setup_fixture() { + ROOT="$(mktemp -d)" + export PROJECT_OPEN_ROOT="${ROOT}" + XDG_CACHE_HOME="$(mktemp -d)" + export XDG_CACHE_HOME + mkdir -p \ + "${ROOT}/mods/HoldFast/.git" \ + "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ + "${ROOT}/misc/khuey/.git" \ + "${ROOT}/wordpress/acme/site" \ + "${ROOT}/wordpress/beta/bedrock" \ + "${ROOT}/deep/a/b/proj/.git" +} + +cleanup() { + [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" "${XDG_CACHE_HOME}" +} +trap cleanup EXIT + +# tmux is unavailable / irrelevant in tests; stub it to a no-op. +tmux() { return 0; } + +test_scan() { + local out + out="$(_project_open_scan | sed "s#^${ROOT}/##" | sort | tr '\n' ',')" + assert_eq "scan finds outermost projects, excludes submodules" \ + "deep/a/b/proj,misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ + "${out}" +} + +test_cache() { + local cache + export PROJECT_OPEN_CACHE_TTL=3600 + po_refresh + cache="$(_project_open_cache_file)" + assert_eq "cache file created" "yes" "$([[ -f "${cache}" ]] && echo yes || echo no)" + + # TTL guard: a new project added now must NOT appear via a guarded rebuild. + mkdir -p "${ROOT}/mods/NewMod/.git" + _project_open_build_cache + assert_eq "TTL guard skips rebuild" "no" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + # po_refresh bypasses the guard and picks up the new project. + po_refresh + assert_eq "po_refresh rebuilds" "yes" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + # read_cache falls back to a live scan when the file is absent. + rm -f "${cache}" "${cache}.ts" + assert_eq "read_cache scans when missing" "1" \ + "$(_project_open_read_cache | grep -c '/mods/HoldFast$')" +} + +test_resolve_and_completion() { + assert_eq "resolve HoldFast to path" "${ROOT}/mods/HoldFast" \ + "$(_project_open_resolve HoldFast)" + assert_eq "resolve deep project" "${ROOT}/deep/a/b/proj" \ + "$(_project_open_resolve proj)" + assert_eq "resolve unknown is empty" "" "$(_project_open_resolve nope)" + + # test_cache (run earlier in main) added mods/NewMod to the fixture, so it + # appears here too. sort makes order irrelevant. + po_refresh + assert_eq "projects lists basenames" "HoldFast,NewMod,acme,beta,khuey,proj," \ + "$(_project_open_projects | LC_ALL=C sort | tr '\n' ',')" + + assert_eq "targets lists subdirs" "lib" "$(_project_open_targets HoldFast)" + assert_eq "targets of childless project is empty" "" \ + "$(_project_open_targets khuey)" + assert_eq "targets of unknown project is empty" "" \ + "$(_project_open_targets nope)" +} + +test_project_open() { + po_refresh + assert_eq "no-arg cds to Code root" "${ROOT}" \ + "$(cd / && project_open >/dev/null 2>&1; pwd)" + assert_eq "name cds into plain project" "${ROOT}/mods/HoldFast" \ + "$(cd / && project_open HoldFast >/dev/null 2>&1; pwd)" + assert_eq "descends into bedrock" "${ROOT}/wordpress/beta/bedrock" \ + "$(cd / && project_open beta >/dev/null 2>&1; pwd)" + assert_eq "descends into site" "${ROOT}/wordpress/acme/site" \ + "$(cd / && project_open acme >/dev/null 2>&1; pwd)" + assert_eq "subdir target cds into subdir" "${ROOT}/mods/HoldFast/lib" \ + "$(cd / && project_open HoldFast lib >/dev/null 2>&1; pwd)" + + local rc + ( cd / && project_open nope >/dev/null 2>&1 ); rc=$? + assert_eq "unknown project returns 1" "1" "${rc}" +} + +main() { + setup_fixture + local dotfiles + dotfiles="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + # shellcheck source=/dev/null + source "${dotfiles}/shell/aliases" + + test_scan + test_cache + test_resolve_and_completion + test_project_open + + exit $(( FAILS > 0 ? 1 : 0 )) +} + +main "$@" From 9c8b2ae174e4491409bcd93fc9dfb2567c55e0fb Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 12:00:40 +0100 Subject: [PATCH 02/15] fix(shell): guard project_open cache stamp and theme lookup against failures --- shell/aliases | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/shell/aliases b/shell/aliases index 8c2d290..32ca55f 100644 --- a/shell/aliases +++ b/shell/aliases @@ -310,9 +310,15 @@ _project_open_build_cache() { dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 - _project_open_scan >"${tmp}" - mv -f "${tmp}" "${cache}" - printf '%s\n' "${now}" >"${stamp}" + # Only stamp once the cache is actually in place; otherwise the TTL guard + # would suppress rebuilds while the cache stays missing/stale. Clean up + # the temp file if the scan or the atomic swap fails. + if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then + printf '%s\n' "${now}" >"${stamp}" + return 0 + fi + rm -f "${tmp}" + return 1 } _project_open_read_cache() { @@ -392,9 +398,11 @@ project_open() { return fi - # Find only themes using Composer. - THEMES=("${NEW_PATH}/web/app/themes/"*/composer.json) - THEME_PATH="${THEMES[1]//composer.json/}" + # Locate the first Composer theme. find (not a glob) so a project with no + # web/app/themes tree returns cleanly instead of tripping zsh NOMATCH now + # that non-WordPress projects can be resolved. + THEME_PATH="$(find "${NEW_PATH}/web/app/themes" -mindepth 2 -maxdepth 2 -name composer.json 2>/dev/null | LC_ALL=C sort | head -n1)" + THEME_PATH="${THEME_PATH%composer.json}" if [ -z "${THEME_PATH}" ]; then echo "No theme available" >&2 return 1 From ada449b4059e4a086efb90cf01e84392f3fef303 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 12:00:40 +0100 Subject: [PATCH 03/15] chore(test): make project_open test mktemp portable to BSD --- tests/project_open_test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index cb6e267..eb4baf5 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -18,9 +18,10 @@ assert_eq() { ROOT="" setup_fixture() { - ROOT="$(mktemp -d)" + # Templates keep mktemp portable: BSD/macOS mktemp needs one, GNU accepts it. + ROOT="$(mktemp -d "${TMPDIR:-/tmp}/project_open_root.XXXXXX")" export PROJECT_OPEN_ROOT="${ROOT}" - XDG_CACHE_HOME="$(mktemp -d)" + XDG_CACHE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/project_open_cache.XXXXXX")" export XDG_CACHE_HOME mkdir -p \ "${ROOT}/mods/HoldFast/.git" \ From c69b1c9cca6af35cf15ea8eff70688cb9fd9a5e9 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 13:52:58 +0100 Subject: [PATCH 04/15] chore(test): run project_open test from any checkout path --- tests/project_open_test.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index eb4baf5..51efbb5 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -32,8 +32,10 @@ setup_fixture() { "${ROOT}/deep/a/b/proj/.git" } +TEST_HOME="" cleanup() { [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" "${XDG_CACHE_HOME}" + [[ -n "${TEST_HOME}" ]] && rm -rf "${TEST_HOME}" } trap cleanup EXIT @@ -114,8 +116,14 @@ main() { setup_fixture local dotfiles dotfiles="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + # shell/aliases sources "${HOME}/.dotfiles/shell/os.sh", so point HOME at a + # temp dir whose .dotfiles symlinks to this repo. That lets the test run + # from any checkout path, not only ~/.dotfiles. + TEST_HOME="$(mktemp -d "${TMPDIR:-/tmp}/project_open_home.XXXXXX")" + ln -s "${dotfiles}" "${TEST_HOME}/.dotfiles" + export HOME="${TEST_HOME}" # shellcheck source=/dev/null - source "${dotfiles}/shell/aliases" + source "${HOME}/.dotfiles/shell/aliases" test_scan test_cache From 8c4decf112a81acba885ffd7960410b9cfe68f1c Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 13:59:17 +0100 Subject: [PATCH 05/15] fix(shell): restrict project_open theme lookup to regular files --- shell/aliases | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/aliases b/shell/aliases index 32ca55f..72c2f0e 100644 --- a/shell/aliases +++ b/shell/aliases @@ -401,7 +401,7 @@ project_open() { # Locate the first Composer theme. find (not a glob) so a project with no # web/app/themes tree returns cleanly instead of tripping zsh NOMATCH now # that non-WordPress projects can be resolved. - THEME_PATH="$(find "${NEW_PATH}/web/app/themes" -mindepth 2 -maxdepth 2 -name composer.json 2>/dev/null | LC_ALL=C sort | head -n1)" + THEME_PATH="$(find "${NEW_PATH}/web/app/themes" -mindepth 2 -maxdepth 2 -type f -name composer.json 2>/dev/null | LC_ALL=C sort | head -n1)" THEME_PATH="${THEME_PATH%composer.json}" if [ -z "${THEME_PATH}" ]; then echo "No theme available" >&2 From 4e86c7614909fcb3987e134611e12280337674b3 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 13:59:17 +0100 Subject: [PATCH 06/15] chore(test): make project_open scan assertion locale-independent --- tests/project_open_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index 51efbb5..7242e8d 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -44,7 +44,7 @@ tmux() { return 0; } test_scan() { local out - out="$(_project_open_scan | sed "s#^${ROOT}/##" | sort | tr '\n' ',')" + out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" assert_eq "scan finds outermost projects, excludes submodules" \ "deep/a/b/proj,misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ "${out}" From 0561964756ddb82c0c3bc07cdc6b09f616dc678e Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 14:11:57 +0100 Subject: [PATCH 07/15] chore(test): guard each temp path independently in cleanup --- tests/project_open_test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index 7242e8d..375446a 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -34,7 +34,8 @@ setup_fixture() { TEST_HOME="" cleanup() { - [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" "${XDG_CACHE_HOME}" + [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" + [[ -n "${XDG_CACHE_HOME}" ]] && rm -rf "${XDG_CACHE_HOME}" [[ -n "${TEST_HOME}" ]] && rm -rf "${TEST_HOME}" } trap cleanup EXIT From 97ccd251f2e1c18ac7206c362171cc45b115a975 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 14:26:25 +0100 Subject: [PATCH 08/15] fix(shell): build project_open cache async only in interactive shells --- shell/aliases | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/shell/aliases b/shell/aliases index 72c2f0e..38a5e9d 100644 --- a/shell/aliases +++ b/shell/aliases @@ -460,8 +460,11 @@ _project_open_complete_bash() { if [[ -n "${BASH_VERSION:-}" ]]; then complete -F _project_open_complete_bash project_open complete -F _project_open_complete_bash po - # Rebuild the project cache in the background, detached so no job message prints. - ( _project_open_build_cache & ) >/dev/null 2>&1 + # Rebuild the project cache in the background, detached so no job message + # prints. Interactive only, so sourcing in scripts/tests spawns nothing. + if [[ $- == *i* ]]; then + ( _project_open_build_cache & ) >/dev/null 2>&1 + fi fi if [[ -n "${ZSH_VERSION:-}" ]]; then @@ -504,7 +507,10 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then else add-zsh-hook precmd _project_open_register_zsh fi - _project_open_build_cache &!' + # Interactive only, so a non-interactive `zsh -c "source ..."` spawns no job. + if [[ -o interactive ]]; then + _project_open_build_cache &! + fi' fi project_edit() { From 3d695fed9e7eb3413dcc53dd1daba6f991c89c95 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 14:43:16 +0100 Subject: [PATCH 09/15] fix(shell): silence zsh startup cache build like the bash path --- shell/aliases | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/aliases b/shell/aliases index 38a5e9d..f4b9489 100644 --- a/shell/aliases +++ b/shell/aliases @@ -508,8 +508,9 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then add-zsh-hook precmd _project_open_register_zsh fi # Interactive only, so a non-interactive `zsh -c "source ..."` spawns no job. + # Silenced like the bash path so a failed build prints nothing at startup. if [[ -o interactive ]]; then - _project_open_build_cache &! + _project_open_build_cache >/dev/null 2>&1 &! fi' fi From 1fcbdf5890ec2fc8a827a309d94a52a12e54c7b2 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 15:26:30 +0100 Subject: [PATCH 10/15] chore(shell): trim project_open comments to accurate terse notes --- shell/aliases | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/shell/aliases b/shell/aliases index f4b9489..dbbdd8e 100644 --- a/shell/aliases +++ b/shell/aliases @@ -279,9 +279,7 @@ _project_open_root() { _project_open_scan() { local root="${1:-$(_project_open_root)}" local dir - # Pipe (not <()) so the loop body never runs inside a process - # substitution: under zsh, a <() that calls a function nested in $() - # can lose $PATH, which breaks find/cat. Pipes keep PATH intact. + # Pipe, not <(): a <() calling a function inside $() can empty $PATH under zsh. find "${root}" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r dir; do if [[ -e "${dir}/.git" || -d "${dir}/bedrock" || -d "${dir}/site" ]]; then printf '%s\n' "${dir}" @@ -295,9 +293,8 @@ _project_open_cache_file() { printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects" } -# Rebuild the cache atomically. Skips when the cache was rebuilt within the -# last few seconds so a burst of shells (e.g. tmux session restore) does one -# build, not many. Safe to run backgrounded. +# Rebuild the cache via a temp file then atomic mv. Skips when the timestamp +# sidecar is younger than PROJECT_OPEN_CACHE_TTL. Safe to run backgrounded. _project_open_build_cache() { local cache stamp dir tmp now last ttl cache="$(_project_open_cache_file)" @@ -310,9 +307,7 @@ _project_open_build_cache() { dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 - # Only stamp once the cache is actually in place; otherwise the TTL guard - # would suppress rebuilds while the cache stays missing/stale. Clean up - # the temp file if the scan or the atomic swap fails. + # Stamp only after the swap succeeds; drop the temp file on failure. if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then printf '%s\n' "${now}" >"${stamp}" return 0 @@ -325,8 +320,7 @@ _project_open_read_cache() { local cache line cache="$(_project_open_cache_file)" if [[ -f "${cache}" ]]; then - # Builtin read, not cat: this runs inside completion's nested - # command substitution where zsh may have emptied $PATH. + # Builtin read, not cat: $PATH may be empty in nested zsh completion. while IFS= read -r line || [[ -n "${line}" ]]; do printf '%s\n' "${line}" done < "${cache}" @@ -348,9 +342,8 @@ po_refresh() { _project_open_resolve() { local name="${1}" cache line scanned [[ -n "${name}" ]] || return 1 - # Builtin file read (cache) then here-string (live scan): no <() so - # $PATH survives under zsh's nested completion subshells, and the - # match's `return 0` fires in the function, not a pipe subshell. + # Builtin read + here-string, no <(): keeps $PATH under zsh and lets the + # match return from the function rather than a pipe subshell. cache="$(_project_open_cache_file)" if [[ -f "${cache}" ]]; then while IFS= read -r line || [[ -n "${line}" ]]; do From 4016beac193ea5e1832a566e86bfe6ce8545136c Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 15:32:03 +0100 Subject: [PATCH 11/15] fix(shell): keep project_open completion cache-only and drop comments --- shell/aliases | 48 ++++++-------------------------------- tests/project_open_test.sh | 7 ++++-- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/shell/aliases b/shell/aliases index dbbdd8e..dbfa84d 100644 --- a/shell/aliases +++ b/shell/aliases @@ -272,14 +272,9 @@ _project_open_root() { printf '%s\n' "${PROJECT_OPEN_ROOT:-${HOME}/Code}" } -# Recursively list outermost project dirs under a root. A directory is a -# project if it contains .git, bedrock, or site; once matched we do not -# descend into it, so vendored submodules inside a repo are excluded. -# find (not globs) is used so empty dirs don't trip zsh NOMATCH. _project_open_scan() { local root="${1:-$(_project_open_root)}" local dir - # Pipe, not <(): a <() calling a function inside $() can empty $PATH under zsh. find "${root}" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r dir; do if [[ -e "${dir}/.git" || -d "${dir}/bedrock" || -d "${dir}/site" ]]; then printf '%s\n' "${dir}" @@ -293,8 +288,6 @@ _project_open_cache_file() { printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects" } -# Rebuild the cache via a temp file then atomic mv. Skips when the timestamp -# sidecar is younger than PROJECT_OPEN_CACHE_TTL. Safe to run backgrounded. _project_open_build_cache() { local cache stamp dir tmp now last ttl cache="$(_project_open_cache_file)" @@ -307,7 +300,6 @@ _project_open_build_cache() { dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 - # Stamp only after the swap succeeds; drop the temp file on failure. if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then printf '%s\n' "${now}" >"${stamp}" return 0 @@ -319,14 +311,10 @@ _project_open_build_cache() { _project_open_read_cache() { local cache line cache="$(_project_open_cache_file)" - if [[ -f "${cache}" ]]; then - # Builtin read, not cat: $PATH may be empty in nested zsh completion. - while IFS= read -r line || [[ -n "${line}" ]]; do - printf '%s\n' "${line}" - done < "${cache}" - return 0 - fi - _project_open_scan + [[ -f "${cache}" ]] || return 0 + while IFS= read -r line || [[ -n "${line}" ]]; do + printf '%s\n' "${line}" + done < "${cache}" } po_refresh() { @@ -336,20 +324,16 @@ po_refresh() { _project_open_build_cache } -# Map a bare project name to its absolute path. Names are unique across -# ~/Code, so the first match wins. Falls back to a live scan when the cache -# is stale (e.g. a just-added project). _project_open_resolve() { - local name="${1}" cache line scanned + local name="${1}" cache_only="${2}" cache line scanned [[ -n "${name}" ]] || return 1 - # Builtin read + here-string, no <(): keeps $PATH under zsh and lets the - # match return from the function rather than a pipe subshell. cache="$(_project_open_cache_file)" if [[ -f "${cache}" ]]; then while IFS= read -r line || [[ -n "${line}" ]]; do [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } done < "${cache}" fi + [[ -n "${cache_only}" ]] && return 1 scanned="$(_project_open_scan)" while IFS= read -r line || [[ -n "${line}" ]]; do [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } @@ -391,9 +375,6 @@ project_open() { return fi - # Locate the first Composer theme. find (not a glob) so a project with no - # web/app/themes tree returns cleanly instead of tripping zsh NOMATCH now - # that non-WordPress projects can be resolved. THEME_PATH="$(find "${NEW_PATH}/web/app/themes" -mindepth 2 -maxdepth 2 -type f -name composer.json 2>/dev/null | LC_ALL=C sort | head -n1)" THEME_PATH="${THEME_PATH%composer.json}" if [ -z "${THEME_PATH}" ]; then @@ -406,9 +387,6 @@ project_open() { alias po='project_open' _project_open_projects() { - # Not `path`: under zsh the last pipe stage runs in the current shell, - # and `path` is the special array tied to $PATH; reading into it would - # clobber $PATH. A local non-special name is safe in both shells. local proj _project_open_read_cache | while IFS= read -r proj; do printf '%s\n' "${proj##*/}" @@ -418,7 +396,7 @@ _project_open_projects() { _project_open_targets() { local name="${1}" project_path target_dir [[ -n "${name}" ]] || return 0 - project_path="$(_project_open_resolve "${name}")" || return 0 + project_path="$(_project_open_resolve "${name}" cache_only)" || return 0 [[ -n "${project_path}" ]] || return 0 find "${project_path}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | while IFS= read -r target_dir; do printf '%s\n' "${target_dir##*/}" @@ -430,8 +408,6 @@ _project_open_complete_bash() { current="${COMP_WORDS[COMP_CWORD]}" COMPREPLY=() - # while-read + manual prefix match avoids COMP_WORDS[-1] when COMP_CWORD is 0 - # and preserves names with spaces. if (( COMP_CWORD == 1 )); then while IFS= read -r candidate; do [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") @@ -453,17 +429,12 @@ _project_open_complete_bash() { if [[ -n "${BASH_VERSION:-}" ]]; then complete -F _project_open_complete_bash project_open complete -F _project_open_complete_bash po - # Rebuild the project cache in the background, detached so no job message - # prints. Interactive only, so sourcing in scripts/tests spawns nothing. if [[ $- == *i* ]]; then ( _project_open_build_cache & ) >/dev/null 2>&1 fi fi if [[ -n "${ZSH_VERSION:-}" ]]; then - # eval is required here because the Zsh-specific ${(@f)...} syntax inside the - # function body is a Bash parse error unless the code is deferred and evaluated - # only when Zsh is active. eval '_project_open_complete_zsh() { local current previous local -a candidates @@ -483,11 +454,8 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then return fi - # Explicitly return when no completion candidate applicable (CURRENT > 3) return } - # Defer registration until compdef exists, so completion still works when this - # file is sourced before compinit. The hook removes itself once registered. _project_open_register_zsh() { (( $+functions[compdef] )) || return 0 compdef _project_open_complete_zsh project_open @@ -500,8 +468,6 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then else add-zsh-hook precmd _project_open_register_zsh fi - # Interactive only, so a non-interactive `zsh -c "source ..."` spawns no job. - # Silenced like the bash path so a failed build prints nothing at startup. if [[ -o interactive ]]; then _project_open_build_cache >/dev/null 2>&1 &! fi' diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index 375446a..2f4fb15 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -69,10 +69,13 @@ test_cache() { assert_eq "po_refresh rebuilds" "yes" \ "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" - # read_cache falls back to a live scan when the file is absent. rm -f "${cache}" "${cache}.ts" - assert_eq "read_cache scans when missing" "1" \ + assert_eq "read_cache empty when cache missing" "0" \ "$(_project_open_read_cache | grep -c '/mods/HoldFast$')" + assert_eq "resolve scans when cache missing" "${ROOT}/mods/HoldFast" \ + "$(_project_open_resolve HoldFast)" + assert_eq "resolve cache_only empty when cache missing" "" \ + "$(_project_open_resolve HoldFast cache_only)" } test_resolve_and_completion() { From d2f05e841e978d1ad09a5c5f344fe580a5171a43 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 15:52:15 +0100 Subject: [PATCH 12/15] fix(shell): harden project_open TTL parsing, error output, and scan streaming --- shell/aliases | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/shell/aliases b/shell/aliases index dbfa84d..4571806 100644 --- a/shell/aliases +++ b/shell/aliases @@ -293,8 +293,10 @@ _project_open_build_cache() { cache="$(_project_open_cache_file)" stamp="${cache}.ts" ttl="${PROJECT_OPEN_CACHE_TTL:-3}" + case "${ttl}" in '' | *[!0-9]*) ttl=3 ;; esac now="$(date +%s)" last="$(cat "${stamp}" 2>/dev/null || echo 0)" + case "${last}" in '' | *[!0-9]*) last=0 ;; esac (( now - last < ttl )) && return 0 dir="${cache%/*}" @@ -325,7 +327,7 @@ po_refresh() { } _project_open_resolve() { - local name="${1}" cache_only="${2}" cache line scanned + local name="${1}" cache_only="${2}" cache line tmp [[ -n "${name}" ]] || return 1 cache="$(_project_open_cache_file)" if [[ -f "${cache}" ]]; then @@ -334,10 +336,12 @@ _project_open_resolve() { done < "${cache}" fi [[ -n "${cache_only}" ]] && return 1 - scanned="$(_project_open_scan)" + tmp="$(mktemp "${TMPDIR:-/tmp}/project_open_scan.XXXXXX")" || return 1 + _project_open_scan >"${tmp}" while IFS= read -r line || [[ -n "${line}" ]]; do - [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } - done <<< "${scanned}" + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; rm -f "${tmp}"; return 0; } + done < "${tmp}" + rm -f "${tmp}" return 1 } @@ -351,7 +355,7 @@ project_open() { else NEW_PATH="$(_project_open_resolve "${1}")" if [ -z "${NEW_PATH}" ]; then - echo "No project named ${1} under ${CODE_PATH}" >&2 + printf 'No project named %s under %s\n' "${1}" "${CODE_PATH}" >&2 return 1 fi tmux rename-window "${1}" From a23f2434bf528a2627acede5106b3fed817802ee Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 16:20:49 +0100 Subject: [PATCH 13/15] fix(shell): skip project_open cache build when root is missing --- shell/aliases | 4 +++- tests/project_open_test.sh | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/shell/aliases b/shell/aliases index 4571806..ba434fa 100644 --- a/shell/aliases +++ b/shell/aliases @@ -289,7 +289,9 @@ _project_open_cache_file() { } _project_open_build_cache() { - local cache stamp dir tmp now last ttl + local cache stamp dir tmp now last ttl root + root="$(_project_open_root)" + [[ -d "${root}" ]] || return 1 cache="$(_project_open_cache_file)" stamp="${cache}.ts" ttl="${PROJECT_OPEN_CACHE_TTL:-3}" diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index 2f4fb15..d596866 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -76,6 +76,11 @@ test_cache() { "$(_project_open_resolve HoldFast)" assert_eq "resolve cache_only empty when cache missing" "" \ "$(_project_open_resolve HoldFast cache_only)" + + rm -f "${cache}" "${cache}.ts" + PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_build_cache + assert_eq "no cache built when root missing" "no" \ + "$([[ -f "${cache}" ]] && echo yes || echo no)" } test_resolve_and_completion() { From 8dc528a6e65dbbaa6389449cbb76c1efa316d6a3 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 16:25:58 +0100 Subject: [PATCH 14/15] fix(shell): silence false-positive SC2094 on project_open scan temp file --- shell/aliases | 1 + 1 file changed, 1 insertion(+) diff --git a/shell/aliases b/shell/aliases index ba434fa..aee3653 100644 --- a/shell/aliases +++ b/shell/aliases @@ -340,6 +340,7 @@ _project_open_resolve() { [[ -n "${cache_only}" ]] && return 1 tmp="$(mktemp "${TMPDIR:-/tmp}/project_open_scan.XXXXXX")" || return 1 _project_open_scan >"${tmp}" + # shellcheck disable=SC2094 while IFS= read -r line || [[ -n "${line}" ]]; do [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; rm -f "${tmp}"; return 0; } done < "${tmp}" From 677af7befc3eb45a676851888aa69f580305494c Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 17:00:12 +0100 Subject: [PATCH 15/15] fix(shell): rebuild project_open cache when file is gone despite fresh stamp --- shell/aliases | 2 +- tests/project_open_test.sh | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/shell/aliases b/shell/aliases index aee3653..f2eef9b 100644 --- a/shell/aliases +++ b/shell/aliases @@ -299,7 +299,7 @@ _project_open_build_cache() { now="$(date +%s)" last="$(cat "${stamp}" 2>/dev/null || echo 0)" case "${last}" in '' | *[!0-9]*) last=0 ;; esac - (( now - last < ttl )) && return 0 + [[ -f "${cache}" ]] && (( now - last < ttl )) && return 0 dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index d596866..7450502 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -77,6 +77,12 @@ test_cache() { assert_eq "resolve cache_only empty when cache missing" "" \ "$(_project_open_resolve HoldFast cache_only)" + po_refresh + rm -f "${cache}" + PROJECT_OPEN_CACHE_TTL=3600 _project_open_build_cache + assert_eq "rebuilds when cache gone but stamp fresh" "yes" \ + "$([[ -f "${cache}" ]] && echo yes || echo no)" + rm -f "${cache}" "${cache}.ts" PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_build_cache assert_eq "no cache built when root missing" "no" \