Skip to content

feat: idempotent PATH manipulation in shell env output #26

@michael-herwig

Description

@michael-herwig

Value

Users who source ocx shell env or ocx shell profile load multiple times per session (.bashrc/.zshrc, CI re-runs, nested shells) get a growing PATH with duplicate entries. This slows command lookup, makes echo $PATH unreadable, and causes ordering bugs. With this fix, generated shell output is idempotent — safe to eval repeatedly, readable when printed, and no shell pollution.

Context

Shell::export_path() in crates/ocx_lib/src/shell.rs:120 generates simple prepend commands like export PATH="/new:$PATH". No deduplication. Three surfaces affected:

  • Shell::export_path() — shell export statements for shell env, shell profile load
  • ProfileBuilder::add() — delegates to export_path()
  • Env::add_path() — runtime env for ocx exec

Industry standard: fish has fish_add_path --move, zsh has typeset -U path, mise/direnv/nix all emit idempotent snippets with helper functions. A tool that pollutes PATH on repeated sourcing is a bug.

Scope

  • Update Shell::export_path() to generate dedup+prepend for all 9 shells
  • Update Env::add_path() to deduplicate at runtime
  • Helper function pattern for shells that need it (defined once, unset after — no pollution)
  • Native syntax for shells that support clean one-liners
  • Batch: simple prepend only (documented limitation)
  • Out of scope: constant variable handling (already has conflict detection)

Design

Two strategies based on shell capabilities:

Strategy A — Native syntax (no helper needed): Zsh, Fish, and Elvish have built-in array/list operations that express dedup+prepend cleanly in a single line.

Strategy B — Helper function (defined once, unset after): Bash, POSIX shells, and PowerShell need multi-step logic. A helper function is defined once at the top of the output, called N times for each PATH-type var, then unset at the end. This follows the pattern used by mise (_mise_hook), direnv (_direnv_hook), and nix (_nix_import_env). Users who need structured copy-paste output already have ocx env (key-value table / JSON).

Shell Strategy Mechanism External deps
Zsh Native path=(/val "${(@)path:#/val}") None
Fish Native fish_add_path --prepend --move --path /val None (builtin, fish 3.2+)
Elvish Native set paths = [/val (each {|p| if (!=s $p /val) { put $p }} $paths)] None
Bash Helper __ocx_prepend() using read -ra array split + loop None
Dash/Ash/Ksh Helper __ocx_prepend() using awk -v RS=: awk (POSIX.1 required)
PowerShell Helper __ocx_prepend() using -split + Where-Object None
Batch Prepend only SET "PATH=/val;%PATH%" N/A

Example generated output for bash with two packages:

# ocx env
__ocx_prepend() { local IFS=: _r=; read -ra _p <<< "${!1}"; for _e in "${_p[@]}"; do [ "$_e" != "$2" ] && [ -n "$_e" ] && _r="${_r:+$_r:}$_e"; done; export "$1"="$2${_r:+:$_r}"; }
__ocx_prepend PATH "/home/user/.ocx/installs/.../cmake/bin"
export CMAKE_HOME="/home/user/.ocx/installs/.../cmake"
__ocx_prepend PATH "/home/user/.ocx/installs/.../node/bin"
unset -f __ocx_prepend

Example generated output for zsh (no helper):

# ocx env
path=(/home/user/.ocx/installs/.../cmake/bin "${(@)path:#/home/user/.ocx/installs/.../cmake/bin}")
export CMAKE_HOME="/home/user/.ocx/installs/.../cmake"
path=(/home/user/.ocx/installs/.../node/bin "${(@)path:#/home/user/.ocx/installs/.../node/bin}")
flowchart TD
    A["Package metadata: PATH type var"] --> B{"Output target?"}
    B -->|"shell env / profile load"| C["Shell::export_path()"]
    B -->|"exec / env"| D["Env::add_path()"]
    C --> E{"Shell capabilities?"}
    E -->|"Zsh / Fish / Elvish"| F["Native dedup syntax\nOne clean line per var"]
    E -->|"Bash / Dash / Ash / Ksh / PowerShell"| G["Helper function\nDefined once → called N times → unset"]
    E -->|"Batch"| H["Simple prepend\nDedup not feasible"]
    D --> I["Split on separator → filter exact matches → prepend → rejoin"]

    style F fill:#d4edda
    style G fill:#fff3cd
    style H fill:#f8d7da
Loading

Why helper functions over self-contained lines

  • Primary consumers are eval "$(ocx shell env ...)" — programmatic eval, not copy-paste
  • ocx env already serves the structured copy-paste use case (key-value / JSON)
  • A 150-char bash one-liner per PATH entry is unreadable when printed to terminal
  • mise, direnv, nix all use helper functions in eval output — established pattern
  • Helper is unset after use — zero shell pollution

Why awk is safe to depend on for POSIX shells

  • awk is POSIX.1 required — present on Alpine (ash), Debian minimal (dash), BusyBox
  • Docker scratch images lack awk but also lack a shell to source the output
  • The alternative (while IFS=: read loop) is fragile with edge cases around IFS restoration

Why no dedup for batch

  • CMD.exe has no array type, no string split, no filter primitive
  • FOR /F dedup requires multi-line syntax + SETLOCAL ENABLEDELAYEDEXPANSION
  • Not expressible as a function pattern (CMD has no function scope)
  • Batch scripts are rarely sourced repeatedly (no .bashrc equivalent)

Acceptance Criteria

  • Sourcing ocx shell env pkg twice produces no duplicate PATH entries (all shells except batch)
  • Sourcing ocx shell profile load twice produces no duplicate PATH entries
  • ocx exec runtime env has no duplicate PATH entries
  • Helper function is unset/removed after use — no shell namespace pollution
  • Helper defined at most once per output block, even with multiple packages
  • Partial path matches preserved (/usr/bin not affected when adding /usr/bin/extra)
  • Empty PATH edge case handled (no leading/trailing separators)
  • Zsh/Fish/Elvish: zero subprocesses, native syntax, no helper
  • Bash: zero subprocesses (builtins only)
  • POSIX (Dash/Ash/Ksh): max 1 awk invocation per PATH entry
  • PowerShell: zero subprocesses
  • Batch: simple prepend with documented limitation
  • Unit tests per shell for idempotency

Dependencies

None

References

  • fish fish_add_path builtin (fish 3.2+, 2021)
  • zsh typeset -U path / ${(@)path:#pattern} array filtering
  • POSIX awk -v RS=: dedup pattern (Linux Journal)
  • mise, direnv, nix helper function patterns in eval output

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions