gabriel / muse public
muse.plugin.zsh
222 lines 8.4 KB
b48b8742 fix(omzsh-plugin): restore ref: prefix check regressed by #163 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 #
70 # Muse HEAD format (canonical, written by muse/core/store.py):
71 # ref: refs/heads/<branch> — on a branch (symbolic ref)
72 # commit: <sha256> — detached HEAD (direct commit reference)
73 function _muse_parse_head() {
74 local head_file="$MUSE_REPO_ROOT/.muse/HEAD"
75 if [[ ! -f "$head_file" ]]; then
76 MUSE_BRANCH=""; return 1
77 fi
78 local raw
79 raw=$(<"$head_file")
80 if [[ "$raw" == "ref: refs/heads/"* ]]; then
81 local branch="${raw#ref: refs/heads/}"
82 # Reject anything that could inject prompt escapes or path components.
83 if [[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]; then
84 MUSE_BRANCH="$branch"
85 else
86 MUSE_BRANCH="?"
87 fi
88 elif [[ "$raw" == "commit: "* ]]; then
89 local sha="${raw#commit: }"
90 MUSE_BRANCH="${sha:0:8}" # detached HEAD — show short SHA
91 else
92 MUSE_BRANCH="?"
93 fi
94 }
95
96 # Read domain from .muse/repo.json. One python3 call; path via env var only.
97 function _muse_parse_domain() {
98 local repo_json="$MUSE_REPO_ROOT/.muse/repo.json"
99 if [[ ! -f "$repo_json" ]]; then
100 MUSE_DOMAIN="midi"; return
101 fi
102 MUSE_DOMAIN=$(MUSE_REPO_JSON="$repo_json" python3 <<'PYEOF' 2>/dev/null
103 import json, os
104 try:
105 d = json.load(open(os.environ['MUSE_REPO_JSON']))
106 v = str(d.get('domain', 'midi'))
107 # Accept only safe domain names: alphanumeric plus hyphens/underscores,
108 # max 32 chars. Anything else falls back to 'midi'.
109 safe = v.replace('-', '').replace('_', '')
110 print(v if (safe.isalnum() and 1 <= len(v) <= 32) else 'midi')
111 except Exception:
112 print('midi')
113 PYEOF
114 )
115 : ${MUSE_DOMAIN:=midi}
116 }
117
118 # Check dirty state. Runs with timeout; only called after a muse command.
119 function _muse_check_dirty() {
120 local output rc count=0
121 output=$(cd -- "$MUSE_REPO_ROOT" && \
122 timeout -- "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null)
123 rc=$?
124 if (( rc == 124 )); then
125 # Timeout — leave previous dirty state in place rather than lying.
126 return
127 fi
128 while IFS= read -r line; do
129 [[ "$line" == "##"* || -z "$line" ]] && continue
130 (( count++ ))
131 done <<< "$output"
132 MUSE_DIRTY=$(( count > 0 ? 1 : 0 ))
133 MUSE_DIRTY_COUNT=$count
134 }
135
136 # ── §2 Cache management ──────────────────────────────────────────────────────
137
138 # Lightweight refresh: head + domain only. Called on directory change.
139 function _muse_refresh() {
140 if ! _muse_find_root; then
141 MUSE_DOMAIN="midi"; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
142 return 1
143 fi
144 _muse_parse_head
145 _muse_parse_domain
146 }
147
148 # Full refresh: head + domain + dirty. Called after a muse command.
149 function _muse_refresh_full() {
150 _muse_refresh || return
151 _muse_check_dirty
152 _MUSE_CMD_RAN=0
153 }
154
155 # ── §3 ZSH hooks ─────────────────────────────────────────────────────────────
156
157 # On directory change: refresh head and domain; clear dirty (stale after cd).
158 function _muse_hook_chpwd() {
159 MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0
160 _muse_refresh 2>/dev/null
161 }
162 chpwd_functions+=(_muse_hook_chpwd)
163
164 # Before a command: flag when the user runs muse so we refresh after.
165 function _muse_hook_preexec() {
166 [[ "${${(z)1}[1]}" == "muse" ]] && _MUSE_CMD_RAN=1
167 }
168 preexec_functions+=(_muse_hook_preexec)
169
170 # Before the prompt: full refresh only when a muse command just ran.
171 function _muse_hook_precmd() {
172 (( _MUSE_CMD_RAN )) && _muse_refresh_full 2>/dev/null
173 }
174 precmd_functions+=(_muse_hook_precmd)
175
176 # ── §4 Prompt ────────────────────────────────────────────────────────────────
177
178 # Primary prompt segment. Example usage in ~/.zshrc:
179 # PROMPT='%~ $(muse_prompt_info) %# '
180 # Emits nothing when not inside a muse repo.
181 function muse_prompt_info() {
182 [[ -z "$MUSE_REPO_ROOT" ]] && return
183
184 local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-${MUSE_DOMAIN_ICONS[_default]}}"
185 [[ "$MUSE_PROMPT_ICONS" == "0" ]] && icon="[$MUSE_DOMAIN]"
186
187 # Escape % so ZSH does not treat branch-name content as prompt directives.
188 local branch="${MUSE_BRANCH//\%/%%}"
189
190 local dirty=""
191 (( MUSE_DIRTY )) && dirty=" %F{red}✗ ${MUSE_DIRTY_COUNT}%f"
192
193 echo -n "%F{magenta}${icon} ${branch}%f${dirty}"
194 }
195
196 # ── §5 Aliases ───────────────────────────────────────────────────────────────
197 alias mst='muse status'
198 alias msts='muse status --short'
199 alias mcm='muse commit -m'
200 alias mco='muse checkout'
201 alias mlg='muse log'
202 alias mlgo='muse log --oneline'
203 alias mlgg='muse log --graph'
204 alias mdf='muse diff'
205 alias mdfst='muse diff --stat'
206 alias mbr='muse branch'
207 alias mtg='muse tag'
208 alias mfh='muse fetch'
209 alias mpull='muse pull'
210 alias mpush='muse push'
211 alias mrm='muse remote'
212
213 # ── §6 Completion ────────────────────────────────────────────────────────────
214 if [[ -f "${0:A:h}/_muse" ]]; then
215 fpath=("${0:A:h}" $fpath)
216 autoload -Uz compinit
217 compdef _muse muse 2>/dev/null
218 fi
219
220 # ── Init ──────────────────────────────────────────────────────────────────────
221 # Warm head + domain on load so the first prompt is not blank.
222 _muse_refresh 2>/dev/null