Skip to content
Closed
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
50 changes: 41 additions & 9 deletions docs/reference/status-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,17 @@ 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
Native git status with no external dependency: a branch glyph plus the
branch name, the upstream divergence, and a minimal clean/dirty summary —
` main ✓` on a clean work tree, ` main ⇡2 ⇣1 ✖1 +2 !3 −1 ?2` on a
diverged, dirty one. `⇡`/`⇣` are the commits ahead/behind the upstream,
shown only when nonzero; the dirty groups follow symbol-before-count in
this fixed order: conflicts, staged, modified, deleted, untracked (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.
conflicts group, never leaking into 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:**

Expand All @@ -213,13 +216,42 @@ 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.
and always agrees with the text. Divergence does not escalate: the probe
checks the work tree only, so a diverged-but-clean tree keeps the resting
accent.

```sh
set -g @themux_git_color "#{E:@thm_role_accent}" # resting (clean)
set -g @themux_git_active_color "#{E:@thm_role_notice}" # dirty
set -g @themux_git_symbol_clean "✓"
set -g @themux_git_symbol_dirty "!"
```

**Symbols:** each symbol has a glyph default and a plain-ASCII default,
picked through the module's [leading content](./configuration.md#status-modules)
cascade (`@themux_git_leading_show` > `@themux_module_leading_show` >
`@themux_all_leading_show`) — the same three-tier cascade used everywhere
else. `icon`/`auto`/any invalid or unset value renders the glyph tier;
`label`/`off` renders the plain tier instead. An explicit
`@themux_git_symbol_*` override always wins over both tiers.

| Group | Option | Glyph default | Plain default |
| --- | --- | --- | --- |
| Branch | `@themux_git_symbol_branch` | ` ` (U+E0A0 + trailing space) | (empty) |
| Clean | `@themux_git_symbol_clean` | `✓` | `ok` |
| Conflict | `@themux_git_symbol_conflict` | `✖` | `x` |
| Staged | `@themux_git_symbol_staged` | `+` | `+` |
| Modified | `@themux_git_symbol_modified` | `!` | `!` |
| Deleted | `@themux_git_symbol_deleted` | `−` (U+2212 minus sign) | `-` |
| Untracked | `@themux_git_symbol_untracked` | `?` | `?` |
| Ahead | `@themux_git_symbol_ahead` | `⇡` | `^` |
| Behind | `@themux_git_symbol_behind` | `⇣` | `v` |

The branch symbol carries its own trailing spacing and is concatenated
straight onto the branch name, so the plain tier's empty default leaves the
branch name standing alone with no stray leading space.

```sh
set -g @themux_git_symbol_conflict "✗"
set -g @themux_git_leading_show "off" # switch this module to the plain tier
```

Prefer `git` for a minimal status that follows the theme with nothing to
Expand Down
44 changes: 41 additions & 3 deletions modules/git.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,57 @@
# 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.
#
# The text is "<branch-sym><branch>", then "<ahead><n>"/"<behind><n>" for a
# nonzero upstream divergence, then the work tree part: the clean symbol on
# a clean tree, or symbol-first dirty groups "<conflict><n> <staged><n>
# <modified><n> <deleted><n> <untracked><n>" (zero groups omitted) — e.g.
# " main ⇡2 ⇣1 ✖1 +2 !3 −1 ?2". Each of the nine symbols below has a glyph
# default and a plain-ASCII default, picked through the leading_show cascade
# (see the block right below).
set -ogq "@themux_${MODULE_NAME}_icon" "󰊢"
set -ogq "@themux_${MODULE_NAME}_color" "#{E:@thm_role_accent}"
set -ogq "@themux_${MODULE_NAME}_symbol_clean" "✓"
set -ogq "@themux_${MODULE_NAME}_symbol_dirty" "!"

# The dirty-group symbols degrade from glyphs to plain ASCII when the module
# is configured to hide/replace icons, reusing the leading_show cascade
# (@themux_git_leading_show > @themux_module_leading_show — see
# utils/metric_module.conf for the reference pattern this is copied from).
# @themux_all_leading_show is only reached when the module tier is unset,
# and stock options bake @themux_module_leading_show "icon", so in practice
# tiers switch through the module or per-module option.
# `label` and `off` both mean "no icons wanted"; `icon`, `auto`, and any
# invalid/unset value keep the glyphs (module_render.sh's own leading_show
# fallback is icon too). An explicit @themux_${MODULE_NAME}_symbol_* override
# always wins over both tiers (-ogq only sets the option when it is unset).
set -gqF "@_tmx_${MODULE_NAME}_leading_show" "#{?#{==:#{@themux_${MODULE_NAME}_leading_show},},#{?#{==:#{@themux_module_leading_show},},#{@themux_all_leading_show},#{@themux_module_leading_show}},#{@themux_${MODULE_NAME}_leading_show}}"
set -gqF "@_tmx_${MODULE_NAME}_plain" "#{||:#{==:#{@_tmx_${MODULE_NAME}_leading_show},label},#{==:#{@_tmx_${MODULE_NAME}_leading_show},off}}"
# The branch symbol carries its own trailing spacing (glyph + space) and the
# script concatenates it directly onto the branch name, so the plain tier's
# empty value leaves no leading-space artifact. A trailing space inside a
# #{?...} branch survives tmux expansion (verified against tmux 3.7a).
set -ogq "@themux_${MODULE_NAME}_symbol_branch" "#{?#{@_tmx_${MODULE_NAME}_plain},, }"
set -ogq "@themux_${MODULE_NAME}_symbol_clean" "#{?#{@_tmx_${MODULE_NAME}_plain},ok,✓}"
set -ogq "@themux_${MODULE_NAME}_symbol_conflict" "#{?#{@_tmx_${MODULE_NAME}_plain},x,✖}"
set -ogq "@themux_${MODULE_NAME}_symbol_staged" "#{?#{@_tmx_${MODULE_NAME}_plain},+,+}"
set -ogq "@themux_${MODULE_NAME}_symbol_modified" "#{?#{@_tmx_${MODULE_NAME}_plain},!,!}"
set -ogq "@themux_${MODULE_NAME}_symbol_deleted" "#{?#{@_tmx_${MODULE_NAME}_plain},-,−}"
set -ogq "@themux_${MODULE_NAME}_symbol_untracked" "#{?#{@_tmx_${MODULE_NAME}_plain},?,?}"
set -ogq "@themux_${MODULE_NAME}_symbol_ahead" "#{?#{@_tmx_${MODULE_NAME}_plain},^,⇡}"
set -ogq "@themux_${MODULE_NAME}_symbol_behind" "#{?#{@_tmx_${MODULE_NAME}_plain},v,⇣}"

# `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.
# The ten quoted arguments are positional and load-bearing — path, then
# branch/clean/conflict/staged/modified/deleted/untracked/ahead/behind — and
# must match the usage order in utils/git_status.sh exactly.
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}" "#{E:@themux_git_symbol_branch}" "#{E:@themux_git_symbol_clean}" "#{E:@themux_git_symbol_conflict}" "#{E:@themux_git_symbol_staged}" "#{E:@themux_git_symbol_modified}" "#{E:@themux_git_symbol_deleted}" "#{E:@themux_git_symbol_untracked}" "#{E:@themux_git_symbol_ahead}" "#{E:@themux_git_symbol_behind}")'

