From 837118fa8bcf8206e6306a795fdd86fc4d0059de Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:04:28 -0400 Subject: [PATCH 1/8] feat(modules): native git module with themed dirty escalation Branch plus a minimal clean/dirty summary printed as plain text by utils/git_status.sh, so the block is styled natively by variant/accent and a dirty work tree warms the accent through the shared active machinery (@themux_git_active_when backed by a cheap tracked-only probe). Renders only inside a git work tree, like gitmux. --- modules/git.conf | 33 +++++++++++++++++++++++ themux.conf | 1 + utils/git_status.sh | 66 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 modules/git.conf create mode 100755 utils/git_status.sh diff --git a/modules/git.conf b/modules/git.conf new file mode 100644 index 0000000..43f36b5 --- /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 tracked +# files only, so untracked show in the text but do not escalate. +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/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..b2ea14c --- /dev/null +++ b/utils/git_status.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +# Minimal git status text for the git module: " " on a clean +# work tree, " M A 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 tracked files have +# changes and 0 otherwise (tmux's #{?...} reads the string "0" as false). +# Untracked files show in the text but do not escalate. 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 -uno 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: 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 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 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" +[ "$modified" -gt 0 ] && out="$out ${modified}M" +[ "$staged" -gt 0 ] && out="$out ${staged}A" +[ "$deleted" -gt 0 ] && out="$out ${deleted}D" +[ "$untracked" -gt 0 ] && out="$out ${untracked}?" +printf '%s' "$out" From 0cb6cb25a615fb69abf30fae6315f1c7254e37f2 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:04:36 -0400 Subject: [PATCH 2/8] feat(gitmux): dark text background for stock gitmux colours gitmux's default palette targets a black bar; defaulting the text block to @thm_crust keeps those colours readable on any theme surface. --- modules/gitmux.conf | 4 ++++ 1 file changed, 4 insertions(+) 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 From 47c1e0c6073b4d9c4fd39038dcae5fcbefecd1ea Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:04:36 -0400 Subject: [PATCH 3/8] test(git): cover git module wiring, gitmux text bg and the status script Asserts the when gate, dirty escalation and script invocation wiring, the gitmux text background default, and runs git_status.sh against a throwaway repo through clean, staged, modified/untracked, --dirty probe, symbol override and non-repo states. --- run_tests.sh | 1 + tests/git_module.sh | 63 +++++++++++++++++++++++++++++++++++ tests/git_module_expected.txt | 24 +++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/git_module.sh create mode 100644 tests/git_module_expected.txt 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..0cd34c7 --- /dev/null +++ b/tests/git_module.sh @@ -0,0 +1,63 @@ +#!/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. +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, and a +# plain directory outside any repo. +git_status="${script_dir}/../utils/git_status.sh" +repo=$(mktemp -d) +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)" + +outside=$(mktemp -d) +printf '\nscript_outside_repo [%s]\n' "$("$git_status" "$outside")" +rm -rf "$repo" "$outside" diff --git a/tests/git_module_expected.txt b/tests/git_module_expected.txt new file mode 100644 index 0000000..995e71f --- /dev/null +++ b/tests/git_module_expected.txt @@ -0,0 +1,24 @@ + +@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 ! 1A] +script_modified_untracked [main ! 1M 1?] +script_probe_dirty [1] +script_symbols [main WARN 1M 1?] +script_outside_repo [] From 37d9c54ccba44b39b76f9ea01a2b63473770475e Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:04:43 -0400 Subject: [PATCH 4/8] docs: document the git module and the gitmux text background default Adds the Git module entry (options, dirty escalation, when to prefer it over gitmux), notes gitmux's new text background default, registers git in the built-in module list, and fixes the stale per-module background override to the real @themux__text_bg option. --- docs/reference/configuration.md | 4 +-- docs/reference/status-line.md | 46 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) 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..b904466 100644 --- a/docs/reference/status-line.md +++ b/docs/reference/status-line.md @@ -55,9 +55,13 @@ 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. + ### Removing a specific module option ```sh @@ -187,6 +191,37 @@ 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 ! 2M 1A 3?` on a +dirty one (modified, staged, deleted and untracked counts; zero groups are +omitted, a short SHA replaces the branch when HEAD is detached). 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 (tracked changes only — untracked files +show in the text but do not escalate) 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. + +```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 +236,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`. From 1fc48deea518b1a955c062ec8ca4f44753606a7c Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:31:57 -0400 Subject: [PATCH 5/8] fix(modules): respect naked variant over raw bg overrides A raw per-channel _text_bg/_icon_bg override was applied unconditionally to both the resting and active channel, so gitmux's crust text background (the default since the previous commit) painted an opaque block even under the naked variant, which promises a transparent background. Gate each bg override by that channel's own resolved variant instead. --- docs/reference/status-line.md | 4 +++- utils/module_render.sh | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/reference/status-line.md b/docs/reference/status-line.md index b904466..741a5c3 100644 --- a/docs/reference/status-line.md +++ b/docs/reference/status-line.md @@ -60,7 +60,9 @@ 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. +[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 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") From 49be5d7d90f1a0729ecd62a8a2926ea6d8d76d4d Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:32:09 -0400 Subject: [PATCH 6/8] fix(git): conflict group, staged label, untracked dirty parity, quoted path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged count rendered as "A" while counting every X in [MTADRC], which reads as "added" but really means "staged" — relabel it "S". An unmerged XY pair (UU, AU, UA, DU, UD, DD, AA) was counted straight into the staged/modified/deleted groups instead of its own conflicts group, so a conflicted file rendered as an ordinary change; detect it first and count it as "C" ahead of the rest. --dirty used status --porcelain -uno (tracked changes only) while the text mode counts untracked files too, so an untracked-only work tree showed "! 1?" in the text but kept the accent resting; drop -uno so both passes agree. Quote the script path in both #() invocations in git.conf so an install path containing spaces doesn't break the command. --- docs/reference/status-line.md | 20 +++++++++++--------- modules/git.conf | 8 ++++---- utils/git_status.sh | 33 ++++++++++++++++++++++----------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/reference/status-line.md b/docs/reference/status-line.md index 741a5c3..bb351b3 100644 --- a/docs/reference/status-line.md +++ b/docs/reference/status-line.md @@ -196,11 +196,13 @@ 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 ! 2M 1A 3?` on a -dirty one (modified, staged, deleted and untracked counts; zero groups are -omitted, a short SHA replaces the branch when HEAD is detached). The module -prints plain text, so it is themed like every other pill and renders only -inside a git work tree. +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:** @@ -208,10 +210,10 @@ inside a git work tree. set -g @themux_status_line_1 "windows / git" ``` -**Dirty escalation:** a dirty work tree (tracked changes only — untracked files -show in the text but do not escalate) 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. +**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) diff --git a/modules/git.conf b/modules/git.conf index 43f36b5..3598fcb 100644 --- a/modules/git.conf +++ b/modules/git.conf @@ -13,13 +13,13 @@ 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}")' +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 tracked -# files only, so untracked show in the text but do not escalate. +# 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 -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). diff --git a/utils/git_status.sh b/utils/git_status.sh index b2ea14c..43dbb1d 100755 --- a/utils/git_status.sh +++ b/utils/git_status.sh @@ -1,22 +1,23 @@ #!/usr/bin/env bash # Minimal git status text for the git module: " " on a clean -# work tree, " M A D ?" on a dirty one (zero +# 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 tracked files have -# changes and 0 otherwise (tmux's #{?...} reads the string "0" as false). -# Untracked files show in the text but do not escalate. Outside a repo the -# text mode prints nothing; both modes always exit 0. +# --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 -uno 2>/dev/null | head -1) + 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 @@ -31,10 +32,13 @@ branch=$(git -C "$path" symbolic-ref --short -q HEAD 2>/dev/null) || 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: 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 staged=0 modified=0 deleted=0 untracked=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 ;; @@ -46,6 +50,12 @@ while IFS= read -r line; do 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)) ;; @@ -59,8 +69,9 @@ if [ "$dirty" -eq 0 ]; then 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}A" +[ "$staged" -gt 0 ] && out="$out ${staged}S" [ "$deleted" -gt 0 ] && out="$out ${deleted}D" [ "$untracked" -gt 0 ] && out="$out ${untracked}?" printf '%s' "$out" From c9c833d44290e9a9024105b027397356484ee14b Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:32:18 -0400 Subject: [PATCH 7/8] test(git): conflict coverage and temp-dir cleanup Add a real UU conflict repo (two branches editing the same line, then a failed merge) asserting " ! 1C", then layer a fabricated add/add and delete/delete pair onto the same unresolved merge to assert AA/DD count as conflicts instead of leaking into the staged/deleted groups. Cover the untracked-only --dirty/text parity fix and the quoted script path invocations, and update the staged-label expectation to "S". Trap the temp repos right after mktemp so the harness ERR-trap path can't leak them. --- tests/git_module.sh | 64 ++++++++++++++++++++++++++++++----- tests/git_module_expected.txt | 10 ++++-- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/tests/git_module.sh b/tests/git_module.sh index 0cd34c7..a4b9edf 100644 --- a/tests/git_module.sh +++ b/tests/git_module.sh @@ -18,13 +18,14 @@ 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. +# 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; } +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; } +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; } @@ -37,10 +38,15 @@ tmux show -gqv @_tmx_module_git_core | { grep -oF '#{?#{E:@_tmx_git_dirty},' || 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, and a -# plain directory outside any repo. +# untracked, the --dirty probe backing active_when, symbol overrides, an +# untracked-only work tree, a plain directory outside any repo, and a +# conflicted merge. 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 @@ -58,6 +64,48 @@ 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)" -outside=$(mktemp -d) -printf '\nscript_outside_repo [%s]\n' "$("$git_status" "$outside")" -rm -rf "$repo" "$outside" +# 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: a real UU from two branches editing the same line plus a failed +# merge, asserted on its own first. Then an add/add and a fabricated +# delete/delete (git plumbing — an ordinary merge almost never surfaces a +# genuine DD pair) are layered onto the same unresolved merge, asserting +# neither leaks into the staged/deleted groups. +git -C "$conflict_repo" init -q -b main +echo "base line" >"$conflict_repo/shared.txt" +echo "victim" >"$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 +victim_blob=$(git -C "$conflict_repo" rev-parse HEAD:victim.txt) + +git -C "$conflict_repo" checkout -q -b feature +echo "feature line" >"$conflict_repo/shared.txt" +git -C "$conflict_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -am feature + +git -C "$conflict_repo" checkout -q main +echo "main line" >"$conflict_repo/shared.txt" +git -C "$conflict_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -am mainedit + +git -C "$conflict_repo" merge feature --no-edit -q >/dev/null 2>&1 || true +printf '\nscript_conflict [%s]' "$("$git_status" "$conflict_repo")" + +blob_one=$(printf 'one\n' | git -C "$conflict_repo" hash-object -w --stdin) +blob_two=$(printf 'two\n' | git -C "$conflict_repo" hash-object -w --stdin) +{ + printf '100644 %s 2\tadded.txt\n' "$blob_one" + printf '100644 %s 3\tadded.txt\n' "$blob_two" +} | git -C "$conflict_repo" update-index --index-info +printf 'two\n' >"$conflict_repo/added.txt" + +git -C "$conflict_repo" rm -q --cached victim.txt +rm -f "$conflict_repo/victim.txt" +printf '100644 %s 1\tvictim.txt\n' "$victim_blob" | git -C "$conflict_repo" update-index --index-info + +printf '\nscript_conflict_extended [%s]\n' "$("$git_status" "$conflict_repo")" diff --git a/tests/git_module_expected.txt b/tests/git_module_expected.txt index 995e71f..92562b0 100644 --- a/tests/git_module_expected.txt +++ b/tests/git_module_expected.txt @@ -8,8 +8,8 @@ @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} +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}, @@ -17,8 +17,12 @@ core_active_switch #{?#{E:@_tmx_git_dirty}, script_clean [main ✓] script_probe_clean [0] -script_staged [main ! 1A] +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] From 05b60a18b4649dede2d4f04ecf6152de376f7fd8 Mon Sep 17 00:00:00 2001 From: Jeffrey Leon Date: Thu, 2 Jul 2026 09:43:36 -0400 Subject: [PATCH 8/8] test(git): fabricate conflict fixtures via index stages for CI stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict fixtures built a UU via a real `git merge` and layered a fabricated AA/DD on top of it. On CI (no git identity configured, unlike this machine's global gitconfig) the un-`-c`'d merge aborted immediately with "unable to auto-detect email address" before ever attempting the merge, an error silently swallowed by `|| true`; the repo was left clean ("main ✓" instead of "main ! 1C"), and the AA+DD fabricated on top of that unchanged state only produced 2 conflicts instead of 3. Both mismatches were the same root cause, not two separate bugs. Fabricate all three conflict types (UU, AA, DD) directly through index stages instead — the same stage combination git itself uses to derive the XY code (1+2+3 = UU, 2+3 with no 1 = AA, 1 alone = DD) — so the fixture no longer depends on a real merge's identity requirement, diff outcome or version-specific merge machinery. Each fabrication is verified against a fresh `git status --porcelain` right away via a require_conflict() guard, so a stanza that fails to register is an immediate, clearly labelled failure instead of a silently wrong count downstream. --- tests/git_module.sh | 73 ++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/tests/git_module.sh b/tests/git_module.sh index a4b9edf..e5ae568 100644 --- a/tests/git_module.sh +++ b/tests/git_module.sh @@ -40,7 +40,7 @@ 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 -# conflicted merge. +# fabricated conflicted index. git_status="${script_dir}/../utils/git_status.sh" repo=$(mktemp -d) outside=$(mktemp -d) @@ -73,39 +73,64 @@ printf '\nscript_probe_untracked_only [%s]' "$("$git_status" --dirty "$repo")" printf '\nscript_outside_repo [%s]' "$("$git_status" "$outside")" -# Conflicts: a real UU from two branches editing the same line plus a failed -# merge, asserted on its own first. Then an add/add and a fabricated -# delete/delete (git plumbing — an ordinary merge almost never surfaces a -# genuine DD pair) are layered onto the same unresolved merge, asserting -# neither leaks into the staged/deleted groups. +# 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 -echo "base line" >"$conflict_repo/shared.txt" -echo "victim" >"$conflict_repo/victim.txt" +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 -victim_blob=$(git -C "$conflict_repo" rev-parse HEAD:victim.txt) - -git -C "$conflict_repo" checkout -q -b feature -echo "feature line" >"$conflict_repo/shared.txt" -git -C "$conflict_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -am feature - -git -C "$conflict_repo" checkout -q main -echo "main line" >"$conflict_repo/shared.txt" -git -C "$conflict_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -am mainedit +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" -git -C "$conflict_repo" merge feature --no-edit -q >/dev/null 2>&1 || true +status=$(git -C "$conflict_repo" --no-optional-locks status --porcelain) +require_conflict 'UU shared.txt' "$status" printf '\nscript_conflict [%s]' "$("$git_status" "$conflict_repo")" -blob_one=$(printf 'one\n' | git -C "$conflict_repo" hash-object -w --stdin) -blob_two=$(printf 'two\n' | git -C "$conflict_repo" hash-object -w --stdin) +# AA: added.txt gets stage 2 + 3 only (no stage 1 — no common ancestor). { - printf '100644 %s 2\tadded.txt\n' "$blob_one" - printf '100644 %s 3\tadded.txt\n' "$blob_two" + 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 'two\n' >"$conflict_repo/added.txt" +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_blob" | git -C "$conflict_repo" update-index --index-info +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")"