diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5a11f35..040aeec 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -359,8 +359,8 @@ panes exactly. Each module defines three options (and is referenced as `@themux_module_` in a layout zone). Built-in modules: `session`, `application`, `directory`, -`host`, `user`, `date_time`, `time`, `cpu`, `ram`, `load`, `uptime`, `gitmux`, `kube`, -`battery`, `weather`, `clima`, `pomodoro_plus`, `zoom`. +`host`, `user`, `date_time`, `time`, `cpu`, `ram`, `load`, `uptime`, `git`, `gitmux`, +`kube`, `battery`, `weather`, `clima`, `pomodoro_plus`, `zoom`. | Option | Effect | | --- | --- | diff --git a/docs/reference/status-line.md b/docs/reference/status-line.md index 3b48bef..bb351b3 100644 --- a/docs/reference/status-line.md +++ b/docs/reference/status-line.md @@ -55,9 +55,15 @@ set -g @themux_[module_name]_text "text" ### Override the specific module's background color ```sh -set -g @themux_module_[module_name]_bg_color "#{@thm_surface_0}" +set -g @themux_[module_name]_text_bg "#{@thm_surface_0}" ``` +The raw per-channel overrides `@themux_[module_name]_{icon,text}_{fg,bg}` pin a +concrete colour over the variant; see the +[Configuration reference](./configuration.md) for the full cascade. A `_bg` +override is skipped on any channel (resting or active) whose resolved variant is +`naked`, so a `naked` slot keeps its transparent background. + ### Removing a specific module option ```sh @@ -187,6 +193,39 @@ run '~/.config/tmux/plugins/tpm/tpm' set -g @themux_status_line_1 "windows / load" ``` +## Git module + +Native git status with no external dependency: the branch plus a minimal +clean/dirty summary — `main ✓` on a clean work tree, `main ! 1C 2M 1S 1D 3?` on +a dirty one (conflicts, modified, staged, deleted and untracked counts; zero +groups are omitted, a short SHA replaces the branch when HEAD is detached). +Conflicted files (an unmerged XY pair, e.g. `UU`) are counted only in the +conflicts group, never in staged/modified/deleted. The module prints plain +text, so it is themed like every other pill and renders only inside a git +work tree. + +**Configure:** + +```sh +set -g @themux_status_line_1 "windows / git" +``` + +**Dirty escalation:** a dirty work tree (any change — tracked or untracked) +switches the accent from `@themux_git_color` to `@themux_git_active_color` +through the module active state, so the pill warms natively under any variant +and always agrees with the text. + +```sh +set -g @themux_git_color "#{E:@thm_teal}" # resting (clean) +set -g @themux_git_active_color "#{E:@thm_peach}" # dirty +set -g @themux_git_symbol_clean "✓" +set -g @themux_git_symbol_dirty "!" +``` + +Prefer `git` for a minimal status that follows the theme with nothing to +install; prefer `gitmux` below for richer information (remote state, stash, +divergence) rendered by an external binary with its own colour scheme. + ## Gitmux module **Requirements:** This module depends on [gitmux](https://github.com/arl/gitmux). @@ -201,6 +240,15 @@ Add the gitmux module to the status modules list. set -g @themux_status_line_1 "windows / gitmux" ``` +gitmux's stock colours are designed for a black bar, so the module's text block +defaults to the theme's darkest step (`@themux_gitmux_text_bg`, default +`@thm_crust`) to keep them readable on any theme surface. Override it to move +the block back onto the regular surface: + +```sh +set -g @themux_gitmux_text_bg "#{E:@thm_surface_0}" +``` + Follow the instructions in the [gitmux documentation](https://github.com/arl/gitmux/blob/main/README.md#customizing) to create a gitmux config file. The gitmux plugin expects a file to be present at `~/.gitmux.conf`. diff --git a/modules/git.conf b/modules/git.conf new file mode 100644 index 0000000..3598fcb --- /dev/null +++ b/modules/git.conf @@ -0,0 +1,33 @@ +# vim:set ft=tmux: +%hidden MODULE_NAME="git" + +# Native git status: branch plus a minimal clean/dirty summary, drawn by +# utils/git_status.sh. The script prints plain text only, so the block is +# styled like any other module and every prop (variant, shape, padding, +# leading_position) keeps working. No external binary required. +set -ogq "@themux_${MODULE_NAME}_icon" "󰊢" +set -ogq "@themux_${MODULE_NAME}_color" "#{E:@thm_teal}" +set -ogq "@themux_${MODULE_NAME}_symbol_clean" "✓" +set -ogq "@themux_${MODULE_NAME}_symbol_dirty" "!" + +# `set -F` would eat the #() in the text, so the script's absolute path is +# baked into an internal option at load time and referenced from the command. +run "tmux set -gq @_tmx_${MODULE_NAME}_script '#{d:current_file}/../utils/git_status.sh'" +set -gq "@themux_${MODULE_NAME}_text" ' #("#{E:@_tmx_git_script}" "#{pane_current_path}" "#{@themux_git_symbol_clean}" "#{@themux_git_symbol_dirty}")' + +# A dirty work tree warms the accent through the shared active machinery: the +# probe prints 1 or 0 (tmux reads the string "0" as false) and checks any +# change — tracked or untracked — so the accent always agrees with the text. +set -ogq "@themux_${MODULE_NAME}_active_color" "#{E:@thm_peach}" +set -gq "@_tmx_${MODULE_NAME}_dirty" '#("#{E:@_tmx_git_script}" --dirty "#{pane_current_path}")' +set -ogq "@themux_${MODULE_NAME}_active_when" "#{E:@_tmx_${MODULE_NAME}_dirty}" + +# Only render inside a git work tree (a cheap rev-parse, evaluated per refresh). +set -gq "@_tmx_${MODULE_NAME}_in_repo" '#(git -C "#{pane_current_path}" rev-parse --is-inside-work-tree 2>/dev/null)' +set -gq "@themux_${MODULE_NAME}_when" "#{E:@_tmx_${MODULE_NAME}_in_repo}" + +# module_block.conf matches modules by substring over the joined status lines, +# so the token "git" also matches inside "gitmux": a status line configured +# with only gitmux still renders this module, unused. Harmless — it only costs +# one extra render at load. +source -F "#{d:current_file}/../utils/module_block.conf" diff --git a/modules/gitmux.conf b/modules/gitmux.conf index 816cad1..0bcf7fd 100644 --- a/modules/gitmux.conf +++ b/modules/gitmux.conf @@ -12,6 +12,10 @@ set -gq "@themux_${MODULE_NAME}_text" ' #(gitmux "#{pane_current_path}")' # land on the module's background instead of the bare bar. set -ogq "@themux_${MODULE_NAME}_self_styled" "yes" +# gitmux's stock palette targets a black bar; pin the text block to the +# theme's darkest step so those colours read as intended on any surface. +set -ogq "@themux_${MODULE_NAME}_text_bg" "#{E:@thm_crust}" + # Only render inside a git work tree (a cheap rev-parse, evaluated per refresh). # Driving the gate through @themux__when lets the module renderer hide the # whole segment when outside a repo and, in a powerline run, lets neighbours diff --git a/run_tests.sh b/run_tests.sh index df8383e..c73cf3c 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -14,6 +14,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/application_module.sh --expected "${script_dir}"/tests/application_module_expected.txt "$@" "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/battery_module.sh --expected "${script_dir}"/tests/battery_module_expected.txt "$@" "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/cpu_module.sh --expected "${script_dir}"/tests/cpu_module_expected.txt "$@" +"${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/git_module.sh --expected "${script_dir}"/tests/git_module_expected.txt "$@" "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/load_module.sh --expected "${script_dir}"/tests/load_module_expected.txt "$@" "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/pane_styling.sh --expected "${script_dir}"/tests/pane_styling_expected.txt "$@" "${script_dir}"/tests/harness.sh --test "${script_dir}"/tests/panes_variant.sh --expected "${script_dir}"/tests/panes_variant_expected.txt "$@" diff --git a/tests/git_module.sh b/tests/git_module.sh new file mode 100644 index 0000000..e5ae568 --- /dev/null +++ b/tests/git_module.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) +# shellcheck disable=SC1091 +source "${script_dir}/helpers.sh" + +# A module renders only when referenced in a status line (lazy render). +tmux set -g @themux_status_line_1 "git" +tmux source "${script_dir}/../themux_options.conf" +tmux source "${script_dir}/../themux.conf" + +# Wiring: the when gate, the dirty escalation and the accent options all point +# at the expected internal probes and palette roles. +print_option @themux_git_when +print_option @themux_git_active_when +print_option @themux_git_active_color +print_option @themux_git_color + +# The internal probes and the script invocation. grep -o keeps the output +# deterministic (the baked script path is absolute) and `|| true` guards the +# harness ERR-trap, which kills the tmux server on a bare zero-match grep. The +# path is quoted ("#{E:...}") so an install path containing spaces still works. +printf '\nin_repo_probe ' +tmux show -gqv @_tmx_git_in_repo | { grep -oF 'rev-parse --is-inside-work-tree' || true; } +printf 'dirty_probe ' +tmux show -gqv @_tmx_git_dirty | { grep -oF '#("#{E:@_tmx_git_script}" --dirty' || true; } +printf 'text_invocation ' +tmux show -gqv @themux_git_text | { grep -oF '#("#{E:@_tmx_git_script}"' || true; } +printf 'script_path ' +tmux show -gqv @_tmx_git_script | { grep -oF 'utils/git_status.sh' || true; } + +# The baked core routes the active state through the dirty probe (an expanded +# draw would need a live repo path, so assert on the raw core). +printf 'core_active_switch ' +tmux show -gqv @_tmx_module_git_core | { grep -oF '#{?#{E:@_tmx_git_dirty},' || true; } | sort -u + +# gitmux keeps its stock colours but sits on the theme's darkest step. +print_option @themux_gitmux_text_bg + +# The script itself, against a throwaway repo: clean, staged, modified plus +# untracked, the --dirty probe backing active_when, symbol overrides, an +# untracked-only work tree, a plain directory outside any repo, and a +# fabricated conflicted index. +git_status="${script_dir}/../utils/git_status.sh" +repo=$(mktemp -d) +outside=$(mktemp -d) +conflict_repo=$(mktemp -d) +trap 'rm -rf "$repo" "$outside" "$conflict_repo"' EXIT + +git -C "$repo" init -q -b main +git -C "$repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q --allow-empty -m init + +printf '\nscript_clean [%s]' "$("$git_status" "$repo")" +printf '\nscript_probe_clean [%s]' "$("$git_status" --dirty "$repo")" + +echo staged >"$repo/staged_file" +git -C "$repo" add staged_file +printf '\nscript_staged [%s]' "$("$git_status" "$repo")" + +git -C "$repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -m add +echo change >>"$repo/staged_file" +echo untracked >"$repo/untracked_file" +printf '\nscript_modified_untracked [%s]' "$("$git_status" "$repo")" +printf '\nscript_probe_dirty [%s]' "$("$git_status" --dirty "$repo")" +printf '\nscript_symbols [%s]' "$("$git_status" "$repo" OK WARN)" + +# Untracked-only: --dirty must agree with the text ("1"), matching the same +# porcelain semantics the text mode uses instead of the old "text says dirty, +# colour says clean" split. +git -C "$repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -am settle +printf '\nscript_untracked_only [%s]' "$("$git_status" "$repo")" +printf '\nscript_probe_untracked_only [%s]' "$("$git_status" --dirty "$repo")" + +printf '\nscript_outside_repo [%s]' "$("$git_status" "$outside")" + +# Conflicts: fabricated deterministically via index stages, not a real merge +# — a real `git merge` needs a committer identity and depends on the git +# version's merge/diff machinery, so it is version- and environment-sensitive +# (observed on CI: no git identity configured makes an un-`-c`'d merge abort +# before it ever runs, silently swallowed by `|| true`, leaving a clean repo). +# The index-stage combination is what git itself uses to derive the XY code: +# stage 1 (base) + 2 (ours) + 3 (theirs) = UU; 2 + 3 with no 1 = AA; 1 alone +# (no 2, no 3) = DD. Each fabrication is verified against a fresh +# `git status --porcelain` right away — a stanza that fails to register turns +# into an immediate, clearly labelled failure instead of a silently wrong +# count later. +require_conflict() { # $1 expected "XY path" line, $2 porcelain status text + grep -qF "$1" <<<"$2" && return 0 + printf 'FIXTURE ERROR: expected "%s" in git status --porcelain, got:\n%s\n' \ + "$1" "$2" >&2 + exit 1 +} + +git -C "$conflict_repo" init -q -b main +printf 'shared base\n' >"$conflict_repo/shared.txt" +printf 'victim\n' >"$conflict_repo/victim.txt" +git -C "$conflict_repo" add shared.txt victim.txt +git -C "$conflict_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -m base +shared_base=$(git -C "$conflict_repo" rev-parse HEAD:shared.txt) +victim_base=$(git -C "$conflict_repo" rev-parse HEAD:victim.txt) +shared_ours=$(printf 'shared ours\n' | git -C "$conflict_repo" hash-object -w --stdin) +shared_theirs=$(printf 'shared theirs\n' | git -C "$conflict_repo" hash-object -w --stdin) +added_ours=$(printf 'added ours\n' | git -C "$conflict_repo" hash-object -w --stdin) +added_theirs=$(printf 'added theirs\n' | git -C "$conflict_repo" hash-object -w --stdin) + +# UU: drop the plain (stage 0) entry, then fabricate all three stages. +git -C "$conflict_repo" rm -q --cached shared.txt +{ + printf '100644 %s 1\tshared.txt\n' "$shared_base" + printf '100644 %s 2\tshared.txt\n' "$shared_ours" + printf '100644 %s 3\tshared.txt\n' "$shared_theirs" +} | git -C "$conflict_repo" update-index --index-info +printf 'shared ours\n' >"$conflict_repo/shared.txt" + +status=$(git -C "$conflict_repo" --no-optional-locks status --porcelain) +require_conflict 'UU shared.txt' "$status" +printf '\nscript_conflict [%s]' "$("$git_status" "$conflict_repo")" + +# AA: added.txt gets stage 2 + 3 only (no stage 1 — no common ancestor). +{ + printf '100644 %s 2\tadded.txt\n' "$added_ours" + printf '100644 %s 3\tadded.txt\n' "$added_theirs" +} | git -C "$conflict_repo" update-index --index-info +printf 'added theirs\n' >"$conflict_repo/added.txt" + +# DD: victim.txt drops the stage 0 entry and keeps only stage 1 (both sides +# deleted it, so no worktree file either). +git -C "$conflict_repo" rm -q --cached victim.txt +rm -f "$conflict_repo/victim.txt" +printf '100644 %s 1\tvictim.txt\n' "$victim_base" | git -C "$conflict_repo" update-index --index-info + +status=$(git -C "$conflict_repo" --no-optional-locks status --porcelain) +require_conflict 'AA added.txt' "$status" +require_conflict 'DD victim.txt' "$status" +require_conflict 'UU shared.txt' "$status" +printf '\nscript_conflict_extended [%s]\n' "$("$git_status" "$conflict_repo")" diff --git a/tests/git_module_expected.txt b/tests/git_module_expected.txt new file mode 100644 index 0000000..92562b0 --- /dev/null +++ b/tests/git_module_expected.txt @@ -0,0 +1,28 @@ + +@themux_git_when #{E:@_tmx_git_in_repo} + +@themux_git_active_when #{E:@_tmx_git_dirty} + +@themux_git_active_color #{E:@thm_peach} + +@themux_git_color #{E:@thm_teal} + +in_repo_probe rev-parse --is-inside-work-tree +dirty_probe #("#{E:@_tmx_git_script}" --dirty +text_invocation #("#{E:@_tmx_git_script}" +script_path utils/git_status.sh +core_active_switch #{?#{E:@_tmx_git_dirty}, + +@themux_gitmux_text_bg #{E:@thm_crust} + +script_clean [main ✓] +script_probe_clean [0] +script_staged [main ! 1S] +script_modified_untracked [main ! 1M 1?] +script_probe_dirty [1] +script_symbols [main WARN 1M 1?] +script_untracked_only [main ! 1?] +script_probe_untracked_only [1] +script_outside_repo [] +script_conflict [main ! 1C] +script_conflict_extended [main ! 3C] diff --git a/themux.conf b/themux.conf index e1a3c25..de15adc 100644 --- a/themux.conf +++ b/themux.conf @@ -27,6 +27,7 @@ source -F "#{d:current_file}/modules/clima.conf" source -F "#{d:current_file}/modules/cpu.conf" source -F "#{d:current_file}/modules/date_time.conf" source -F "#{d:current_file}/modules/directory.conf" +source -F "#{d:current_file}/modules/git.conf" source -F "#{d:current_file}/modules/gitmux.conf" source -F "#{d:current_file}/modules/host.conf" source -F "#{d:current_file}/modules/kube.conf" diff --git a/utils/git_status.sh b/utils/git_status.sh new file mode 100755 index 0000000..43dbb1d --- /dev/null +++ b/utils/git_status.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Minimal git status text for the git module: " " on a clean +# work tree, " C M S D ?" on a dirty one (zero +# groups are omitted). Plain text only — no #[...] codes — so the renderer +# styles the block like any other module and every prop keeps working. +# +# Usage: git_status.sh [] [] +# git_status.sh --dirty +# +# --dirty backs @themux_git_active_when: it prints 1 when the work tree has +# any change — tracked or untracked — and 0 otherwise (tmux's #{?...} reads +# the string "0" as false). It runs the same porcelain (no -uno) as the text +# mode, so the accent and the text never disagree. Outside a repo the text +# mode prints nothing; both modes always exit 0. + +set -u + +if [ "${1-}" = "--dirty" ]; then + changes=$(git -C "${2:-.}" --no-optional-locks status --porcelain 2>/dev/null | head -1) + if [ -n "$changes" ]; then printf '1'; else printf '0'; fi + exit 0 +fi + +path=${1:-.} +clean_sym=${2:-✓} +dirty_sym=${3:-!} + +# Branch name, or a short SHA when HEAD is detached; bail quietly outside a repo. +branch=$(git -C "$path" symbolic-ref --short -q HEAD 2>/dev/null) || + branch=$(git -C "$path" rev-parse --short HEAD 2>/dev/null) || exit 0 + +status=$(git -C "$path" --no-optional-locks status --porcelain -b 2>/dev/null) || exit 0 + +# One porcelain pass, counted by the two-character XY code. An unmerged pair +# (UU, AU, UA, DU, UD, DD, AA) is checked first and counted into its own +# conflicts group so it never leaks into staged/modified/deleted. Otherwise X +# (index) feeds the staged group, Y (work tree) the modified/deleted groups, +# "??" the untracked group. A file can appear in two groups (e.g. staged with +# unstaged edits). +dirty=0 conflicts=0 staged=0 modified=0 deleted=0 untracked=0 +while IFS= read -r line; do + case $line in + '' | '##'*) continue ;; + '??'*) + untracked=$((untracked + 1)) + dirty=1 + continue + ;; + esac + dirty=1 + x=${line:0:1} y=${line:1:1} + case $x$y in + UU | AU | UA | DU | UD | DD | AA) + conflicts=$((conflicts + 1)) + continue + ;; + esac + case $x in [MTADRC]) staged=$((staged + 1)) ;; esac + case $y in + M | T) modified=$((modified + 1)) ;; + D) deleted=$((deleted + 1)) ;; + esac +done <<<"$status" + +if [ "$dirty" -eq 0 ]; then + printf '%s %s' "$branch" "$clean_sym" + exit 0 +fi + +out="$branch $dirty_sym" +[ "$conflicts" -gt 0 ] && out="$out ${conflicts}C" +[ "$modified" -gt 0 ] && out="$out ${modified}M" +[ "$staged" -gt 0 ] && out="$out ${staged}S" +[ "$deleted" -gt 0 ] && out="$out ${deleted}D" +[ "$untracked" -gt 0 ] && out="$out ${untracked}?" +printf '%s' "$out" diff --git a/utils/module_render.sh b/utils/module_render.sh index fe421a5..93bbfe1 100755 --- a/utils/module_render.sh +++ b/utils/module_render.sh @@ -150,13 +150,17 @@ resolve_style "$lead_av" "$lead_aacc" "$surface" "$crust" "$fg"; aibg=$RS_BG aif # fg/bg (#{_fg_color}/#{_bg_color}), so the block escalates colour at # draw time — something a variant cannot do, since the segment is baked once at # layout time. The two channels take different live refs, so they always contrast. -[ -n "$icon_bg_ov" ] && { ribg="$icon_bg_ov"; aibg="$icon_bg_ov"; } +# A bg override is skipped on a channel whose resolved variant is naked, so naked's +# promised transparency (RS_BG=default) is never overwritten by an opaque pin. +[ -n "$icon_bg_ov" ] && [ "$leading" != naked ] && ribg="$icon_bg_ov" +[ -n "$icon_bg_ov" ] && [ "$lead_av" != naked ] && aibg="$icon_bg_ov" [ -n "$icon_fg_ov" ] && { rifg="$icon_fg_ov"; aifg="$icon_fg_ov"; } ibg=$(chan "$aibg" "$ribg") ifg=$(chan "$aifg" "$rifg") resolve_style "$text_style" "$text_acc" "$surface" "$crust" "$fg"; rtbg=$RS_BG rtfg=$RS_FG resolve_style "$text_av" "$text_aacc" "$surface" "$crust" "$fg"; atbg=$RS_BG atfg=$RS_FG -[ -n "$text_bg_ov" ] && { rtbg="$text_bg_ov"; atbg="$text_bg_ov"; } +[ -n "$text_bg_ov" ] && [ "$text_style" != naked ] && rtbg="$text_bg_ov" +[ -n "$text_bg_ov" ] && [ "$text_av" != naked ] && atbg="$text_bg_ov" [ -n "$text_fg_ov" ] && { rtfg="$text_fg_ov"; atfg="$text_fg_ov"; } tbg=$(chan "$atbg" "$rtbg") tfg=$(chan "$atfg" "$rtfg")