Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun
1. `~/.zshrc` sources `load.zsh` (added by `install.sh`)
2. `load.zsh` orchestrates everything:
- Locale, zsh options, `path-ethic` plugin (synchronous — needed at startup)
- Sources all `include/` files: exports, mise, aliases, functions, keybindings, completions, history
- Sources all `include/` files: exports, mise, aliases, functions, keybindings, completions, history, prompt
- **`include/mise` runs synchronously right after `include/exports`** — runtime `PATH` must be correct at the first prompt, so never defer it
- **fzf shell integration loads at the end of `include/keybindings`**, before `fzf-tab` — `fzf-tab` must be the last plugin to bind `^I`
- **`include/prompt` runs synchronously after `include/history`** so the first prompt is Starship's; it is skipped when `starship` is not on `PATH` or `TERM` is `dumb`. Starship's `PROMPT` is a `$(…)` expansion, so `setopt prompt_subst` in `load.zsh` is required
- **Deferred plugins** (via `zsh-defer`, interactive sessions only): `zsh-history-substring-search`, `zsh-autosuggestions`, `fzf-tab`, `zsh-syntax-highlighting` — these load after the prompt appears
- **Non-interactive fallback**: deferred plugins load synchronously when `[[ ! -o interactive ]]` (e.g., tests)
- **`zsh-syntax-highlighting` MUST be loaded LAST** (after all other plugins and keybindings, even when deferred)
Expand All @@ -31,7 +32,7 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun
- **`dotfiles/`** — Symlinked to `$HOME` during install (not copied — changes are live). The repo has no `.gitignore` of its own — ignore rules go in `dotfiles/.gitignore_global`
- **`agents/`** — Global Agent instructions (`AGENTS.md`). `install.sh` symlinks agent configuration files (e.g. `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`) to this file. If a target already exists, the script prompts `[n/Y]` before replacing it (default Yes, also at end of input); an existing correct link is left untouched
- **`brew/Brewfile`** — The only Brewfile: every Homebrew package of the three machine profiles (`essentials`, `dev`, `workstation`), one line each, each inside its `if profiles.include?("<name>")` block. The profiles come from `HOMEBREW_PROFILE_INSTALL_PROFILES` (unset or empty means every profile, so that a plain `brew bundle cleanup` never treats a profile's packages as unlisted; `profile install` always sets it; the profiles form one chain in the order of `known`, so applying `dev` also applies `essentials`, and applying `workstation` also applies `dev` and `essentials`); the `HOMEBREW_` prefix is required because the `brew` wrapper passes no other variables to the Brewfile. `include/exports` exports `HOMEBREW_BUNDLE_FILE` with this file's path, so a plain `brew bundle` reads it from any directory; `scripts/profile` still passes `--file` on every `brew bundle` call, so it does not depend on the shell environment. Keep the file to Ruby logic that Homebrew documents for Brewfiles (no processes, no loading of other files). No language runtimes here — mise owns them
- **`config/`** — Config files with nested targets (`install.sh` links dotfiles by basename only, so they cannot live in `dotfiles/`). `config/mise/profile.toml` holds the runtime versions and mise settings; `install.sh` links it to `~/.config/mise/conf.d/profile.toml` (not `config.toml`, which `mise use -g` writes to). `config/nvim/` is the nvim config (`init.lua` and the committed `lazy-lock.json`); `install.sh` links the directory as a whole to `~/.config/nvim`, so lazy.nvim writes the lockfile into the repo, and moves an old-layout `~/.config/nvim` (a directory whose `init.lua` links into the repo) to `~/.config/nvim.bak.<epoch seconds>` first
- **`config/`** — Config files with nested targets (`install.sh` links dotfiles by basename only, so they cannot live in `dotfiles/`). `config/mise/profile.toml` holds the runtime versions and mise settings; `install.sh` links it to `~/.config/mise/conf.d/profile.toml` (not `config.toml`, which `mise use -g` writes to). `config/starship.toml` is the Starship prompt config; `include/exports` points `STARSHIP_CONFIG` at it, so it needs no link. `config/nvim/` is the nvim config (`init.lua` and the committed `lazy-lock.json`); `install.sh` links the directory as a whole to `~/.config/nvim`, so lazy.nvim writes the lockfile into the repo, and moves an old-layout `~/.config/nvim` (a directory whose `init.lua` links into the repo) to `~/.config/nvim.bak.<epoch seconds>` first
- **`bootstrap.sh`** — One-command new-Mac setup (`curl … | zsh -s -- [essentials|dev|workstation]`): Homebrew, clone, `install.sh`, then `scripts/profile install`. At the repo root, not in `scripts/`, so it is not a command on `PATH`. Add no install logic here; it belongs in `scripts/profile` or `install.sh`
- **`zsh-plugins/`** — Git submodules
- **`tests/`** — Test suite using `zsh-scriptest` submodule
Expand Down Expand Up @@ -71,7 +72,7 @@ Tests use the `zsh-scriptest` framework (submodule in `tests/`). Test files must
- Test functions are called sequentially at the bottom of the file (not auto-discovered).
- Test files must be executable — the runner silently skips a non-executable file.
- Call `cleanup` before `finish_tests`: `finish_tests` exits on failure, and a skipped cleanup leaves the fingerprint behind for the next file.
- Run child shells with `env -i HOME="$HOME" PATH=… TERM=dumb` so variables exported by the developer's own shell cannot mask what `load.zsh` exports.
- Run child shells with `env -i HOME="$HOME" PATH=… TERM=dumb` so variables exported by the developer's own shell cannot mask what `load.zsh` exports. Only a test of `include/prompt` sets another `TERM`, because the prompt is skipped on a dumb terminal.
- See `tests/sanity.test.sh` for the canonical pattern.

CI runs on **ubuntu-latest** and **macos-latest** (GitHub Actions, `.github/workflows/ci.yml`).
Expand Down
69 changes: 69 additions & 0 deletions config/starship.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"$schema" = 'https://starship.rs/config-schema.json'

# Each git module after the branch starts with its own space, so the group ends with one space only in a repo.
format = """
$username$hostname$directory\
($git_branch$git_commit$git_state${custom.git_files}$git_metrics$git_status )\
$all"""

palette = 'git'

[palettes.git]
branch = '#b8c084'
muted = '#8b98a0'
added = '#8fa83c'
deleted = '#d9473f'

[aws]
disabled = true

[gcloud]
disabled = true

[git_branch]
symbol = ' '
style = 'fg:branch'
format = '[$symbol$branch(:$remote_branch)]($style)'

[git_commit]
style = 'fg:branch'
format = '[ \($hash$tag\)]($style)'

[git_state]
style = 'fg:yellow'
format = '[ \($state( $progress_current/$progress_total)\)]($style)'

# Starship has no variable for the total number of changed files.
# --ignore-submodules=dirty skips the scan of every submodule's working tree, which
# can push the module past command_timeout; a moved submodule commit still counts.
[custom.git_files]
description = 'Number of changed files, staged, unstaged or untracked'
when = true
require_repo = true
shell = ['sh']
command = 'n=$(git status --porcelain --ignore-submodules=dirty | wc -l | tr -d " "); [ "$n" -eq 0 ] || echo "$n"'
style = 'fg:muted'
format = '([ ± $output]($style))'

# Untracked files count in ± N but add no lines here: git_metrics diffs tracked files against HEAD.
[git_metrics]
disabled = false
ignore_submodules = true
format = '( [•](fg:muted)( [+$added](fg:added))( [-$deleted](fg:deleted)))'

# File counts come from custom.git_files; the stash, conflict and remote state left here needs no submodule scan.
[git_status]
ignore_submodules = true
format = '($all_status$ahead_behind)'
conflicted = '[ =${count}](bold fg:red)'
stashed = '[ ≡${count}](fg:purple)'
deleted = ''
renamed = ''
modified = ''
staged = ''
typechanged = ''
untracked = ''
ahead = '[ ⇡${count}](fg:added)'
behind = '[ ⇣${count}](fg:deleted)'
diverged = '[ ⇡${ahead_count}⇣${behind_count}](bold fg:yellow)'
up_to_date = ''
3 changes: 3 additions & 0 deletions include/exports
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ PATH="$PATH:$HOME/go/bin"
# Homebrew: a plain `brew bundle` reads the repo Brewfile from any directory.
export HOMEBREW_BUNDLE_FILE="$SHA1N_PROFILE_HOME/brew/Brewfile"

# Starship reads the repo config directly, so no ~/.config/starship.toml link is needed.
export STARSHIP_CONFIG="$SHA1N_PROFILE_HOME/config/starship.toml"

# Backup
export BACKUP_DST_PATH="/Volumes/Backup/latest"
export BACKUP_SRC_PATH="/Volumes/Content"
Expand Down
9 changes: 9 additions & 0 deletions include/prompt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env zsh

# Starship errors on a dumb terminal at init and at every prompt, which no stderr redirect here can cover.
if (( $+commands[starship] )) && [[ $TERM != dumb ]]; then
() {
local starship_init
starship_init="$(starship init zsh 2>/dev/null)" && eval "$starship_init"
}
fi
1 change: 1 addition & 0 deletions load.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ source $SHA1N_PROFILE_HOME/include/functions
source $SHA1N_PROFILE_HOME/include/keybindings
source $SHA1N_PROFILE_HOME/include/completions
source $SHA1N_PROFILE_HOME/include/history
source $SHA1N_PROFILE_HOME/include/prompt

# fzf-tab: after compinit (include/completions), before zsh-syntax-highlighting
if [[ -o interactive ]]; then
Expand Down
80 changes: 74 additions & 6 deletions tests/env.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,33 @@ load_and_eval() {
}

eval_load_eval() {
local search_path="$1" before="$2" after="$3"
env -i HOME="$HOME" PATH="$search_path" TERM=dumb \
local search_path="$1" before="$2" after="$3" term="${4:-dumb}"
env -i HOME="$HOME" PATH="$search_path" TERM="$term" \
"$zsh_bin" -f -c 'eval "$2"; source "$1"; eval "$3"' _ "$load_script" "$before" "$after"
}

load_stderr() {
local search_path="$1"
env -i HOME="$HOME" PATH="$search_path" TERM=dumb \
local search_path="$1" term="${2:-dumb}"
env -i HOME="$HOME" PATH="$search_path" TERM="$term" \
"$zsh_bin" -f -c 'source "$1" 2>&1 >/dev/null' _ "$load_script"
}

new_stub_dir() {
mktemp -d "${TMPDIR:-/tmp}/env_test_stubs.XXXXXX"
}

write_starship_stub() {
local stub_dir="$1" init_status="$2"
cat >"$stub_dir/starship" <<EOF
#!/bin/sh
if [ "\$1" = "init" ] && [ "\$2" = "zsh" ]; then
[ $init_status -eq 0 ] || exit $init_status
echo "PROMPT='starship-stub> '"
fi
EOF
chmod +x "$stub_dir/starship"
}

function test_pip_require_virtualenv() {
test_case_title

Expand All @@ -56,6 +68,17 @@ function test_homebrew_bundle_file() {
assert_equal "$profiles_type" ""
}

function test_starship_config() {
test_case_title

local exported="$(load_and_eval "$system_path" 'print -r -- "${(t)STARSHIP_CONFIG}"')"
local value="$(load_and_eval "$system_path" 'print -r -- "$STARSHIP_CONFIG"')"

assert_equal "$value" "$profile_home/config/starship.toml"
assert_contains "$exported" "export"
assert_file_exists "$value"
}

function test_go_bin_on_path() {
test_case_title

Expand Down Expand Up @@ -115,7 +138,7 @@ EOF
assert_empty "$err"
}

function test_prompt_not_set() {
function test_prompt_not_set_without_starship() {
test_case_title

local before="PROMPT='sentinel> '; RPROMPT='right-sentinel'"
Expand All @@ -129,6 +152,47 @@ function test_prompt_not_set() {
assert_equal "$rprompt" "right-sentinel"
}

function test_starship_init_sets_prompt() {
test_case_title

local stub_dir="$(new_stub_dir)"
write_starship_stub "$stub_dir" 0

local prompt="$(eval_load_eval "$stub_dir:$system_path" "PROMPT='sentinel> '" 'print -r -- "$PROMPT"' xterm-256color)"

rm -rf "$stub_dir"

assert_equal "$prompt" "starship-stub> "
}

function test_starship_skipped_on_dumb_terminal() {
test_case_title

local stub_dir="$(new_stub_dir)"
write_starship_stub "$stub_dir" 0

local prompt="$(eval_load_eval "$stub_dir:$system_path" "PROMPT='sentinel> '" 'print -r -- "$PROMPT"' dumb)"

rm -rf "$stub_dir"

assert_equal "$prompt" "sentinel> "
}

function test_starship_init_failure_is_silent() {
test_case_title

local stub_dir="$(new_stub_dir)"
write_starship_stub "$stub_dir" 1

local prompt="$(eval_load_eval "$stub_dir:$system_path" "PROMPT='sentinel> '" 'print -r -- "$PROMPT"' xterm-256color)"
local err="$(load_stderr "$stub_dir:$system_path" xterm-256color)"

rm -rf "$stub_dir"

assert_equal "$prompt" "sentinel> "
assert_empty "$err"
}

function test_no_theme_functions() {
test_case_title

Expand All @@ -140,11 +204,15 @@ function test_no_theme_functions() {
setup
run_test test_pip_require_virtualenv
run_test test_homebrew_bundle_file
run_test test_starship_config
run_test test_go_bin_on_path
run_test test_mise_activated
run_test test_mise_absent_is_silent
run_test test_fzf_without_zsh_flag_is_silent
run_test test_prompt_not_set
run_test test_prompt_not_set_without_starship
run_test test_starship_init_sets_prompt
run_test test_starship_skipped_on_dumb_terminal
run_test test_starship_init_failure_is_silent
run_test test_no_theme_functions
cleanup
finish_tests
Loading