Skip to content
4 changes: 2 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,8 @@ panes exactly.

Each module defines three options (and is referenced as `@themux_module_<name>`
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 |
| --- | --- |
Expand Down
50 changes: 49 additions & 1 deletion docs/reference/status-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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`.
Expand Down
33 changes: 33 additions & 0 deletions modules/git.conf
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions modules/gitmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name>_when lets the module renderer hide the
# whole segment when outside a repo and, in a powerline run, lets neighbours
Expand Down
1 change: 1 addition & 0 deletions run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
Expand Down
136 changes: 136 additions & 0 deletions tests/git_module.sh
Original file line number Diff line number Diff line change
@@ -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")"
28 changes: 28 additions & 0 deletions tests/git_module_expected.txt
Original file line number Diff line number Diff line change
@@ -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]
1 change: 1 addition & 0 deletions themux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
77 changes: 77 additions & 0 deletions utils/git_status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash

# Minimal git status text for the git module: "<branch> <clean>" on a clean
# work tree, "<branch> <dirty> <n>C <n>M <n>S <n>D <n>?" 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 <path> [<clean-symbol>] [<dirty-symbol>]
# git_status.sh --dirty <path>
#
# --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"
Loading