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