gabriel / muse public
muse.plugin.zsh
217 lines 8.2 KB
838d4a3e feat(omzsh-plugin): strip to minimal, secure shell integration Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # muse.plugin.zsh — Oh My ZSH plugin for Muse version control
2 # ==============================================================================
3 # Minimal, secure shell integration. Shows domain + branch in your prompt.
4 # Nothing else runs automatically; everything else is a muse command away.
5 #
6 # Setup (after running tools/install-omzsh-plugin.sh):
7 # Add $(muse_prompt_info) to your PROMPT in ~/.zshrc, e.g.:
8 # PROMPT='%~ $(muse_prompt_info) %# '
9 #
10 # Configuration (set in ~/.zshrc BEFORE plugins=(… muse …)):
11 # MUSE_PROMPT_ICONS=1 Use emoji icons; set 0 for plain ASCII (default 1)
12 # MUSE_DIRTY_TIMEOUT=1 Seconds before dirty-check gives up (default 1)
13 #
14 # Security notes:
15 # - No eval of any data read from disk or env.
16 # - Branch names are regex-validated and %-escaped before prompt display.
17 # - Domain name is validated as alphanumeric before use.
18 # - All repo paths passed to subprocesses via env vars (not -c strings).
19 # - Dirty check runs only after a muse command, never on every keystroke.
20 # - Zero subprocesses on prompt render; one python3 on directory change.
21 # ==============================================================================
22
23 autoload -Uz is-at-least
24 if ! is-at-least 5.0; then
25 print "[muse] ZSH 5.0+ required. Plugin not loaded." >&2
26 return 1
27 fi
28
29 # ── Configuration ─────────────────────────────────────────────────────────────
30 : ${MUSE_PROMPT_ICONS:=1}
31 : ${MUSE_DIRTY_TIMEOUT:=1}
32
33 # Domain icon map. Override individual elements in ~/.zshrc before plugins=().
34 typeset -gA MUSE_DOMAIN_ICONS
35 MUSE_DOMAIN_ICONS=(
36 midi "♪"
37 code "⌥"
38 bitcoin "₿"
39 scaffold "⬡"
40 _default "◈"
41 )
42
43 # ── Internal state ────────────────────────────────────────────────────────────
44 typeset -g MUSE_REPO_ROOT="" # absolute path to repo root, or ""
45 typeset -g MUSE_DOMAIN="midi" # active domain plugin name
46 typeset -g MUSE_BRANCH="" # branch name, 8-char SHA, or "?"
47 typeset -gi MUSE_DIRTY=0 # 1 when working tree has uncommitted changes
48 typeset -gi MUSE_DIRTY_COUNT=0 # number of changed paths
49 typeset -gi _MUSE_CMD_RAN=0 # 1 after any muse command runs
50
51 # ── §1 Core detection (zero subprocesses) ───────────────────────────────────
52
53 # Walk up from $PWD to find .muse/. Sets MUSE_REPO_ROOT. Pure ZSH.
54 function _muse_find_root() {
55 local dir="$PWD"
56 while [[ "$dir" != "/" ]]; do
57 if [[ -d "$dir/.muse" ]]; then
58 MUSE_REPO_ROOT="$dir"
59 return 0
60 fi
61 dir="${dir:h}"
62 done
63 MUSE_REPO_ROOT=""
64 return 1
65 }
66
67 # Read branch from .muse/HEAD without forking. Validates before storing.
68 # Branch names are restricted to [a-zA-Z0-9/_.-] to prevent prompt injection.
69 function _muse_parse_head() {
70 local head_file="$MUSE_REPO_ROOT/.muse/HEAD"
71 if [[ ! -f "$head_file" ]]; then
72 MUSE_BRANCH=""; return 1
73 fi
74 local raw
75 raw=$(<"$head_file")
76 if [[ "$raw" == "ref: refs/heads/"* ]]; then
77 local branch="${raw#ref: refs/heads/}"
78 # Reject anything that could inject prompt escapes or path components.
79 if [[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]; then
80 MUSE_BRANCH="$branch"
81 else
82 MUSE_BRANCH="?"
83 fi
84 elif [[ "$raw" =~ '^[0-9a-f]{64}$' ]]; then
85 MUSE_BRANCH="${raw:0:8}" # detached HEAD
86 else
87 MUSE_BRANCH="?"
88 fi
89 }
90
91 # Read domain from .muse/repo.json. One python3 call; path via env var only.
92 function _muse_parse_domain() {
93 local repo_json="$MUSE_REPO_ROOT/.muse/repo.json"
94 if [[ ! -f "$repo_json" ]]; then
95 MUSE_DOMAIN="midi"; return
96 fi
97 MUSE_DOMAIN=$(MUSE_REPO_JSON="$repo_json" python3 <<'PYEOF' 2>/dev/null
98 import json, os
99 try:
100 d = json.load(open(os.environ['MUSE_REPO_JSON']))
101 v = str(d.get('domain', 'midi'))
102 # Accept only safe domain names: alphanumeric plus hyphens/underscores,
103 # max 32 chars. Anything else falls back to 'midi'.
104 safe = v.replace('-', '').replace('_', '')
105 print(v if (safe.isalnum() and 1 <= len(v) <= 32) else 'midi')
106 except Exception:
107 print('midi')
108 PYEOF
109 )
110 : ${MUSE_DOMAIN:=midi}
111 }
112
113 # Check dirty state. Runs with timeout; only called after a muse command.
114 function _muse_check_dirty() {
115 local output rc count=0
116 output=$(cd -- "$MUSE_REPO_ROOT" && \
117 timeout -- "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null)
118 rc=$?
119 if (( rc == 124 )); then
120 # Timeout — leave previous dirty state in place rather than lying.
121 return
122 fi
123 while IFS= read -r line; do
124 [[ "$line" == "##"* || -z "$line" ]] && continue
125 (( count++ ))
126 done <<< "$output"
127 MUSE_DIRTY=$(( count > 0 ? 1 : 0 ))
128 MUSE_DIRTY_COUNT=$count
129 }
130
131 # ── §2 Cache management ──────────────────────────────────────────────────────
132
133 # Lightweight refresh: head + domain only. Called on directory change.
134 function _muse_refresh() {
135 if ! _muse_find_root; then
136 MUSE_DOMAIN="midi"; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
137 return 1
138 fi
139 _muse_parse_head
140 _muse_parse_domain
141 }
142
143 # Full refresh: head + domain + dirty. Called after a muse command.
144 function _muse_refresh_full() {
145 _muse_refresh || return
146 _muse_check_dirty
147 _MUSE_CMD_RAN=0
148 }
149
150 # ── §3 ZSH hooks ─────────────────────────────────────────────────────────────
151
152 # On directory change: refresh head and domain; clear dirty (stale after cd).
153 function _muse_hook_chpwd() {
154 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
155 _muse_refresh 2>/dev/null
156 }
157 chpwd_functions+=(_muse_hook_chpwd)
158
159 # Before a command: flag when the user runs muse so we refresh after.
160 function _muse_hook_preexec() {
161 [[ "${${(z)1}[1]}" == "muse" ]] && _MUSE_CMD_RAN=1
162 }
163 preexec_functions+=(_muse_hook_preexec)
164
165 # Before the prompt: full refresh only when a muse command just ran.
166 function _muse_hook_precmd() {
167 (( _MUSE_CMD_RAN )) && _muse_refresh_full 2>/dev/null
168 }
169 precmd_functions+=(_muse_hook_precmd)
170
171 # ── §4 Prompt ────────────────────────────────────────────────────────────────
172
173 # Primary prompt segment. Example usage in ~/.zshrc:
174 # PROMPT='%~ $(muse_prompt_info) %# '
175 # Emits nothing when not inside a muse repo.
176 function muse_prompt_info() {
177 [[ -z "$MUSE_REPO_ROOT" ]] && return
178
179 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-${MUSE_DOMAIN_ICONS[_default]}}"
180 [[ "$MUSE_PROMPT_ICONS" == "0" ]] && icon="[$MUSE_DOMAIN]"
181
182 # Escape % so ZSH does not treat branch-name content as prompt directives.
183 local branch="${MUSE_BRANCH//\%/%%}"
184
185 local dirty=""
186 (( MUSE_DIRTY )) && dirty=" %F{red}✗ ${MUSE_DIRTY_COUNT}%f"
187
188 echo -n "%F{magenta}${icon} ${branch}%f${dirty}"
189 }
190
191 # ── §5 Aliases ───────────────────────────────────────────────────────────────
192 alias mst='muse status'
193 alias msts='muse status --short'
194 alias mcm='muse commit -m'
195 alias mco='muse checkout'
196 alias mlg='muse log'
197 alias mlgo='muse log --oneline'
198 alias mlgg='muse log --graph'
199 alias mdf='muse diff'
200 alias mdfst='muse diff --stat'
201 alias mbr='muse branch'
202 alias mtg='muse tag'
203 alias mfh='muse fetch'
204 alias mpull='muse pull'
205 alias mpush='muse push'
206 alias mrm='muse remote'
207
208 # ── §6 Completion ────────────────────────────────────────────────────────────
209 if [[ -f "${0:A:h}/_muse" ]]; then
210 fpath=("${0:A:h}" $fpath)
211 autoload -Uz compinit
212 compdef _muse muse 2>/dev/null
213 fi
214
215 # ── Init ──────────────────────────────────────────────────────────────────────
216 # Warm head + domain on load so the first prompt is not blank.
217 _muse_refresh 2>/dev/null