From cf2b9ed1ebffc97bcfd37191f422777dee0293e0 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Fri, 19 Jun 2026 12:25:23 +0100 Subject: [PATCH 1/8] feat(shell): add `project_open` autocomplete --- .github/workflows/lint.yml | 4 + shell/aliases | 150 ++++++++++++++++++++++++++++++-- shell/completions/_project_open | 23 +++++ shell/zshrc | 3 + tests/project_open_test.sh | 147 +++++++++++++++++++++++++++++++ 5 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 shell/completions/_project_open create mode 100644 tests/project_open_test.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a90ee00..a21be70 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,10 @@ jobs: uses: actions/checkout@v3 - name: Run Shellcheck uses: azohra/shell-linter@latest + with: + # zsh completion functions (shell/completions) use zsh-only syntax + # that ShellCheck cannot parse; ShellCheck has no zsh dialect. + exclude-paths: "shell/completions" lua: runs-on: ubuntu-latest diff --git a/shell/aliases b/shell/aliases index ffee1e2..66ce642 100644 --- a/shell/aliases +++ b/shell/aliases @@ -268,15 +268,96 @@ 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}" +} + +_project_open_scan() { + local root="${1:-$(_project_open_root)}" + local dir + find "${root}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 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() { + # Key the cache by root so changing PROJECT_OPEN_ROOT never reads stale paths. + local root key + root="$(_project_open_root)" + key="${root//\//_}" + printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects${key}" +} + +_project_open_build_cache() { + local cache dir tmp root + root="$(_project_open_root)" + [[ -d "${root}" ]] || return 1 + cache="$(_project_open_cache_file)" + [[ -f "${cache}" ]] && return 0 + + dir="${cache%/*}" + mkdir -p "${dir}" 2>/dev/null || return 1 + tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 + if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then + return 0 + fi + rm -f "${tmp}" + return 1 +} + +_project_open_read_cache() { + local cache line + cache="$(_project_open_cache_file)" + [[ -f "${cache}" ]] || return 0 + while IFS= read -r line || [[ -n "${line}" ]]; do + printf '%s\n' "${line}" + done < "${cache}" +} + +po_refresh() { + local cache + cache="$(_project_open_cache_file)" + rm -f "${cache}" + _project_open_build_cache +} + +_project_open_resolve() { + local name="${1}" cache_only="${2}" cache line tmp + [[ -n "${name}" ]] || return 1 + 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 + 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}" + rm -f "${tmp}" + 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 + printf 'No project named %s under %s\n' "${1}" "${CODE_PATH}" >&2 + return 1 + fi tmux rename-window "${1}" fi @@ -298,9 +379,8 @@ project_open() { return fi - # Find only themes using Composer. - THEMES=("${NEW_PATH}/web/app/themes/"*/composer.json) - THEME_PATH="${THEMES[1]//composer.json/}" + 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 return 1 @@ -309,6 +389,64 @@ project_open() { cd "${THEME_PATH}" || return } alias po='project_open' + +_project_open_projects() { + local proj + _project_open_read_cache | while IFS= read -r proj; do + printf '%s\n' "${proj##*/}" + done +} + +_project_open_targets() { + local name="${1}" project_path target_dir + [[ -n "${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##*/}" + done +} + +_project_open_complete_bash() { + local current previous candidate + current="${COMP_WORDS[COMP_CWORD]}" + COMPREPLY=() + + if (( COMP_CWORD == 1 )); then + while IFS= read -r candidate; do + [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") + done < <(_project_open_projects) + return 0 + fi + + if (( COMP_CWORD == 2 )); then + previous="${COMP_WORDS[COMP_CWORD-1]}" + while IFS= read -r candidate; do + [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") + done < <(_project_open_targets "${previous}") + return 0 + fi + + return 0 +} + +if [[ -n "${BASH_VERSION:-}" ]]; then + complete -F _project_open_complete_bash project_open + complete -F _project_open_complete_bash po + if [[ $- == *i* ]]; then + ( _project_open_build_cache & ) >/dev/null 2>&1 + fi +fi + +if [[ -n "${ZSH_VERSION:-}" ]]; then + # Completion is registered the canonical way via an autoloaded #compdef + # function on $fpath (shell/completions/_project_open). Here we only kick + # off the async cache build; eval defers the zsh-only `&!` past bash parsing. + if [[ -o interactive ]]; then + eval '_project_open_build_cache >/dev/null 2>&1 &!' + fi +fi + project_edit() { WP_PATH="${HOME}/Code/wordpress" diff --git a/shell/completions/_project_open b/shell/completions/_project_open new file mode 100644 index 0000000..520076d --- /dev/null +++ b/shell/completions/_project_open @@ -0,0 +1,23 @@ +#compdef project_open po + +# Autoloaded via $fpath so compinit (re)registers it on every run, surviving +# the repeated/deferred compinit calls in zshrc. Discovery helpers live in +# shell/aliases and are available in the interactive shell. + +(( $+functions[_project_open_projects] )) || return 1 + +local -a candidates + +if (( CURRENT == 2 )); then + candidates=("${(@f)$(_project_open_projects)}") + compadd -- "${candidates[@]}" + return +fi + +if (( CURRENT == 3 )); then + candidates=("${(@f)$(_project_open_targets "${words[CURRENT-1]}")}") + compadd -- "${candidates[@]}" + return +fi + +return diff --git a/shell/zshrc b/shell/zshrc index d8ac4ca..8ea413c 100644 --- a/shell/zshrc +++ b/shell/zshrc @@ -97,6 +97,9 @@ HISTSIZE=100000 SAVEHIST=10000 setopt SHARE_HISTORY +if (( ! ${fpath[(Ie)${HOME}/.dotfiles/shell/completions]} )); then + fpath=("${HOME}/.dotfiles/shell/completions" ${fpath}) +fi autoload -U compinit && compinit zmodload -i zsh/complist compdef __git_branch_names gpDo diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh new file mode 100644 index 0000000..77495f1 --- /dev/null +++ b/tests/project_open_test.sh @@ -0,0 +1,147 @@ +#!/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() { + # 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 "${TMPDIR:-/tmp}/project_open_cache.XXXXXX")" + 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" +} + +TEST_HOME="" +cleanup() { + [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" + [[ -n "${XDG_CACHE_HOME}" ]] && rm -rf "${XDG_CACHE_HOME}" + [[ -n "${TEST_HOME}" ]] && rm -rf "${TEST_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}/##" | 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}" +} + +test_cache() { + local cache + po_refresh + cache="$(_project_open_cache_file)" + assert_eq "cache file created" "yes" "$([[ -f "${cache}" ]] && echo yes || echo no)" + + # build is a no-op when the cache already exists: a new project must NOT appear. + mkdir -p "${ROOT}/mods/NewMod/.git" + _project_open_build_cache + assert_eq "build skips when cache exists" "no" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + # po_refresh forces a rebuild and picks up the new project. + po_refresh + assert_eq "po_refresh rebuilds" "yes" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + rm -f "${cache}" + 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)" + + # Check the cache path for the missing root itself, since cache files are + # keyed by root: building under a missing root must not create its cache. + local missing_cache + missing_cache="$(PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_cache_file)" + PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_build_cache + assert_eq "no cache built when root missing" "no" \ + "$([[ -f "${missing_cache}" ]] && echo yes || echo no)" +} + +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)" + # 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 "${HOME}/.dotfiles/shell/aliases" + + test_scan + test_cache + test_resolve_and_completion + test_project_open + + exit $(( FAILS > 0 ? 1 : 0 )) +} + +main "$@" From de95141a40e8c5bebc0762cbfa002c286a02f509 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 21:28:32 +0100 Subject: [PATCH 2/8] fix(shell): hash-key cache, sort scan, return 0 from completion --- shell/aliases | 11 +++++++---- shell/completions/_project_open | 6 +++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/shell/aliases b/shell/aliases index 66ce642..f4b8311 100644 --- a/shell/aliases +++ b/shell/aliases @@ -285,11 +285,14 @@ _project_open_scan() { } _project_open_cache_file() { - # Key the cache by root so changing PROJECT_OPEN_ROOT never reads stale paths. + # Key the cache by a hash of the root so a different PROJECT_OPEN_ROOT never + # reads a stale cache. Hashing (not char substitution) avoids path collisions; + # trailing slashes are stripped so "/x" and "/x/" share one cache. local root key root="$(_project_open_root)" - key="${root//\//_}" - printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects${key}" + root="${root%/}" + key="$(printf '%s' "${root}" | cksum | cut -d' ' -f1)" + printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects-${key}" } _project_open_build_cache() { @@ -302,7 +305,7 @@ _project_open_build_cache() { dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 - if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then + if _project_open_scan | LC_ALL=C sort >"${tmp}" && mv -f "${tmp}" "${cache}"; then return 0 fi rm -f "${tmp}" diff --git a/shell/completions/_project_open b/shell/completions/_project_open index 520076d..7c8e8b7 100644 --- a/shell/completions/_project_open +++ b/shell/completions/_project_open @@ -11,13 +11,13 @@ local -a candidates if (( CURRENT == 2 )); then candidates=("${(@f)$(_project_open_projects)}") compadd -- "${candidates[@]}" - return + return 0 fi if (( CURRENT == 3 )); then candidates=("${(@f)$(_project_open_targets "${words[CURRENT-1]}")}") compadd -- "${candidates[@]}" - return + return 0 fi -return +return 0 From a849512b584d26388fbec190c2ee8e4800e40ef5 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 21:38:22 +0100 Subject: [PATCH 3/8] refactor(shell): drop project_open theme target and redundant targets guard --- shell/aliases | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/shell/aliases b/shell/aliases index f4b8311..cc42d7f 100644 --- a/shell/aliases +++ b/shell/aliases @@ -364,7 +364,7 @@ project_open() { tmux rename-window "${1}" fi - if [ -n "${2}" ] && [[ "${2}" != "theme" ]]; then + if [ -n "${2}" ]; then # shellcheck disable=SC2164 cd "${NEW_PATH}/${2}" return @@ -376,20 +376,8 @@ project_open() { NEW_PATH="${NEW_PATH}/site" fi - if [ -z "${2}" ]; then - # shellcheck disable=SC2164 - cd "${NEW_PATH}" - return - fi - - 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 - return 1 - fi - - cd "${THEME_PATH}" || return + # shellcheck disable=SC2164 + cd "${NEW_PATH}" } alias po='project_open' @@ -403,7 +391,7 @@ _project_open_projects() { _project_open_targets() { local name="${1}" project_path target_dir [[ -n "${name}" ]] || return 0 - project_path="$(_project_open_resolve "${name}" cache_only)" || return 0 + project_path="$(_project_open_resolve "${name}" cache_only)" [[ -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##*/}" From e4701acee67c0b377a34a3ebe2ffc822ef97deae Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 22:29:48 +0100 Subject: [PATCH 4/8] refactor(shell): scan projects in a single find pass instead of recursive find --- shell/aliases | 21 ++++++++++++--------- tests/project_open_test.sh | 6 ++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/shell/aliases b/shell/aliases index cc42d7f..0fd09e3 100644 --- a/shell/aliases +++ b/shell/aliases @@ -274,14 +274,17 @@ _project_open_root() { _project_open_scan() { local root="${1:-$(_project_open_root)}" - local dir - find "${root}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 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 + # One find lists every project marker (.git/bedrock/site) under root. Markers + # and hidden dirs are pruned, so we never descend into a .git or app dir; awk + # then maps each marker to its parent (the project) and keeps the outermost + # per subtree, dropping vendored submodules nested inside a project. + find "${root}" -mindepth 1 \ + \( -type d -name '.*' ! -name '.git' -prune \) -o \ + \( \( -name '.git' -o -name 'bedrock' -o -name 'site' \) -print -prune \) \ + 2>/dev/null | + sed 's#/[^/]*$##' | + LC_ALL=C sort -u | + awk 'last == "" || substr($0, 1, length(last) + 1) != last "/" { print; last = $0 }' } _project_open_cache_file() { @@ -305,7 +308,7 @@ _project_open_build_cache() { dir="${cache%/*}" mkdir -p "${dir}" 2>/dev/null || return 1 tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 - if _project_open_scan | LC_ALL=C sort >"${tmp}" && mv -f "${tmp}" "${cache}"; then + if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then return 0 fi rm -f "${tmp}" diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index 77495f1..bc46322 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -29,7 +29,8 @@ setup_fixture() { "${ROOT}/misc/khuey/.git" \ "${ROOT}/wordpress/acme/site" \ "${ROOT}/wordpress/beta/bedrock" \ - "${ROOT}/deep/a/b/proj/.git" + "${ROOT}/deep/a/b/proj/.git" \ + "${ROOT}/.hidden/proj/.git" } TEST_HOME="" @@ -45,8 +46,9 @@ tmux() { return 0; } test_scan() { local out + # Excludes nested submodules (lib/vcpkg) and hidden dirs (.hidden/proj). out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" - assert_eq "scan finds outermost projects, excludes submodules" \ + assert_eq "scan finds outermost projects, excludes submodules and hidden dirs" \ "deep/a/b/proj,misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ "${out}" } From c77bdbcc439847ecae105e94d73eb3cc4280c9fc Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 23:00:46 +0100 Subject: [PATCH 5/8] refactor(shell): scan projects at fixed category/project depth via .git --- shell/aliases | 16 +++++----------- tests/project_open_test.sh | 21 ++++++++++++--------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/shell/aliases b/shell/aliases index 0fd09e3..376a85b 100644 --- a/shell/aliases +++ b/shell/aliases @@ -274,17 +274,11 @@ _project_open_root() { _project_open_scan() { local root="${1:-$(_project_open_root)}" - # One find lists every project marker (.git/bedrock/site) under root. Markers - # and hidden dirs are pruned, so we never descend into a .git or app dir; awk - # then maps each marker to its parent (the project) and keeps the outermost - # per subtree, dropping vendored submodules nested inside a project. - find "${root}" -mindepth 1 \ - \( -type d -name '.*' ! -name '.git' -prune \) -o \ - \( \( -name '.git' -o -name 'bedrock' -o -name 'site' \) -print -prune \) \ - 2>/dev/null | - sed 's#/[^/]*$##' | - LC_ALL=C sort -u | - awk 'last == "" || substr($0, 1, length(last) + 1) != last "/" { print; last = $0 }' + # Projects live at // and always hold a .git, so one + # find at that fixed depth lists them; submodules sit deeper and fall out. + find "${root}" -mindepth 3 -maxdepth 3 -name '.git' 2>/dev/null | + sed 's#/\.git$##' | + LC_ALL=C sort } _project_open_cache_file() { diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh index bc46322..4b7f768 100644 --- a/tests/project_open_test.sh +++ b/tests/project_open_test.sh @@ -23,14 +23,17 @@ setup_fixture() { export PROJECT_OPEN_ROOT="${ROOT}" XDG_CACHE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/project_open_cache.XXXXXX")" export XDG_CACHE_HOME + # Projects sit at // and always hold a .git. + # wordpress projects carry a .git alongside their bedrock/site app dir; + # vcpkg is a nested submodule (deeper) that must be excluded. mkdir -p \ "${ROOT}/mods/HoldFast/.git" \ "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ "${ROOT}/misc/khuey/.git" \ + "${ROOT}/wordpress/acme/.git" \ "${ROOT}/wordpress/acme/site" \ - "${ROOT}/wordpress/beta/bedrock" \ - "${ROOT}/deep/a/b/proj/.git" \ - "${ROOT}/.hidden/proj/.git" + "${ROOT}/wordpress/beta/.git" \ + "${ROOT}/wordpress/beta/bedrock" } TEST_HOME="" @@ -46,10 +49,10 @@ tmux() { return 0; } test_scan() { local out - # Excludes nested submodules (lib/vcpkg) and hidden dirs (.hidden/proj). + # Excludes nested submodules (lib/vcpkg sits deeper than the fixed scan depth). out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" - assert_eq "scan finds outermost projects, excludes submodules and hidden dirs" \ - "deep/a/b/proj,misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ + assert_eq "scan finds category/project dirs, excludes submodules" \ + "misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ "${out}" } @@ -90,14 +93,14 @@ test_cache() { 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 wordpress project" "${ROOT}/wordpress/acme" \ + "$(_project_open_resolve acme)" 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," \ + assert_eq "projects lists basenames" "HoldFast,NewMod,acme,beta,khuey," \ "$(_project_open_projects | LC_ALL=C sort | tr '\n' ',')" assert_eq "targets lists subdirs" "lib" "$(_project_open_targets HoldFast)" From 3e774f270601e0198a060496e8ccbcbb79bc6693 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 23:04:47 +0100 Subject: [PATCH 6/8] fix(shell): drop dead project_open_scan argument flagged by shellcheck --- shell/aliases | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/aliases b/shell/aliases index 376a85b..b8b1230 100644 --- a/shell/aliases +++ b/shell/aliases @@ -273,7 +273,8 @@ _project_open_root() { } _project_open_scan() { - local root="${1:-$(_project_open_root)}" + local root + root="$(_project_open_root)" # Projects live at // and always hold a .git, so one # find at that fixed depth lists them; submodules sit deeper and fall out. find "${root}" -mindepth 3 -maxdepth 3 -name '.git' 2>/dev/null | From 746477b66bd99b311c41dcbd3c91dc9a51941c77 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Wed, 24 Jun 2026 23:08:38 +0100 Subject: [PATCH 7/8] fix(shell): sort project_open targets and return success from completion guard --- shell/aliases | 8 +++++--- shell/completions/_project_open | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/shell/aliases b/shell/aliases index b8b1230..cba620f 100644 --- a/shell/aliases +++ b/shell/aliases @@ -391,9 +391,11 @@ _project_open_targets() { [[ -n "${name}" ]] || return 0 project_path="$(_project_open_resolve "${name}" cache_only)" [[ -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 + 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 | + LC_ALL=C sort } _project_open_complete_bash() { diff --git a/shell/completions/_project_open b/shell/completions/_project_open index 7c8e8b7..c43dabd 100644 --- a/shell/completions/_project_open +++ b/shell/completions/_project_open @@ -4,7 +4,9 @@ # the repeated/deferred compinit calls in zshrc. Discovery helpers live in # shell/aliases and are available in the interactive shell. -(( $+functions[_project_open_projects] )) || return 1 +# Succeed with no candidates if the discovery helpers (from shell/aliases) aren't +# loaded yet, so zsh doesn't treat this as a failed completer. +(( $+functions[_project_open_projects] )) || return 0 local -a candidates From 0678c7081b6f5e7867f97da0e44a019bf8efe64f Mon Sep 17 00:00:00 2001 From: codepuncher Date: Thu, 25 Jun 2026 09:54:27 +0100 Subject: [PATCH 8/8] chore(test): convert project_open suite to bats and run it in ci --- .github/workflows/lint.yml | 14 ++- .gitmodules | 12 +++ tests/bats | 1 + tests/project_open.bats | 179 +++++++++++++++++++++++++++++++++ tests/project_open_test.sh | 152 ---------------------------- tests/test_helper.bash | 14 +++ tests/test_helper/bats-assert | 1 + tests/test_helper/bats-file | 1 + tests/test_helper/bats-support | 1 + 9 files changed, 221 insertions(+), 154 deletions(-) create mode 100644 .gitmodules create mode 160000 tests/bats create mode 100644 tests/project_open.bats delete mode 100644 tests/project_open_test.sh create mode 100644 tests/test_helper.bash create mode 160000 tests/test_helper/bats-assert create mode 160000 tests/test_helper/bats-file create mode 160000 tests/test_helper/bats-support diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a21be70..2b1335a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,8 +14,18 @@ jobs: uses: azohra/shell-linter@latest with: # zsh completion functions (shell/completions) use zsh-only syntax - # that ShellCheck cannot parse; ShellCheck has no zsh dialect. - exclude-paths: "shell/completions" + # ShellCheck cannot parse; tests/ holds .bats files (non-bash @test + # syntax) and third-party BATS submodules. ShellCheck skips both. + exclude-paths: "shell/completions,tests" + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Run BATS tests + run: ./tests/bats/bin/bats tests/*.bats lua: runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a431ec9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "tests/bats"] + path = tests/bats + url = https://github.com/bats-core/bats-core.git +[submodule "tests/test_helper/bats-support"] + path = tests/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "tests/test_helper/bats-assert"] + path = tests/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git +[submodule "tests/test_helper/bats-file"] + path = tests/test_helper/bats-file + url = https://github.com/bats-core/bats-file.git diff --git a/tests/bats b/tests/bats new file mode 160000 index 0000000..5a7db7a --- /dev/null +++ b/tests/bats @@ -0,0 +1 @@ +Subproject commit 5a7db7a98951d9d89b3b5e7800037e655a93345f diff --git a/tests/project_open.bats b/tests/project_open.bats new file mode 100644 index 0000000..03fb2c4 --- /dev/null +++ b/tests/project_open.bats @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Tests for project_open discovery/cache/resolution (shell/aliases). +# shellcheck disable=SC2030,SC2031 # BATS runs each test in a subshell by design + +load test_helper + +# tmux is irrelevant here; stub it to a no-op so sourcing/aliases stay quiet. +tmux() { return 0; } + +setup() { + ROOT="$(temp_make)" + export PROJECT_OPEN_ROOT="${ROOT}" + XDG_CACHE_HOME="$(temp_make)" + export XDG_CACHE_HOME + + # Projects sit at // and always hold a .git. + # wordpress projects carry a .git alongside their bedrock/site app dir; + # vcpkg is a nested submodule (deeper) that must be excluded from scans. + mkdir -p \ + "${ROOT}/mods/HoldFast/.git" \ + "${ROOT}/mods/HoldFast/lib" \ + "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ + "${ROOT}/misc/khuey/.git" \ + "${ROOT}/wordpress/acme/.git" \ + "${ROOT}/wordpress/acme/site" \ + "${ROOT}/wordpress/beta/.git" \ + "${ROOT}/wordpress/beta/bedrock" + + # shell/aliases sources ${HOME}/.dotfiles/shell/os.sh, so point HOME at a + # temp dir whose .dotfiles symlinks to this repo. That lets the suite run + # from any checkout path, not only ~/.dotfiles. + TEST_HOME="$(temp_make)" + ln -s "$(cd "${DOTFILES_DIR}" && pwd)" "${TEST_HOME}/.dotfiles" + export HOME="${TEST_HOME}" + + # shellcheck source=/dev/null + source "${HOME}/.dotfiles/shell/aliases" +} + +teardown() { + temp_del "${ROOT}" + temp_del "${XDG_CACHE_HOME}" + temp_del "${TEST_HOME}" +} + +@test "scan finds category/project dirs and excludes nested submodules" { + local out + out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" + assert_equal "${out}" "misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," +} + +@test "po_refresh creates the cache file" { + po_refresh + assert_file_exist "$(_project_open_cache_file)" +} + +@test "build is a no-op when the cache already exists" { + po_refresh + mkdir -p "${ROOT}/mods/NewMod/.git" + _project_open_build_cache + run grep -q '/NewMod$' "$(_project_open_cache_file)" + assert_failure +} + +@test "po_refresh rebuilds and picks up new projects" { + po_refresh + mkdir -p "${ROOT}/mods/NewMod/.git" + po_refresh + run grep -q '/NewMod$' "$(_project_open_cache_file)" + assert_success +} + +@test "read_cache is empty when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_read_cache + assert_output '' +} + +@test "resolve falls back to a scan when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_resolve HoldFast + assert_output "${ROOT}/mods/HoldFast" +} + +@test "resolve cache_only returns nothing when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_resolve HoldFast cache_only + assert_output '' +} + +@test "no cache is built when the root is missing" { + export PROJECT_OPEN_ROOT="${ROOT}/missing" + local missing_cache + missing_cache="$(_project_open_cache_file)" + run _project_open_build_cache + assert_file_not_exist "${missing_cache}" +} + +@test "resolve maps a project name to its path" { + run _project_open_resolve HoldFast + assert_output "${ROOT}/mods/HoldFast" +} + +@test "resolve maps a wordpress project to its path" { + run _project_open_resolve acme + assert_output "${ROOT}/wordpress/acme" +} + +@test "resolve of an unknown name is empty" { + run _project_open_resolve nope + assert_output '' +} + +@test "projects lists project basenames" { + po_refresh + local out + out="$(_project_open_projects | LC_ALL=C sort | tr '\n' ',')" + assert_equal "${out}" "HoldFast,acme,beta,khuey," +} + +@test "targets lists a project's subdirs" { + po_refresh + run _project_open_targets HoldFast + assert_output "lib" +} + +@test "targets of a childless project is empty" { + po_refresh + run _project_open_targets khuey + assert_output '' +} + +@test "targets of an unknown project is empty" { + po_refresh + run _project_open_targets nope + assert_output '' +} + +@test "no-arg project_open cds to the root" { + po_refresh + local dir + dir="$(cd / && project_open >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}" +} + +@test "project_open cds into a plain project" { + po_refresh + local dir + dir="$(cd / && project_open HoldFast >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/mods/HoldFast" +} + +@test "project_open descends into bedrock" { + po_refresh + local dir + dir="$(cd / && project_open beta >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/wordpress/beta/bedrock" +} + +@test "project_open descends into site" { + po_refresh + local dir + dir="$(cd / && project_open acme >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/wordpress/acme/site" +} + +@test "project_open cds into the subdir" { + po_refresh + local dir + dir="$(cd / && project_open HoldFast lib >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/mods/HoldFast/lib" +} + +@test "project_open of an unknown project returns 1" { + po_refresh + local rc=0 + (cd / && project_open nope >/dev/null 2>&1) || rc=$? + assert_equal "${rc}" "1" +} diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh deleted file mode 100644 index 4b7f768..0000000 --- a/tests/project_open_test.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/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() { - # 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 "${TMPDIR:-/tmp}/project_open_cache.XXXXXX")" - export XDG_CACHE_HOME - # Projects sit at // and always hold a .git. - # wordpress projects carry a .git alongside their bedrock/site app dir; - # vcpkg is a nested submodule (deeper) that must be excluded. - mkdir -p \ - "${ROOT}/mods/HoldFast/.git" \ - "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ - "${ROOT}/misc/khuey/.git" \ - "${ROOT}/wordpress/acme/.git" \ - "${ROOT}/wordpress/acme/site" \ - "${ROOT}/wordpress/beta/.git" \ - "${ROOT}/wordpress/beta/bedrock" -} - -TEST_HOME="" -cleanup() { - [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" - [[ -n "${XDG_CACHE_HOME}" ]] && rm -rf "${XDG_CACHE_HOME}" - [[ -n "${TEST_HOME}" ]] && rm -rf "${TEST_HOME}" -} -trap cleanup EXIT - -# tmux is unavailable / irrelevant in tests; stub it to a no-op. -tmux() { return 0; } - -test_scan() { - local out - # Excludes nested submodules (lib/vcpkg sits deeper than the fixed scan depth). - out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" - assert_eq "scan finds category/project dirs, excludes submodules" \ - "misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ - "${out}" -} - -test_cache() { - local cache - po_refresh - cache="$(_project_open_cache_file)" - assert_eq "cache file created" "yes" "$([[ -f "${cache}" ]] && echo yes || echo no)" - - # build is a no-op when the cache already exists: a new project must NOT appear. - mkdir -p "${ROOT}/mods/NewMod/.git" - _project_open_build_cache - assert_eq "build skips when cache exists" "no" \ - "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" - - # po_refresh forces a rebuild and picks up the new project. - po_refresh - assert_eq "po_refresh rebuilds" "yes" \ - "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" - - rm -f "${cache}" - 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)" - - # Check the cache path for the missing root itself, since cache files are - # keyed by root: building under a missing root must not create its cache. - local missing_cache - missing_cache="$(PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_cache_file)" - PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_build_cache - assert_eq "no cache built when root missing" "no" \ - "$([[ -f "${missing_cache}" ]] && echo yes || echo no)" -} - -test_resolve_and_completion() { - assert_eq "resolve HoldFast to path" "${ROOT}/mods/HoldFast" \ - "$(_project_open_resolve HoldFast)" - assert_eq "resolve wordpress project" "${ROOT}/wordpress/acme" \ - "$(_project_open_resolve acme)" - 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," \ - "$(_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)" - # 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 "${HOME}/.dotfiles/shell/aliases" - - test_scan - test_cache - test_resolve_and_completion - test_project_open - - exit $(( FAILS > 0 ? 1 : 0 )) -} - -main "$@" diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..c861d12 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Common test helper for BATS tests. + +# GitHub Actions sets TERM=dumb, which makes tput error; force a sane value. +if [[ -z "${TERM:-}" || "${TERM}" == "dumb" ]]; then + export TERM="xterm" +fi + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/bats-file/load' + +# Repo root (tests/ lives directly under it). +DOTFILES_DIR="${BATS_TEST_DIRNAME}/.." diff --git a/tests/test_helper/bats-assert b/tests/test_helper/bats-assert new file mode 160000 index 0000000..697471b --- /dev/null +++ b/tests/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/tests/test_helper/bats-file b/tests/test_helper/bats-file new file mode 160000 index 0000000..6bee58b --- /dev/null +++ b/tests/test_helper/bats-file @@ -0,0 +1 @@ +Subproject commit 6bee58bec7c2f4aed1a7425ccd4bdc42b4a84599 diff --git a/tests/test_helper/bats-support b/tests/test_helper/bats-support new file mode 160000 index 0000000..0954abb --- /dev/null +++ b/tests/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96