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:
ethernet
2026-10-02 15:28:31 -04:00
parent cb326e32c7
commit d6bb42e8ba
11 changed files with 291 additions and 31 deletions

View File

@@ -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.

View File

@@ -136,6 +136,13 @@ source ./activate
hermes --version
```
fish:
```fish
source ./activate.fish
hermes --version
```
PowerShell:
```powershell

View File

@@ -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
View 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

View File

@@ -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__":

View File

@@ -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"
}

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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