Skip to content

feat(bash)!: hand off interactive login shells to zsh when available #87

Description

@ahjota

Problem

Some tools read the login-shell entry from the passwd database directly (sshd for git/rsync/sftp, cron, su) rather than consulting $SHELL, so that entry must always name a shell that is guaranteed to exist on the host.

  • bash is the only shell present on every supported host: macOS ships /bin/bash 3.2, Fedora/Bazzite ship bash 5, containers and rescue environments have bash.
  • zsh is not guaranteed: preinstalled on macOS, optional on Fedora.
  • Changing the passwd entry on Fedora normally means chsh, which lives in the util-linux-user package. Installing it is rejected: it adds a hard requirement for a one-time operation. (sudo usermod -s needs no new packages but still requires root, which is not always available.)
  • $SHELL must never be set from .zshenv/.zshrc/.bashrc; it is normally set by login(1) from the passwd entry. The handoff below is the single sanctioned exception.

Decision

  1. The passwd login-shell entry stays bash on every host: the guaranteed-valid baseline for any tool that reads it directly.
  2. Interactive login bash sessions hand off to zsh, when zsh is available, via exec at the start of dot_bash_profile.tmpl.
  3. zsh is classified as an optional/recommended dependency, not required (feeds into chore: mark req/rec/opt dependencies #80).

Proposed fix

Restructure dot_bash_profile.tmpl so the handoff runs first; the existing .bashrc source moves below it and only executes when the session stays in bash:

# Hand off to zsh for interactive sessions when available.
# The passwd entry stays bash (guaranteed present on all hosts);
# $SHELL is updated to reflect the actual session shell.
if [[ $- == *i* ]] && command -v zsh >/dev/null 2>&1; then
    export SHELL="$(command -v zsh)"
    exec zsh -l
fi

# Staying in bash (no zsh installed, or non-interactive): load interactive config.
if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

Design notes:

  • command -v zsh, not a hardcoded path: zsh lives at /bin/zsh (macOS), /usr/bin/zsh (Fedora), or /opt/homebrew/bin/zsh (Homebrew). Runtime lookup keeps one template valid on all hosts with no per-host chezmoi flag.
  • [[ $- == *i* ]] restricts the handoff to interactive shells. Non-interactive login shells (bash -l -c ..., some ssh-based tooling) must stay in bash.
  • The snippet is POSIX-plus-[[ ]]: valid on bash 3.2 and bash 5.x alike. No associative arrays (see fix(bash): 10-droid.sh fails on bash 3.2 (associative arrays unsupported) #83).
  • dot_bashrc.tmpl must NOT hand off: running bash deliberately from zsh, or tools spawning bash -i, should yield bash.
  • dot_zshrc.tmpl is untouched.
  • Handoff-first ordering: exec replaces the process, so only exported environment variables survive it. Everything .bashrc sets up (aliases, starship init, thefuck, bash-completion) is non-exported shell state that is discarded at exec; sourcing it before the handoff would pay full bash interactive startup on every login just to throw it away.
  • PATH prerequisite: the $HOME/.local/bin:$HOME/bin bootstrap currently lives in dot_bashrc.tmpl, and dot_zshrc.tmpl never adds $HOME/bin (it adds $HOME/.local/bin only conditionally, when Factory is installed). With handoff-first ordering .bashrc never runs on the zsh path, so move that PATH bootstrap into dot_shellrc.d/10-env.sh (sourced by both shells) to keep zsh sessions self-sufficient.
  • Escape hatch for deliberate bash: bash --noprofile -i skips .bash_profile entirely, yielding a real interactive bash with no handoff. This is the sanctioned way to debug the login stack, verify installer profile edits, or test bash 3.2 behavior on macOS — the cases where bash -l would otherwise hand you zsh against your intent.

Platform notes

  • macOS: /bin/bash is already in /etc/shells, so chsh -s /bin/bash is permitted where a passwd change is desired. Add export BASH_SILENCE_DEPRECATION_WARNING=1 to dot_bash_profile.tmpl to suppress Apple's "default interactive shell is now zsh" login nag.
  • Fedora/Bazzite: no new packages required. /etc is mutable on Atomic, so sudo usermod -s /bin/bash "$USER" remains an option where root exists, but this spec works with zero privileges.

Consequences

  • getent passwd "$USER" reports bash: always true, always safe for non-interactive tooling.
  • $SHELL reports zsh when zsh exists, bash otherwise: always honest about the running session.
  • Nothing in the stack ever points at a shell that is not installed.
  • Known edge case: bash -l typed inside a zsh session hands back off to zsh. Acceptable; consistent with "login means zsh when available". Use bash --noprofile -i when a real interactive bash is intended.

Acceptance

  • Login shell with zsh installed results in a zsh session with $SHELL set to the resolved zsh path
  • Login shell without zsh installed results in a bash session with no errors printed
  • bash -l -c 'echo hi' stays in bash (no handoff)
  • Handoff path skips ~/.bashrc (verify with bash -xli; no .bashrc lines in the trace)
  • bash --noprofile -i yields an interactive bash session (no handoff)
  • PATH bootstrap moved from dot_bashrc.tmpl to dot_shellrc.d/10-env.sh; post-handoff zsh sessions include $HOME/bin and $HOME/.local/bin in PATH
  • Verified on bash 3.2 (macOS /bin/bash) and bash 5.x (Fedora)
  • bash -n passes on the rendered template
  • README dependency list marks zsh as optional/recommended (with chore: mark req/rec/opt dependencies #80)
  • Commit follows conventional commit spec

Out of scope


Related: #80, #81. This issue is the canonical home of this spec.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions