gabriel / muse public
muse.plugin.zsh
305 lines 13.0 KB
efce0034 fix(zsh-plugin): re-entry guard on chpwd to stop recursion, revert env -C 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:=0}
31 : ${MUSE_DIRTY_TIMEOUT:=5}
32 : ${MUSE_DEBUG:=0} # set to 1 to print timestamped trace to stderr
33
34 # Domain icon map. Override individual elements in ~/.zshrc before plugins=().
35 typeset -gA MUSE_DOMAIN_ICONS
36 MUSE_DOMAIN_ICONS=(
37 midi "♪"
38 code "⌥"
39 bitcoin "₿"
40 scaffold "⬡"
41 _default "◈"
42 )
43
44 # ── Internal state ────────────────────────────────────────────────────────────
45 typeset -g MUSE_REPO_ROOT="" # absolute path to repo root, or ""
46 typeset -g MUSE_DOMAIN="midi" # active domain plugin name
47 typeset -g MUSE_BRANCH="" # branch name, 8-char SHA, or "?"
48 typeset -gi MUSE_DIRTY=0 # 1 when working tree has uncommitted changes
49 typeset -gi MUSE_DIRTY_COUNT=0 # number of changed paths
50 typeset -gi _MUSE_CMD_RAN=0 # 1 after any muse command runs
51
52 # ── §1 Core detection (zero subprocesses) ───────────────────────────────────
53
54 # Walk up from $PWD to find a valid Muse repo root. Sets MUSE_REPO_ROOT.
55 # A valid repo requires .muse/repo.json — a bare .muse/ directory is not
56 # enough. This prevents false positives from stray or partial .muse/ dirs
57 # (e.g. a forgotten muse init in a parent directory).
58 function _muse_find_root() {
59 local dir="$PWD"
60 while [[ "$dir" != "/" ]]; do
61 if [[ -f "$dir/.muse/repo.json" ]]; then
62 MUSE_REPO_ROOT="$dir"
63 return 0
64 fi
65 dir="${dir:h}"
66 done
67 MUSE_REPO_ROOT=""
68 return 1
69 }
70
71 # Read branch from .muse/HEAD without forking. Validates before storing.
72 # Branch names are restricted to [a-zA-Z0-9/_.-] to prevent prompt injection.
73 #
74 # Muse HEAD format (canonical, written by muse/core/store.py):
75 # ref: refs/heads/<branch> — on a branch (symbolic ref)
76 # commit: <sha256> — detached HEAD (direct commit reference)
77 function _muse_parse_head() {
78 local head_file="$MUSE_REPO_ROOT/.muse/HEAD"
79 if [[ ! -f "$head_file" ]]; then
80 MUSE_BRANCH=""; return 1
81 fi
82 local raw
83 raw=$(<"$head_file")
84 if [[ "$raw" == "ref: refs/heads/"* ]]; then
85 local branch="${raw#ref: refs/heads/}"
86 # Reject anything that could inject prompt escapes or path components.
87 if [[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]; then
88 MUSE_BRANCH="$branch"
89 else
90 MUSE_BRANCH="?"
91 fi
92 elif [[ "$raw" == "commit: "* ]]; then
93 local sha="${raw#commit: }"
94 MUSE_BRANCH="${sha:0:8}" # detached HEAD — show short SHA
95 else
96 MUSE_BRANCH="?"
97 fi
98 }
99
100 # Read domain from .muse/repo.json. One python3 call; path via env var only.
101 function _muse_parse_domain() {
102 local repo_json="$MUSE_REPO_ROOT/.muse/repo.json"
103 if [[ ! -f "$repo_json" ]]; then
104 MUSE_DOMAIN="midi"; return
105 fi
106 MUSE_DOMAIN=$(MUSE_REPO_JSON="$repo_json" python3 <<'PYEOF' 2>/dev/null
107 import json, os
108 try:
109 d = json.load(open(os.environ['MUSE_REPO_JSON']))
110 v = str(d.get('domain', 'midi'))
111 # Accept only safe domain names: alphanumeric plus hyphens/underscores,
112 # max 32 chars. Anything else falls back to 'midi'.
113 safe = v.replace('-', '').replace('_', '')
114 print(v if (safe.isalnum() and 1 <= len(v) <= 32) else 'midi')
115 except Exception:
116 print('midi')
117 PYEOF
118 )
119 : ${MUSE_DOMAIN:=midi}
120 }
121
122 # Check dirty state. Runs with timeout; called on cd, shell load, and after
123 # any muse command. MUSE_DIRTY_TIMEOUT (default 5s) caps the wait.
124 typeset -gi _MUSE_LAST_DIRTY_RC=0 # last exit code from the dirty check subprocess
125 typeset -gi _MUSE_REFRESHING=0 # re-entry guard: 1 while a refresh is in progress
126
127 function _muse_check_dirty() {
128 local output rc count=0
129 print "[dirty:1] called. MUSE_REPO_ROOT=$MUSE_REPO_ROOT MUSE_BRANCH=$MUSE_BRANCH" >&2
130 # Run in a ZSH subshell (not env) so muse is found via PATH/aliases/venv.
131 # The cd here would normally re-fire chpwd_functions inside the subshell and
132 # recurse infinitely, but _muse_hook_chpwd's re-entry guard (_MUSE_REFRESHING)
133 # is inherited by subshells and blocks any nested call immediately.
134 output=$(cd -- "$MUSE_REPO_ROOT" && \
135 timeout -- "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null)
136 rc=$?
137 _MUSE_LAST_DIRTY_RC=$rc
138 print "[dirty:2] rc=$rc output='$output'" >&2
139 if (( rc == 124 )); then
140 print "[dirty:3] TIMEOUT. MUSE_DIRTY unchanged=$MUSE_DIRTY" >&2
141 return
142 fi
143 while IFS= read -r line; do
144 [[ "$line" == "##"* || -z "$line" ]] && continue
145 (( count++ ))
146 done <<< "$output"
147 MUSE_DIRTY=$(( count > 0 ? 1 : 0 ))
148 MUSE_DIRTY_COUNT=$count
149 print "[dirty:4] MUSE_DIRTY=$MUSE_DIRTY count=$count" >&2
150 }
151
152 # ── §2 Cache management ──────────────────────────────────────────────────────
153
154 # Full refresh: head + domain + dirty. Called on directory change and on load.
155 # One muse subprocess (status --porcelain) runs every time — same model as the
156 # git plugin. The timeout in _muse_check_dirty keeps it bounded.
157 function _muse_refresh() {
158 print "[refresh:1] start PWD=$PWD" >&2
159 if ! _muse_find_root; then
160 MUSE_DOMAIN="midi"; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
161 print "[refresh:2] no repo found — cleared state" >&2
162 return 1
163 fi
164 print "[refresh:3] root=$MUSE_REPO_ROOT" >&2
165 _muse_parse_head
166 print "[refresh:4] branch=$MUSE_BRANCH" >&2
167 _muse_parse_domain
168 print "[refresh:5] domain=$MUSE_DOMAIN" >&2
169 _muse_check_dirty
170 print "[refresh:6] done. dirty=$MUSE_DIRTY count=$MUSE_DIRTY_COUNT rc=$_MUSE_LAST_DIRTY_RC" >&2
171 }
172
173 # Post-command refresh: same as _muse_refresh but resets the command flag.
174 function _muse_refresh_full() {
175 (( MUSE_DEBUG )) && print "[muse] _muse_refresh_full (cmd_ran=$_MUSE_CMD_RAN)" >&2
176 _muse_refresh || return
177 _MUSE_CMD_RAN=0
178 }
179
180 # ── §3 ZSH hooks ─────────────────────────────────────────────────────────────
181
182 # On directory change: refresh head and domain; clear dirty (stale after cd).
183 # Pre-clear MUSE_REPO_ROOT so any silent failure in _muse_refresh leaves the
184 # prompt blank rather than showing stale data from the previous directory.
185 function _muse_hook_chpwd() {
186 # Guard: ZSH fires chpwd_functions inside $(…) subshells too. Without this,
187 # the cd inside _muse_check_dirty triggers this hook inside the subshell,
188 # which calls _muse_refresh → _muse_check_dirty → cd → chpwd → ∞.
189 # Subshells inherit _MUSE_REFRESHING, so the guard stops nested calls cold.
190 (( _MUSE_REFRESHING )) && return
191 _MUSE_REFRESHING=1
192 print "[chpwd:1] cd into $PWD — resetting state" >&2
193 MUSE_REPO_ROOT=""; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
194 _muse_refresh
195 print "[chpwd:2] after refresh: dirty=$MUSE_DIRTY branch=$MUSE_BRANCH" >&2
196 _MUSE_REFRESHING=0
197 }
198 chpwd_functions+=(_muse_hook_chpwd)
199
200 # Before a command: flag when the user runs muse so we refresh after.
201 function _muse_hook_preexec() {
202 [[ "${${(z)1}[1]}" == "muse" ]] && _MUSE_CMD_RAN=1
203 }
204 preexec_functions+=(_muse_hook_preexec)
205
206 # Before the prompt: full refresh only when a muse command just ran.
207 function _muse_hook_precmd() {
208 print "[precmd:1] _MUSE_CMD_RAN=$_MUSE_CMD_RAN MUSE_DIRTY=$MUSE_DIRTY" >&2
209 (( _MUSE_CMD_RAN )) && _muse_refresh_full
210 print "[precmd:2] after: MUSE_DIRTY=$MUSE_DIRTY" >&2
211 }
212 precmd_functions+=(_muse_hook_precmd)
213
214 # ── §4 Prompt ────────────────────────────────────────────────────────────────
215
216 # Primary prompt segment. Example usage in ~/.zshrc:
217 # PROMPT='%~ $(muse_prompt_info) %# '
218 # Emits nothing when not inside a muse repo.
219 #
220 # Clean: muse:(code:main) — domain:branch in magenta
221 # Dirty: muse:(code:main) — domain:branch in yellow
222 #
223 # The color of the domain:branch text is the only dirty signal — no extra
224 # symbol. Yellow means "uncommitted changes exist"; magenta means clean.
225 function muse_prompt_info() {
226 if [[ -z "$MUSE_REPO_ROOT" ]]; then
227 print "[prompt:1] no repo — returning empty" >&2
228 return
229 fi
230
231 # Escape % so ZSH does not treat branch-name content as prompt directives.
232 local branch="${MUSE_BRANCH//\%/%%}"
233 local domain="${MUSE_DOMAIN//\%/%%}"
234
235 # Branch: magenta when clean, yellow when dirty. Domain is always magenta.
236 local branch_color="%F{magenta}"
237 local color_name="MAGENTA"
238 if (( MUSE_DIRTY )); then
239 branch_color="%F{yellow}"
240 color_name="YELLOW"
241 fi
242
243 print "[prompt:2] MUSE_DIRTY=$MUSE_DIRTY branch=$branch domain=$domain → branch_color=$color_name" >&2
244
245 # Format: %F{cyan}muse:(%F{magenta}<domain>:%F{yellow|magenta}<branch>%F{cyan})%f
246 if [[ "$MUSE_PROMPT_ICONS" == "1" ]]; then
247 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-${MUSE_DOMAIN_ICONS[_default]}}"
248 echo -n "%F{cyan}${icon} muse:(%F{magenta}${domain}:${branch_color}${branch}%F{cyan})%f"
249 else
250 echo -n "%F{cyan}muse:(%F{magenta}${domain}:${branch_color}${branch}%F{cyan})%f"
251 fi
252 print "[prompt:3] rendered with $color_name" >&2
253 }
254
255 # ── §5 Debug ─────────────────────────────────────────────────────────────────
256
257 # Print current plugin state. Run when the prompt looks wrong.
258 # muse_debug
259 function muse_debug() {
260 print "MUSE_REPO_ROOT = ${MUSE_REPO_ROOT:-(not set)}"
261 print "MUSE_BRANCH = ${MUSE_BRANCH:-(not set)}"
262 print "MUSE_DOMAIN = ${MUSE_DOMAIN:-(not set)}"
263 print "MUSE_DIRTY = $MUSE_DIRTY (count: $MUSE_DIRTY_COUNT)"
264 print "MUSE_DIRTY_TIMEOUT = ${MUSE_DIRTY_TIMEOUT}s"
265 print "_MUSE_LAST_DIRTY_RC = $_MUSE_LAST_DIRTY_RC (124 = timed out)"
266 print "_MUSE_CMD_RAN = $_MUSE_CMD_RAN"
267 print "PWD = $PWD"
268 if [[ -n "$MUSE_REPO_ROOT" ]]; then
269 print "repo.json = $MUSE_REPO_ROOT/.muse/repo.json"
270 print "HEAD = $(< "$MUSE_REPO_ROOT/.muse/HEAD" 2>/dev/null || print '(missing)')"
271 print "--- muse status --porcelain (live, timed) ---"
272 time (cd -- "$MUSE_REPO_ROOT" && muse status --porcelain 2>&1)
273 print "--------------------------------------------"
274 fi
275 }
276
277 # ── §6 Aliases ───────────────────────────────────────────────────────────────
278 alias mst='muse status'
279 alias msts='muse status --short'
280 alias mcm='muse commit -m'
281 alias mco='muse checkout'
282 alias mlg='muse log'
283 alias mlgo='muse log --oneline'
284 alias mlgg='muse log --graph'
285 alias mdf='muse diff'
286 alias mdfst='muse diff --stat'
287 alias mbr='muse branch'
288 alias mtg='muse tag'
289 alias mfh='muse fetch'
290 alias mpull='muse pull'
291 alias mpush='muse push'
292 alias mrm='muse remote'
293
294 # ── §7 Completion ────────────────────────────────────────────────────────────
295 if [[ -f "${0:A:h}/_muse" ]]; then
296 fpath=("${0:A:h}" $fpath)
297 autoload -Uz compinit
298 compdef _muse muse 2>/dev/null
299 fi
300
301 # ── §8 Init ──────────────────────────────────────────────────────────────────
302 # Warm head + domain on load so the first prompt is not blank.
303 print "[init:1] plugin loading. PWD=$PWD" >&2
304 _muse_refresh
305 print "[init:2] plugin loaded. dirty=$MUSE_DIRTY branch=$MUSE_BRANCH" >&2