feat(activate): add activate.fish for the fish shell
`activate` is bash syntax, so fish users had no way to take on the dev environment. `activate.fish` does what `activate` does: snapshots the keys it will change, applies the composed env, defines a `hermes` function that refuses outside the worktree, adds the prompt prefix, and defines `deactivate`. It shells out to the same bash library, so composition is not reimplemented. `pm.environments --format fish` emits the env as `set -gx` statements. fish's read-only names (`PWD`, `SHLVL`, `_`, ...) are skipped because `set -gx PWD` aborts the script, and values named `*PATH` stay lists. `hermes_compose_env` takes the dialect (`sh` or `fish`) as its second argument.
This commit is contained in:
@@ -202,7 +202,7 @@ source ./activate # provisions/syncs PM tools + dependencies, then activates
|
||||
```
|
||||
Select an isolated development `HERMES_HOME` and `HERMES_RUNTIME_DIR` first;
|
||||
see `website/docs/reference/package-management.md#developer-workflow`.
|
||||
PowerShell: `. .\activate.ps1`. `deactivate` restores the prior
|
||||
fish: `source ./activate.fish`; PowerShell: `. .\activate.ps1`. `deactivate` restores the prior
|
||||
environment. Non-interactive callers run under `scripts/run-in-hermes-env CMD...` instead of sourcing.
|
||||
For tests, use the independent test environment in `CONTRIBUTING.md` (or Nix);
|
||||
PM activation's `PYTHONPATH` does not survive the test runner's environment scrub.
|
||||
|
||||
@@ -136,6 +136,13 @@ source ./activate
|
||||
hermes --version
|
||||
```
|
||||
|
||||
fish:
|
||||
|
||||
```fish
|
||||
source ./activate.fish
|
||||
hermes --version
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
|
||||
7
activate
7
activate
@@ -2,8 +2,9 @@
|
||||
# ============================================================================
|
||||
# venv-style activation of the Hermes dev environment for an interactive shell.
|
||||
#
|
||||
# source ./activate (bash / zsh, repo root)
|
||||
# .\activate.ps1 (PowerShell)
|
||||
# source ./activate (bash / zsh, repo root)
|
||||
# source ./activate.fish (fish)
|
||||
# .\activate.ps1 (PowerShell)
|
||||
#
|
||||
# Applies the composed pm env (PATH + tool vars) to the CURRENT shell, with
|
||||
# save/restore: `deactivate` undoes exactly what activation changed.
|
||||
@@ -65,7 +66,7 @@ if [ "$(type -t deactivate 2>/dev/null)" = "function" ]; then
|
||||
deactivate
|
||||
fi
|
||||
|
||||
_hermes_script="$(hermes_compose_env "$_HERMES_REPO")" || {
|
||||
_hermes_script="$(hermes_compose_env "$_HERMES_REPO" sh)" || {
|
||||
echo "activate: could not compose the environment (see above)" >&2
|
||||
unset _hermes_script
|
||||
return 1 2>/dev/null || exit 1
|
||||
|
||||
111
activate.fish
Executable file
111
activate.fish
Executable file
@@ -0,0 +1,111 @@
|
||||
# venv-style activation of the Hermes dev environment for fish.
|
||||
#
|
||||
# source ./activate.fish
|
||||
#
|
||||
# The fish counterpart of ./activate: applies the composed pm env to the
|
||||
# CURRENT shell, with save/restore (`deactivate` undoes exactly what activation
|
||||
# changed), makes `hermes` a function for this checkout, and prefixes the prompt
|
||||
# with the worktree name. Scripts and other processes run under
|
||||
# `scripts/run-in-hermes-env` instead.
|
||||
#
|
||||
# Options: `--test-extras a,b` selects the test environment's runtime extras
|
||||
# (default: [all]).
|
||||
#
|
||||
# Composition (setup sync, bootstrap Python, pm env) is shared with ./activate
|
||||
# through scripts/_activation.sh, so this file only applies its output.
|
||||
|
||||
set -l repo (path resolve (path dirname (status filename)))
|
||||
|
||||
argparse 'test-extras=' -- $argv
|
||||
or return 2
|
||||
set -l test_environment --test-environment
|
||||
if set -q _flag_test_extras
|
||||
set test_environment --test-environment=$_flag_test_extras
|
||||
end
|
||||
|
||||
set -l library '. "$1/scripts/_activation.sh"'
|
||||
bash -c "$library; hermes_sync \"\$1\" \"\$2\"" _ $repo $test_environment >&2
|
||||
or begin
|
||||
echo 'activate.fish: setup failed; shell environment unchanged' >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
# Re-activating deactivates first, as ./activate does.
|
||||
functions -q deactivate
|
||||
and deactivate
|
||||
|
||||
set -l composed (bash -c "$library; hermes_compose_env \"\$1\" fish" _ $repo)
|
||||
or begin
|
||||
echo 'activate.fish: could not compose the environment (see above)' >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
# --- snapshot what we are about to change (deactivate restores this) ---
|
||||
set -g __hermes_keys (string replace -rf '^set -gx (\S+) .*' '$1' -- $composed)
|
||||
for name in $__hermes_keys
|
||||
if set -q $name
|
||||
set -g __hermes_had_$name 1
|
||||
set -g __hermes_prior_$name $$name
|
||||
end
|
||||
end
|
||||
string join \n -- $composed | source
|
||||
|
||||
# This checkout, not whichever `hermes` PATH finds. A function beats PATH.
|
||||
set -g __hermes_worktree $repo
|
||||
# The branch names the worktree. A checkout cannot share a branch with another.
|
||||
set -g __hermes_worktree_name (git -C $repo rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
if test -z "$__hermes_worktree_name" -o "$__hermes_worktree_name" = HEAD
|
||||
set __hermes_worktree_name (path basename $repo)
|
||||
end
|
||||
|
||||
function __hermes_worktree_here
|
||||
set -l top (git rev-parse --show-toplevel 2>/dev/null)
|
||||
or return 1
|
||||
test (path resolve $top) = (path resolve $__hermes_worktree)
|
||||
end
|
||||
|
||||
function hermes --description 'hermes of the activated checkout'
|
||||
if not __hermes_worktree_here
|
||||
echo "hermes: $PWD is outside $__hermes_worktree; refusing (the installed command is hidden while this checkout is active)" >&2
|
||||
return 1
|
||||
end
|
||||
set -l python python
|
||||
if set -q PYTHON
|
||||
set python $PYTHON
|
||||
else if test -x $__hermes_worktree/.venv/bin/python
|
||||
set python $__hermes_worktree/.venv/bin/python
|
||||
else if test -x $__hermes_worktree/venv/bin/python
|
||||
set python $__hermes_worktree/venv/bin/python
|
||||
end
|
||||
pushd $__hermes_worktree >/dev/null
|
||||
$python hermes $argv
|
||||
set -l code $status
|
||||
popd >/dev/null
|
||||
return $code
|
||||
end
|
||||
|
||||
functions -c fish_prompt __hermes_saved_fish_prompt
|
||||
function fish_prompt
|
||||
if __hermes_worktree_here
|
||||
printf '(%s) ' $__hermes_worktree_name
|
||||
end
|
||||
__hermes_saved_fish_prompt
|
||||
end
|
||||
|
||||
function deactivate --description 'undo activate.fish'
|
||||
for name in $__hermes_keys
|
||||
set -l had __hermes_had_$name
|
||||
set -l prior __hermes_prior_$name
|
||||
if set -q $had
|
||||
set -gx $name $$prior
|
||||
else
|
||||
set -eg $name
|
||||
end
|
||||
set -eg $had $prior
|
||||
end
|
||||
functions -e fish_prompt
|
||||
functions -c __hermes_saved_fish_prompt fish_prompt
|
||||
functions -e __hermes_saved_fish_prompt
|
||||
set -eg __hermes_keys __hermes_worktree __hermes_worktree_name
|
||||
functions -e deactivate hermes __hermes_worktree_here
|
||||
end
|
||||
@@ -395,25 +395,45 @@ def activation_environment(project_root: Path) -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def _fish_quote(value: str) -> str:
|
||||
"""Inside fish single quotes only backslash and the quote itself are special."""
|
||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
|
||||
# fish refuses to assign these, and each is either inherited unchanged from the
|
||||
# invoking shell (PWD, SHLVL, _) or fish's own state, so skipping loses nothing.
|
||||
_FISH_READ_ONLY = frozenset({
|
||||
"PWD", "SHLVL", "_", "status", "version", "hostname", "fish_pid", "history",
|
||||
"pipestatus", "status_generation", "umask", "FISH_VERSION",
|
||||
})
|
||||
|
||||
# dialect -> (export statement, names the shell cannot assign). fish splits
|
||||
# values of variables named *PATH on colons when they are set from a single
|
||||
# word, so PATH stays a list.
|
||||
_SHELL_DIALECTS = {
|
||||
"sh": (lambda name, value: f"export {name}={shlex.quote(value)}", frozenset()),
|
||||
"fish": (lambda name, value: f"set -gx {name} {_fish_quote(value)}", _FISH_READ_ONLY),
|
||||
}
|
||||
_SHELL_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
def shell_exports(env: dict[str, str]) -> str:
|
||||
"""The composed environment as a POSIX shell script.
|
||||
def shell_exports(env: dict[str, str], dialect: str) -> str:
|
||||
"""The composed environment as a script a shell of ``dialect`` can evaluate.
|
||||
|
||||
Windows carries names like ``ProgramFiles(ARM)`` that no shell can assign;
|
||||
they pass through untouched instead of failing the whole script.
|
||||
"""
|
||||
return "\n".join(f"export {name}={shlex.quote(str(value))}" for name, value in env.items()
|
||||
if _SHELL_IDENTIFIER.fullmatch(name))
|
||||
statement, read_only = _SHELL_DIALECTS[dialect]
|
||||
return "\n".join(statement(name, str(value)) for name, value in env.items()
|
||||
if _SHELL_IDENTIFIER.fullmatch(name) and name not in read_only)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="Print the composed activation environment.")
|
||||
parser.add_argument("--format", choices=["json", "sh"], default="json")
|
||||
parser.add_argument("--format", choices=["json", *_SHELL_DIALECTS], default="json")
|
||||
options = parser.parse_args(argv)
|
||||
env = activation_environment(Path(__file__).resolve().parents[1])
|
||||
print(json.dumps(env) if options.format == "json" else shell_exports(env))
|
||||
print(json.dumps(env) if options.format == "json" else shell_exports(env, options.format))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# process, one-way) both build on it; neither owns any of it.
|
||||
#
|
||||
# hermes_sync REPO [FLAG] bring the install up to date (setup-hermes.sh)
|
||||
# hermes_compose_env REPO print the composed environment as an `sh` script
|
||||
# hermes_apply_env SCRIPT evaluate that script into this shell
|
||||
# hermes_compose_env REPO DIALECT print the composed environment, `sh` or `fish`
|
||||
# hermes_apply_env SCRIPT evaluate a composed `sh` script into this shell
|
||||
# hermes_activation_current REPO is the inherited environment still REPO's?
|
||||
# hermes_ensure_env REPO sync, compose and apply unless it is current
|
||||
#
|
||||
@@ -48,15 +48,15 @@ hermes_bootstrap_python() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# hermes_compose_env REPO: print the environment of the installed state as an
|
||||
# `sh` script.
|
||||
# hermes_compose_env REPO DIALECT: print the environment of the installed state
|
||||
# as a script for a shell of DIALECT (`sh` or `fish`).
|
||||
hermes_compose_env() {
|
||||
local repo="$1" python script
|
||||
local repo="$1" dialect="$2" python script
|
||||
python="$(hermes_bootstrap_python "$repo")" || {
|
||||
echo "no bootstrap Python found; run setup-hermes.sh" >&2
|
||||
return 1
|
||||
}
|
||||
script="$(PYTHONHOME= PYTHONPATH="$repo" "$python" -m pm.environments --format sh)" &&
|
||||
script="$(PYTHONHOME= PYTHONPATH="$repo" "$python" -m pm.environments --format "$dialect")" &&
|
||||
[ -n "$script" ] || {
|
||||
echo "could not read pm env (run ./setup-hermes.sh first)" >&2
|
||||
return 1
|
||||
@@ -64,7 +64,7 @@ hermes_compose_env() {
|
||||
printf '%s\n' "$script"
|
||||
}
|
||||
|
||||
# hermes_apply_env SCRIPT: evaluate a script from `hermes_compose_env REPO`.
|
||||
# hermes_apply_env SCRIPT: evaluate a script from `hermes_compose_env REPO sh`.
|
||||
hermes_apply_env() {
|
||||
# Resolved before the eval replaces PATH.
|
||||
local cygpath name
|
||||
@@ -121,6 +121,6 @@ hermes_ensure_env() {
|
||||
echo "setup failed" >&2
|
||||
return 1
|
||||
}
|
||||
script="$(hermes_compose_env "$repo")" || return 1
|
||||
script="$(hermes_compose_env "$repo" sh)" || return 1
|
||||
hermes_apply_env "$script"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from pm.store import current_target
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
ACTIVATE = REPO_ROOT / "activate"
|
||||
ACTIVATE_FISH = REPO_ROOT / "activate.fish"
|
||||
ACTIVATE_PS1 = REPO_ROOT / "activate.ps1"
|
||||
SETUP_HERMES_SH = REPO_ROOT / "setup-hermes.sh"
|
||||
SETUP_HERMES_PS1 = REPO_ROOT / "setup-hermes.ps1"
|
||||
@@ -160,7 +161,7 @@ def isolated_checkout(tmp_path: Path) -> Path:
|
||||
shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(root / "hermes_cli").mkdir()
|
||||
(root / "scripts").mkdir()
|
||||
for relative in ("activate", "activate.ps1", "scripts/_activation.sh",
|
||||
for relative in ("activate", "activate.fish", "activate.ps1", "scripts/_activation.sh",
|
||||
"hermes_constants.py", "hermes_cli/__init__.py",
|
||||
"pm/environments.py", "hermes_cli/runtime_state.py"):
|
||||
shutil.copy2(REPO_ROOT / relative, root / relative)
|
||||
|
||||
85
tests/pm/test_activate_fish.py
Normal file
85
tests/pm/test_activate_fish.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""``activate.fish``: fish takes on the environment and gives it back, as ``activate`` does for bash.
|
||||
|
||||
Runs the real script under a real fish against the shared isolated checkout
|
||||
(tests/pm/activation_support.py). Skipped where fish is not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm.activation_support import fake_store, posix, sync_checkout
|
||||
|
||||
# Spawns children with a home it builds itself; the parent's must stay real.
|
||||
pytestmark = [
|
||||
pytest.mark.real_machine_home,
|
||||
pytest.mark.platforms("posix"),
|
||||
pytest.mark.skipif(shutil.which("fish") is None, reason="fish is not installed"),
|
||||
]
|
||||
|
||||
|
||||
def _fish(root: Path, env: dict, script: str, tmp_path: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["fish", "--no-config", "-c", script.replace("@ACTIVATE@", posix(root / "activate.fish"))],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def test_activate_applies_the_environment_and_deactivate_restores_the_shell(tmp_path):
|
||||
root, env = sync_checkout(tmp_path)
|
||||
fake_store(tmp_path)
|
||||
run = _fish(root, env, """
|
||||
set -gx PYTHONPATH caller-original
|
||||
set -gx VIRTUAL_ENV caller-venv
|
||||
set -l original_path (string join : $PATH)
|
||||
|
||||
source "@ACTIVATE@"
|
||||
or exit 10
|
||||
# The sentinel and the tool variables must reach child processes.
|
||||
bash -c 'test -n "$__HERMES_ACTIVATED"'; or exit 11
|
||||
test (bash -c 'printf %s "$HERMES_PM_ACTIVATE_CANARY"') = env-ok; or exit 12
|
||||
# PYTHONPATH stays a list: the checkout first, then the selected environment.
|
||||
printf '%s\\n' $PYTHONPATH
|
||||
|
||||
# Re-activating deactivates first, so one deactivate undoes everything.
|
||||
source "@ACTIVATE@"
|
||||
or exit 13
|
||||
deactivate
|
||||
|
||||
test (string join : $PATH) = $original_path; or exit 20
|
||||
test "$PYTHONPATH" = caller-original; or exit 21
|
||||
test "$VIRTUAL_ENV" = caller-venv; or exit 22
|
||||
set -q __HERMES_ACTIVATED; and exit 23
|
||||
set -q HERMES_PM_ACTIVATE_CANARY; and exit 24
|
||||
functions -q deactivate; and exit 25
|
||||
functions -q hermes; and exit 26
|
||||
functions -q __hermes_saved_fish_prompt; and exit 27
|
||||
exit 0
|
||||
""", tmp_path)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
assert run.stdout.splitlines()[0] == str(root)
|
||||
assert "/first/venv/" in run.stdout.splitlines()[1]
|
||||
|
||||
|
||||
def test_setup_failure_leaves_the_caller_unchanged(tmp_path):
|
||||
root, env = sync_checkout(tmp_path)
|
||||
fake_store(tmp_path)
|
||||
(root / "fail").touch()
|
||||
run = _fish(root, env, """
|
||||
set -gx PYTHONPATH caller-path
|
||||
set -l before (env | sort | string collect)
|
||||
|
||||
source "@ACTIVATE@"
|
||||
set -l code $status
|
||||
test $code -ne 0; or exit 10
|
||||
test (env | sort | string collect) = "$before"; or exit 11
|
||||
set -q __HERMES_ACTIVATED; and exit 12
|
||||
functions -q deactivate; and exit 13
|
||||
exit 0
|
||||
""", tmp_path)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
assert "setup failed" in run.stderr
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
What a process launched under ``scripts/run-in-hermes-env`` sees is covered in
|
||||
tests/scripts/test_run_in_hermes_env.py; how the environment is composed, in
|
||||
test_environment_script.py.
|
||||
test_environment_script.py; the fish port, in test_activate_fish.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""``pm.environments.shell_exports``: the composed environment as a script a shell can evaluate.
|
||||
|
||||
Both ``activate`` and ``scripts/run-in-hermes-env`` evaluate this output, so the
|
||||
contract is what a real shell reads back: any value survives the quoting, and
|
||||
names the shell cannot assign are left out instead of failing the whole script.
|
||||
Every shell that takes on the environment (``activate``, ``activate.fish``,
|
||||
``scripts/run-in-hermes-env``) evaluates this output, so the contract is what a
|
||||
real shell reads back: any value survives its dialect's quoting, and names the
|
||||
shell cannot assign are left out instead of failing the whole script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
@@ -24,19 +26,50 @@ NASTY = {
|
||||
"MULTILINE": "first\nsecond",
|
||||
}
|
||||
|
||||
# Prints each value NUL-terminated, in the order of NASTY.
|
||||
# Prints each value NUL-terminated, in the order of NASTY; `$NAME` reads the same in both shells.
|
||||
PROBE = "printf '%s\\0' " + " ".join(f'"${name}"' for name in NASTY)
|
||||
|
||||
|
||||
def _shell(dialect: str) -> list[str]:
|
||||
if dialect == "sh":
|
||||
return [bash(), "-c"]
|
||||
found = shutil.which("fish")
|
||||
if found is None:
|
||||
pytest.skip("fish is not installed")
|
||||
return [found, "--no-config", "-c"]
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_a_real_shell_reads_every_value_back_unchanged():
|
||||
script = shell_exports(NASTY) + "\n" + PROBE
|
||||
run = subprocess.run([bash(), "-c", script], capture_output=True, text=True, timeout=30)
|
||||
@pytest.mark.parametrize("dialect", ["sh", "fish"])
|
||||
def test_a_real_shell_reads_every_value_back_unchanged(dialect):
|
||||
script = shell_exports(NASTY, dialect) + "\n" + PROBE
|
||||
run = subprocess.run([*_shell(dialect), script], capture_output=True, text=True, timeout=30)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert run.stdout.split("\0")[:-1] == list(NASTY.values())
|
||||
|
||||
|
||||
def test_names_no_shell_can_assign_are_left_out():
|
||||
script = shell_exports({"ProgramFiles(ARM)": "x", "1BAD": "x", "GOOD": "y"})
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_fish_keeps_path_variables_as_lists():
|
||||
script = shell_exports({"PATH": "/a b:/c", "PYTHONPATH": "/x:/y"}, "fish")
|
||||
run = subprocess.run(
|
||||
[*_shell("fish"), script + "\nprintf '%s ' (count $PATH) (count $PYTHONPATH)"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert run.stdout.split() == ["2", "2"]
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_fish_read_only_variables_do_not_abort_the_script():
|
||||
script = shell_exports({"PWD": "/elsewhere", "SHLVL": "9", "AFTER": "reached"}, "fish")
|
||||
run = subprocess.run([*_shell("fish"), script + "\nprintf %s $AFTER"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert run.returncode == 0 and run.stderr == "", run.stderr
|
||||
assert run.stdout == "reached"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dialect", ["sh", "fish"])
|
||||
def test_names_no_shell_can_assign_are_left_out(dialect):
|
||||
script = shell_exports({"ProgramFiles(ARM)": "x", "1BAD": "x", "GOOD": "y"}, dialect)
|
||||
assert "GOOD" in script
|
||||
assert "ProgramFiles" not in script and "1BAD" not in script
|
||||
|
||||
@@ -315,12 +315,14 @@ Then activate it; there is no separate setup command to remember:
|
||||
| Shell | Enter | Leave |
|
||||
|---|---|---|
|
||||
| Bash | `source ./activate` | `deactivate` |
|
||||
| fish | `source ./activate.fish` | `deactivate` |
|
||||
| PowerShell | `. .\activate.ps1` | `deactivate` |
|
||||
|
||||
The leading dot and space in PowerShell are required. Executing
|
||||
`.\activate.ps1` without dot-sourcing does not provide the same session scope.
|
||||
The POSIX script uses Bash syntax. Use Bash for this recipe rather than `sh`,
|
||||
fish, or assuming that a Zsh startup file has Bash semantics.
|
||||
`activate` uses Bash syntax. Use Bash for this recipe rather than `sh`, or
|
||||
assuming that a Zsh startup file has Bash semantics; fish has its own
|
||||
`activate.fish`, which behaves the same.
|
||||
|
||||
Each activation invokes PM's install/sync path and trusts the recorded tool
|
||||
digest instead of re-hashing every entry. PM still installs a missing tool and
|
||||
|
||||
Reference in New Issue
Block a user