# 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.
# Divergence is deliberately excluded (the probe is worktree-only porcelain):
# a diverged-but-clean tree keeps the resting accent.
set -ogq "@themux_${MODULE_NAME}_active_color" "#{E:@thm_role_notice}"
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}"
Expand Down
188 changes: 181 additions & 7 deletions tests/git_module.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
# shellcheck disable=SC1091
source "${script_dir}/helpers.sh"

src() {
tmux source "${script_dir}/../themux_options.conf"
tmux source "${script_dir}/../themux.conf"
}

# 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"
src

# Wiring: the when gate, the dirty escalation and the accent options all point
# at the expected internal probes and palette roles.
Expand Down Expand Up @@ -37,15 +41,66 @@ tmux show -gqv @_tmx_module_git_core | { grep -oF '#{?#{E:@_tmx_git_dirty},' ||
# gitmux keeps its stock colours but sits on the theme's darkest step.
print_option @themux_gitmux_text_bg

# The nine symbols default to glyphs (icon/auto/invalid leading_show) and
# degrade to plain ASCII under the label/off tier, through the same
# three-tier leading_show cascade cpu/ram already use (@_tmx_git_leading_show,
# @_tmx_git_plain — see modules/git.conf). #{E:...} forces the conditional
# default to expand; a plain #{@themux_git_symbol_*} would return the raw
# "#{?...}" formula text instead of a resolved symbol.
printf '\n\nleading_show_default_glyphs '
{ [ "$(tmux display -p '#{E:@themux_git_symbol_clean}')" = "✓" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_conflict}')" = "✖" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_deleted}')" = "−" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_ahead}')" = "⇡" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_behind}')" = "⇣" ]; } && printf "Y" || printf "n"

# The glyph-tier branch symbol carries its own trailing space (the script
# concatenates it directly onto the branch name). Compare against the exact
# two-character value — glyph then space — instead of assuming the space
# survives the #{?...} conditional and the #{E:} expansion.
printf '\nbranch_symbol_glyph_keeps_trailing_space '
{ [ "$(tmux display -p '#{E:@themux_git_symbol_branch}')" = " " ]; } && printf "Y" || printf "n"

