https://github.com/open-technology-foundation/whichx
A robust bash implementation of the classic Unix which command with enhanced features and comprehensive error handling.
https://github.com/open-technology-foundation/whichx
bash which
Last synced: 2 months ago
JSON representation
A robust bash implementation of the classic Unix which command with enhanced features and comprehensive error handling.
- Host: GitHub
- URL: https://github.com/open-technology-foundation/whichx
- Owner: Open-Technology-Foundation
- License: gpl-3.0
- Created: 2025-10-04T04:58:40.000Z (9 months ago)
- Default Branch: main
- Last Pushed: 2025-10-04T05:20:43.000Z (9 months ago)
- Last Synced: 2025-10-04T07:16:47.356Z (9 months ago)
- Topics: bash, which
- Language: Shell
- Homepage: https://yatti.id/
- Size: 15.6 KB
- Stars: 1
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# which
A robust, POSIX-compliant `which` replacement for Bash.
[](LICENSE)
[](https://www.gnu.org/software/bash/)
[](tests/)
**Requires Bash 4.4+** — runs on any OS with a compatible shell.
## TL;DR
```bash
git clone https://github.com/Open-Technology-Foundation/whichx.git
cd whichx && sudo make install
which -a python3
```
## Why Replace which?
The standard `which` command varies significantly across Unix systems:
| Issue | Debian | macOS | Busybox |
|-------|--------|-------|---------|
| Exit code (no args) | 1 | 0 | 0 |
| Exit code (bad option) | 2 | 1 | 1 |
| `-s` silent mode | No | Yes | No |
| Long options | No | No | No |
This implementation provides:
- **Consistent exit codes**: 0 (found), 1 (not found), 2 (no args), 22 (EINVAL)
- **POSIX PATH compliance**: Correct handling of empty PATH elements
- **Dual-mode execution**: Run as script OR source as function (12x faster)
- **Canonical resolution**: Follow symlinks to actual executables
## Installation
### Quick Install
```bash
git clone https://github.com/Open-Technology-Foundation/whichx.git
cd whichx && sudo make install
```
Installs to `/usr/local/bin/which` with man page.
### Custom Prefix
```bash
sudo make install PREFIX=/usr/bin
```
### Sourceable Install (Recommended for Interactive Use)
```bash
sudo make install-sourceable
```
This copies the script to `/etc/profile.d/which.sh`. New shells will have `which()` as a shell function instead of calling an external process.
**Why is this faster?** Each external command invocation requires fork() + exec() + bash interpreter startup (~1.6ms). A shell function runs in-process (~0.13ms). That's **12x faster**.
**Note:** `/etc/profile.d/` is sourced by login shells via `/etc/profile`. Most terminal emulators start non-login shells, which source `~/.bashrc` instead. If `which` isn't available in new terminals, either:
- Add `source /etc/profile.d/which.sh` to your `~/.bashrc`, or
- Configure your terminal to start login shells
### Uninstall
```bash
sudo make uninstall
sudo make uninstall-sourceable
```
## Usage
```
which [OPTIONS] [--] command ...
```
### Options
| Option | Long | Description |
|--------|------|-------------|
| `-a` | `--all` | Print all matches in PATH, not just first |
| `-c` | `--canonical` | Resolve symlinks via realpath/readlink |
| `-q` | `--quiet` | No output, exit code only |
| `-s` | `--silent` | Alias for `-q` |
| `-V` | `--version` | Print version and exit |
| `-h` | `--help` | Print help and exit |
Options can be combined: `-ac` equals `-a -c`
### Exit Codes
| Code | Constant | Meaning |
|------|----------|---------|
| 0 | `EXIT_SUCCESS` | All commands found |
| 1 | `EXIT_FAILURE` | One or more not found |
| 2 | `EXIT_USAGE` | No arguments provided |
| 22 | `EINVAL` | Invalid option |
### Examples
```bash
which ls # /usr/bin/ls
which -a python3 # All python3 in PATH
which -c /usr/bin/python3 # Resolves to /usr/bin/python3.12
which -q docker && echo "ok" # Silent check
which ls cat grep # Multiple commands
which -- -weird-name # Command starting with hyphen
```
## Architecture
### Dual-Mode Design
The script works both as an executable and as a sourceable function:
```bash
# As executable (subprocess)
./which ls
# As sourced function (in-process)
source ./which
which ls
```
This is achieved with the `BASH_SOURCE` guard:
```bash
which() {
# ... function body ...
}
declare -fx which
[[ "${BASH_SOURCE[0]}" == "$0" ]] || return 0
# --- Script mode (direct execution only) ---
set -euo pipefail
shopt -s inherit_errexit
which_help() { ... }
which "$@"
```
When sourced, `BASH_SOURCE[0]` differs from `$0`, so `return 0` exits early after defining the function. When executed, they match, so the script continues to run `which "$@"`.
### Strict Mode Without Pollution
Traditional bash scripts use strict mode at the top, but this would pollute the sourcing shell's environment. This script solves that by placing strict mode **after** the BASH_SOURCE guard:
- **Sourced**: Returns before reaching `set -euo pipefail` — caller's shell unaffected
- **Executed**: Strict mode applies only to the subprocess
### Function Structure
All logic lives in a single `which()` function with:
- All variables declared `local` (no namespace pollution)
- `return` instead of `exit` (function-safe)
- Inline PATH parsing (no helper functions to leak)
- Conditional help: brief when sourced, full when executed
### PATH Parsing
```bash
path_str=${PATH:-}
[[ $path_str == *: ]] && path_str+='.' # Trailing colon = cwd
IFS=':' read -ra path_dirs <<< "$path_str"
for path in "${path_dirs[@]}"; do
[[ -n $path ]] || path='.' # Empty element = cwd
# ...
done
```
The `read -ra` with herestring is a common idiom, but it drops trailing empty elements. The `*:` check handles trailing colons explicitly.
## POSIX Compliance
Per POSIX, an empty element in PATH means the current directory. Many `which` implementations get this wrong.
```bash
# Leading colon = cwd searched first
PATH=":/usr/bin" which ./script
# Trailing colon = cwd searched last
PATH="/usr/bin:" which ./script
# Double colon = cwd searched in middle
PATH="/usr/bin::/usr/local/bin" which ./script
```
This matters for security audits and understanding command resolution.
## Performance
### Methodology
Benchmarks run each command 1000 times, measuring wall-clock time with nanosecond precision.
### Results
| Test | which (subprocess) | which (sourced) | old.which (dash) |
|------|-------------------|-----------------|------------------|
| Single lookup | ~600 ops/s | ~7,500 ops/s | ~1,200 ops/s |
| Large PATH (50 dirs) | ~500 ops/s | ~6,000 ops/s | ~1,200 ops/s |
| Not found | ~600 ops/s | ~7,500 ops/s | ~1,200 ops/s |
### Analysis
**Why is subprocess mode 2x slower than dash-based which?**
Bash has more startup overhead than dash. The actual PATH searching is nearly identical, but bash's interpreter initialization dominates.
**Why is sourced mode 12x faster?**
No fork(), no exec(), no interpreter startup. The function runs directly in the current shell's process space.
### Run Benchmarks
```bash
make benchmark
```
## Testing
### Run Tests
```bash
make test # shellcheck + functional tests
make shellcheck # Static analysis only
make functional # 69 functional tests only
```
### Test Coverage
- Basic operations (find, not found, multiple targets)
- All options (`-a`, `-c`, `-q`, `-s`, `-V`, `-h`, `--long`)
- Combined options (`-ac`, `-qa`, `-aqs`)
- Exit codes (0, 1, 2, 22)
- PATH edge cases (leading/trailing/double colon, empty, nonexistent dirs)
- Input handling (absolute path, relative path, `--` separator, hyphen commands)
- Edge cases (non-executable, directories, symlinks, broken symlinks)
### Adding Tests
Tests use TAP-style output. Add to `tests/test_which.sh`:
```bash
out=$("$WHICH" -a python3 2>&1); rc=$?
assert_exit 0 $rc "description"
assert_contains "python" "$out" "description"
```
## Contributing
### Code Style
- All variables `local` (sourceable requirement)
- Integer variables: `local -i count=0`
- Arrays: `local -a items=()`
- Conditionals: `[[ ]]` never `[ ]`
- Arithmetic: `(( ))` only
- 2-space indentation
- Quote all variable expansions
- Errors to stderr: `printf >&2`
### Requirements
- Must pass `shellcheck`
- Must pass all 69 tests
- No new dependencies
### Pull Requests
1. Fork the repository
2. Create a feature branch
3. Make changes
4. Run `make test`
5. Submit PR
## License
GPL-3.0-or-later — see [LICENSE](LICENSE)
**Indonesian Open Technology Foundation**
admin@yatti.id
## See Also
- `man which` — installed man page
- `type(1)` — bash builtin, shows aliases/functions too
- `command -v` — POSIX way to find commands
- `whereis(1)` — also searches man pages and source