Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 103 additions & 33 deletions shell/aliases
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,99 @@ 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 2>/dev/null | while IFS= read -r dir; do
Comment thread
codepuncher marked this conversation as resolved.
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"
}

_project_open_build_cache() {
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}"
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
[[ -f "${cache}" ]] && (( now - last < ttl )) && 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
printf '%s\n' "${now}" >"${stamp}"
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}" "${cache}.ts"
_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

Comment thread
codepuncher marked this conversation as resolved.
Expand All @@ -298,9 +382,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
Expand All @@ -311,29 +394,18 @@ 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##*/}"
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}" 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
}
Expand All @@ -343,8 +415,6 @@ _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.
if (( COMP_CWORD == 1 )); then
while IFS= read -r candidate; do
[[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}")
Expand All @@ -366,12 +436,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
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
Expand All @@ -391,11 +461,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
Expand All @@ -407,6 +474,9 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then
_project_open_register_zsh
else
add-zsh-hook precmd _project_open_register_zsh
fi
if [[ -o interactive ]]; then
_project_open_build_cache >/dev/null 2>&1 &!
fi'
fi

Expand Down
151 changes: 151 additions & 0 deletions tests/project_open_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/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
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)"

rm -f "${cache}" "${cache}.ts"
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)"

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" \
"$([[ -f "${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 "$@"
Loading