tmux set -g @themux_git_leading_show "off"
src
printf '\nleading_show_off_plain '
{ [ "$(tmux display -p '#{E:@themux_git_symbol_clean}')" = "ok" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_conflict}')" = "x" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_deleted}')" = "-" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_ahead}')" = "^" ] &&
[ "$(tmux display -p '#{E:@themux_git_symbol_behind}')" = "v" ]; } && printf "Y" || printf "n"

# The plain tier drops the branch symbol entirely — empty, not a bare space,
# so the branch name stands alone with no leading-space artifact.
printf '\nbranch_symbol_plain_empty '
{ [ -z "$(tmux display -p '#{E:@themux_git_symbol_branch}')" ]; } && printf "Y" || printf "n"

# An explicit user override on a symbol option always wins, tier or no tier.
tmux set -g @themux_git_symbol_clean "CUSTOM"
src
printf '\nleading_show_off_override_wins '
{ [ "$(tmux display -p '#{E:@themux_git_symbol_clean}')" = "CUSTOM" ]; } && printf "Y" || printf "n"
tmux set -gu @themux_git_symbol_clean

tmux set -g @themux_git_leading_show "label"
src
printf '\nleading_show_label_plain '
{ [ "$(tmux display -p '#{E:@themux_git_symbol_clean}')" = "ok" ]; } && printf "Y" || printf "n"

tmux set -gu @themux_git_leading_show
src

# 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.
# untracked-only work tree, a plain directory outside any repo, a fabricated
# conflicted index, and a locally fabricated upstream divergence.
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
plain_repo=$(mktemp -d)
diverge_repo=$(mktemp -d)
trap 'rm -rf "$repo" "$outside" "$conflict_repo" "$plain_repo" "$diverge_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
Expand All @@ -62,7 +117,13 @@ 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)"

# Symbol overrides use the positional API (path, branch, clean, conflict,
# staged, modified, deleted, untracked, ahead, behind). The repo is dirty
# with 1 modified + 1 untracked and has no upstream, so only BR:/MOD/UNT
# should appear — proving those args land in the right slots, not shifted
# by the unused ones.
printf '\nscript_symbols [%s]' "$("$git_status" "$repo" "BR:" "CLEAN" "CONF" "STG" "MOD" "DEL" "UNT" "AH" "BH")"

# 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,
Expand Down Expand Up @@ -133,4 +194,117 @@ 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")"
printf '\nscript_conflict_extended [%s]' "$("$git_status" "$conflict_repo")"

# Full plain-tier shape, end to end: a dedicated repo with all five dirty
# groups populated (1 conflict, 2 staged, 3 modified, 1 deleted, 2 untracked)
# and the script invoked directly with the literal args the plain tier
# resolves to (empty branch symbol, then ok/x/+/!/-/?/^/v) — no live tmux
# render needed for this one, per the same direct-invocation pattern as
# script_symbols above. The empty first symbol also proves the branch name
# stands alone with no leading-space artifact. The conflict is fabricated
# the same index-stage way as conflict_repo's UU, isolated in its own
# throwaway repo so it cannot perturb the conflict_repo assertions above.
git -C "$plain_repo" init -q -b main
printf 'shared base\n' >"$plain_repo/conflict.txt"
printf 'to delete\n' >"$plain_repo/del_target.txt"
printf 'mod1\n' >"$plain_repo/mod1.txt"
printf 'mod2\n' >"$plain_repo/mod2.txt"
printf 'mod3\n' >"$plain_repo/mod3.txt"
git -C "$plain_repo" add conflict.txt del_target.txt mod1.txt mod2.txt mod3.txt
git -C "$plain_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q -m base

echo new1 >"$plain_repo/new1.txt"
echo new2 >"$plain_repo/new2.txt"
git -C "$plain_repo" add new1.txt new2.txt # 2 staged

echo change >>"$plain_repo/mod1.txt"
echo change >>"$plain_repo/mod2.txt"
echo change >>"$plain_repo/mod3.txt" # 3 modified (unstaged)

rm -f "$plain_repo/del_target.txt" # 1 deleted (unstaged)

echo u1 >"$plain_repo/untracked1.txt"
echo u2 >"$plain_repo/untracked2.txt" # 2 untracked

