diff --git a/docs/reference/status-line.md b/docs/reference/status-line.md index a99b04a..687d337 100644 --- a/docs/reference/status-line.md +++ b/docs/reference/status-line.md @@ -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:** @@ -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 diff --git a/modules/git.conf b/modules/git.conf index 8a9518c..39b1f76 100644 --- a/modules/git.conf +++ b/modules/git.conf @@ -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 "", then ""/"" for a +# nonzero upstream divergence, then the work tree part: the clean symbol on +# a clean tree, or symbol-first dirty groups " +# " (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}" diff --git a/tests/git_module.sh b/tests/git_module.sh index e5ae568..baa2479 100644 --- a/tests/git_module.sh +++ b/tests/git_module.sh @@ -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. @@ -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 @@ -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, @@ -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" diff --git a/tests/git_module_expected.txt b/tests/git_module_expected.txt index bd0ab82..edf22d4 100644 --- a/tests/git_module_expected.txt +++ b/tests/git_module_expected.txt @@ -15,14 +15,30 @@ core_active_switch #{?#{E:@_tmx_git_dirty}, @themux_gitmux_text_bg #{E:@thm_crust} -script_clean [main ✓] + +leading_show_default_glyphs Y +branch_symbol_glyph_keeps_trailing_space Y +leading_show_off_plain Y +branch_symbol_plain_empty Y +leading_show_off_override_wins Y +leading_show_label_plain Y +script_clean [ main ✓] script_probe_clean [0] -script_staged [main ! 1S] -script_modified_untracked [main ! 1M 1?] +script_staged [ main +1] +script_modified_untracked [ main !1 ?1] script_probe_dirty [1] -script_symbols [main WARN 1M 1?] -script_untracked_only [main ! 1?] +script_symbols [BR:main MOD1 UNT1] +script_untracked_only [ main ?1] script_probe_untracked_only [1] script_outside_repo [] -script_conflict [main ! 1C] -script_conflict_extended [main ! 3C] +script_conflict [ main ✖1] +script_conflict_extended [ main ✖3] +script_plain_tier [main x1 +2 !3 -1 ?2] +script_ahead_only [ main ⇡2 ✓] +script_probe_diverged_clean [0] +script_ahead_behind [ main ⇡2 ⇣1 ✓] +script_symbols_divergence [BR:main A2 B1 OK] +script_behind_only [ main ⇣1 ✓] +script_no_upstream [ main ✓] +script_upstream_gone [ main ✓] +script_detached_no_divergence Y diff --git a/utils/git_status.sh b/utils/git_status.sh index 43dbb1d..7bd5eed 100755 --- a/utils/git_status.sh +++ b/utils/git_status.sh @@ -1,13 +1,34 @@ #!/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. +# Minimal git status text for the git module: +# " [] [] " clean tree +# " [] [] " dirty tree +# where is " +# " (symbol before count, zero groups omitted). The branch +# symbol carries its own trailing spacing and is concatenated directly onto +# the branch name, so an empty symbol leaves the branch standing alone with +# no leading-space artifact. Plain text only — no #[...] codes — so the +# renderer styles the block like any other module and every prop keeps +# working. # -# Usage: git_status.sh [] [] +# Usage: git_status.sh [] [] +# [] [] +# [] [] +# [] [] [] # git_status.sh --dirty # +# The positional order above is load-bearing: modules/git.conf passes the +# ten arguments in exactly this order from @themux_git_text — keep both in +# sync. +# +# Ahead/behind come from the porcelain -b branch header ("## main...origin/ +# main [ahead 2, behind 1]"); each nonzero count renders right after the +# branch. Headers without a divergence decoration — "## main" (no upstream), +# "## HEAD (no branch)" (detached, short-SHA fallback), "[gone]" — render +# nothing there. Divergence does NOT trigger the dirty escalation: --dirty +# stays worktree-only porcelain, so a diverged-but-clean tree keeps the +# resting accent. +# # --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 @@ -23,8 +44,18 @@ if [ "${1-}" = "--dirty" ]; then fi path=${1:-.} -clean_sym=${2:-✓} -dirty_sym=${3:-!} +# ${2-...} (no colon): an explicitly EMPTY branch symbol is a designed value +# (the plain tier drops the glyph so the branch name stands alone); the other +# symbols keep ${n:-...} since empty is not a meaningful value for them. +branch_sym=${2-" "} +clean_sym=${3:-✓} +conflict_sym=${4:-✖} +staged_sym=${5:-+} +modified_sym=${6:-!} +deleted_sym=${7:-−} +untracked_sym=${8:-?} +ahead_sym=${9:-⇡} +behind_sym=${10:-⇣} # 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) || @@ -32,16 +63,34 @@ 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. An unmerged pair +# One porcelain pass, counted by the two-character XY code. The "##" branch +# header contributes the ahead/behind counts; a refname cannot contain a +# space or "[", so the "[ahead N" / "behind M" fragments below can only come +# from the header decoration, never from a branch name. 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 +dirty=0 conflicts=0 staged=0 modified=0 deleted=0 untracked=0 ahead=0 behind=0 while IFS= read -r line; do case $line in - '' | '##'*) continue ;; + '') continue ;; + '##'*) + case $line in + *'[ahead '*) + t=${line##*'[ahead '} t=${t%%[!0-9]*} + ahead=${t:-0} + ;; + esac + case $line in + *'[behind '* | *', behind '*) + t=${line##*'behind '} t=${t%%[!0-9]*} + behind=${t:-0} + ;; + esac + continue + ;; '??'*) untracked=$((untracked + 1)) dirty=1 @@ -63,15 +112,18 @@ while IFS= read -r line; do esac done <<<"$status" +out="${branch_sym}${branch}" +[ "$ahead" -gt 0 ] && out="$out $ahead_sym$ahead" +[ "$behind" -gt 0 ] && out="$out $behind_sym$behind" + if [ "$dirty" -eq 0 ]; then - printf '%s %s' "$branch" "$clean_sym" + printf '%s %s' "$out" "$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}?" +[ "$conflicts" -gt 0 ] && out="$out $conflict_sym$conflicts" +[ "$staged" -gt 0 ] && out="$out $staged_sym$staged" +[ "$modified" -gt 0 ] && out="$out $modified_sym$modified" +[ "$deleted" -gt 0 ] && out="$out $deleted_sym$deleted" +[ "$untracked" -gt 0 ] && out="$out $untracked_sym$untracked" printf '%s' "$out"