conflict_base=$(git -C "$plain_repo" rev-parse HEAD:conflict.txt)
conflict_ours=$(printf 'conflict ours\n' | git -C "$plain_repo" hash-object -w --stdin)
conflict_theirs=$(printf 'conflict theirs\n' | git -C "$plain_repo" hash-object -w --stdin)
git -C "$plain_repo" rm -q --cached conflict.txt
{
printf '100644 %s 1\tconflict.txt\n' "$conflict_base"
printf '100644 %s 2\tconflict.txt\n' "$conflict_ours"
printf '100644 %s 3\tconflict.txt\n' "$conflict_theirs"
} | git -C "$plain_repo" update-index --index-info
printf 'conflict ours\n' >"$plain_repo/conflict.txt" # 1 conflict

status=$(git -C "$plain_repo" --no-optional-locks status --porcelain)
require_conflict 'UU conflict.txt' "$status"
printf '\nscript_plain_tier [%s]' "$("$git_status" "$plain_repo" "" "ok" "x" "+" "!" "-" "?" "^" "v")"

# Divergence: fabricated locally, no network — a second local branch plays
# the upstream (git tracks a local branch via --set-upstream-to just fine),
# so `status --porcelain -b` decorates the header with [ahead N, behind M].
# Every commit carries inline -c identity (CI has none configured — the same
# environment trap as the conflict fixture note above), and each state is
# guarded against the actual porcelain -b header the same way
# require_conflict guards the XY codes, so a fixture that fails to register
# dies loudly instead of producing a silently wrong expectation.
require_header() { # $1 expected header fragment, $2 porcelain -b status text
grep -qF "$1" <<<"$2" && return 0
printf 'FIXTURE ERROR: expected "%s" in git status --porcelain -b, got:\n%s\n' \
"$1" "$2" >&2
exit 1
}

git -C "$diverge_repo" init -q -b main
git -C "$diverge_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q --allow-empty -m base
base_sha=$(git -C "$diverge_repo" rev-parse HEAD)
git -C "$diverge_repo" branch -q upstream
git -C "$diverge_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q --allow-empty -m ours1
git -C "$diverge_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q --allow-empty -m ours2
git -C "$diverge_repo" branch -q --set-upstream-to=upstream main

# Ahead-only, clean: divergence renders next to the branch, the clean symbol
# stays, and the --dirty probe stays 0 — divergence must NOT escalate.
status=$(git -C "$diverge_repo" --no-optional-locks status --porcelain -b)
require_header '[ahead 2]' "$status"
printf '\nscript_ahead_only [%s]' "$("$git_status" "$diverge_repo")"
printf '\nscript_probe_diverged_clean [%s]' "$("$git_status" --dirty "$diverge_repo")"

# Combined ahead + behind: the upstream branch advances independently.
git -C "$diverge_repo" checkout -q upstream
git -C "$diverge_repo" -c user.name=themux -c user.email=themux@test -c commit.gpgsign=false commit -q --allow-empty -m theirs
git -C "$diverge_repo" checkout -q main
status=$(git -C "$diverge_repo" --no-optional-locks status --porcelain -b)
require_header '[ahead 2, behind 1]' "$status"
printf '\nscript_ahead_behind [%s]' "$("$git_status" "$diverge_repo")"

# Positional check for the divergence args: distinct override strings on the
# clean diverged tree — only branch/ahead/behind/clean slots should render.
printf '\nscript_symbols_divergence [%s]' "$("$git_status" "$diverge_repo" "BR:" "OK" "C" "S" "M" "D" "U" "A" "B")"

# Behind-only: main rewinds to the base while the upstream stays ahead.
git -C "$diverge_repo" reset -q --hard "$base_sha"
status=$(git -C "$diverge_repo" --no-optional-locks status --porcelain -b)
require_header '[behind 1]' "$status"
printf '\nscript_behind_only [%s]' "$("$git_status" "$diverge_repo")"

# Header edge cases render no divergence: "## main" (no upstream) and
# "[gone]" (upstream configured but its ref vanished).
git -C "$diverge_repo" branch -q --unset-upstream
printf '\nscript_no_upstream [%s]' "$("$git_status" "$diverge_repo")"
git -C "$diverge_repo" config branch.main.remote .
git -C "$diverge_repo" config branch.main.merge refs/heads/vanished
status=$(git -C "$diverge_repo" --no-optional-locks status --porcelain -b)
require_header '[gone]' "$status"
printf '\nscript_upstream_gone [%s]' "$("$git_status" "$diverge_repo")"

# Detached HEAD ("## HEAD (no branch)"): the short-SHA fallback still works
# and no divergence leaks in. The SHA varies per run, so assert the shape
# instead of the literal output.
git -C "$diverge_repo" checkout -q --detach
out=$("$git_status" "$diverge_repo")
printf '\nscript_detached_no_divergence '
{ [[ "$out" != *main* ]] && [[ "$out" == *"✓"* ]] &&
[[ "$out" != *"⇡"* ]] && [[ "$out" != *"⇣"* ]]; } && printf "Y\n" || printf "n\n"
Loading