@mihairo/cmt 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/cmt +972 -0
- package/package.json +33 -0
- package/schema/cmt.schema.json +150 -0
package/cmt
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# cmt — Conventional Commits CLI
|
|
4
|
+
# Zero-dependency. Pure bash. Works wherever git works.
|
|
5
|
+
# https://www.conventionalcommits.org/en/v1.0.0/
|
|
6
|
+
# =============================================================================
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
CC_VERSION="1.0.0"
|
|
10
|
+
CC_CONFIG_FILE=".cmt.json"
|
|
11
|
+
CC_SCHEMA_URL="https://raw.githubusercontent.com/mihai-ro/cmt/main/schema/cmt.schema.json"
|
|
12
|
+
|
|
13
|
+
# colours
|
|
14
|
+
if [[ -z "${NO_COLOR:-}" ]] && ([[ -t 1 ]] || [[ -c /dev/tty ]]); then
|
|
15
|
+
RESET=$'\033[0m'
|
|
16
|
+
BOLD=$'\033[1m'
|
|
17
|
+
DIM=$'\033[2m'
|
|
18
|
+
ITALIC=$'\033[3m'
|
|
19
|
+
ACCENT=$'\033[38;2;86;182;194m' # cyan #56b6c2
|
|
20
|
+
ACCENT_BG=$'\033[48;2;86;182;194m'
|
|
21
|
+
ACCENT_BOLD=$'\033[1;38;2;86;182;194m'
|
|
22
|
+
GREEN=$'\033[38;2;152;195;121m' # green #98c379 — success
|
|
23
|
+
YELLOW=$'\033[38;2;229;192;123m' # yellow #e5c07b — warning / patch
|
|
24
|
+
RED=$'\033[38;2;224;108;117m' # red #e06c75 — error / breaking
|
|
25
|
+
BLUE=$'\033[38;2;97;175;239m' # blue #61afef — info
|
|
26
|
+
MUTED=$'\033[38;2;92;99;112m' # comment #5c6370 — secondary text
|
|
27
|
+
CYAN="$ACCENT"
|
|
28
|
+
else
|
|
29
|
+
RESET=''; BOLD=''; DIM=''; ITALIC=''
|
|
30
|
+
ACCENT=''; ACCENT_BG=''; ACCENT_BOLD=''
|
|
31
|
+
GREEN=''; YELLOW=''; RED=''; BLUE=''; MUTED=''
|
|
32
|
+
CYAN=''
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# helpers
|
|
36
|
+
info() { printf " ${BLUE}·${RESET} %s\n" "$*"; }
|
|
37
|
+
success() { printf " ${GREEN}✔${RESET} %s\n" "$*"; }
|
|
38
|
+
warn() { printf " ${YELLOW}⚑${RESET} ${YELLOW}%s${RESET}\n" "$*" >&2; }
|
|
39
|
+
error() { printf " ${RED}✖${RESET} %s\n" "$*" >&2; }
|
|
40
|
+
bold() { printf "${BOLD}%s${RESET}" "$*"; }
|
|
41
|
+
die() { error "$*"; exit 1; }
|
|
42
|
+
|
|
43
|
+
# Conventional Commits spec types
|
|
44
|
+
# format: "type|emoji|semver_impact|description"
|
|
45
|
+
BUILTIN_TYPES=(
|
|
46
|
+
"feat|✨|minor|A new feature"
|
|
47
|
+
"fix|🐛|patch|A bug fix"
|
|
48
|
+
"docs|📚|none|Documentation only changes"
|
|
49
|
+
"style|💅|none|Formatting, missing semi-colons, etc — no logic change"
|
|
50
|
+
"refactor|♻️ |none|Code change that neither fixes a bug nor adds a feature"
|
|
51
|
+
"perf|⚡|patch|A code change that improves performance"
|
|
52
|
+
"test|🧪|none|Adding or correcting tests"
|
|
53
|
+
"build|🏗️ |none|Changes to build system or external dependencies"
|
|
54
|
+
"ci|🔧|none|Changes to CI/CD configuration files and scripts"
|
|
55
|
+
"chore|🔩|none|Other changes that don't modify src or test files"
|
|
56
|
+
"revert|⏪|patch|Reverts a previous commit"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# config loader
|
|
60
|
+
load_config() {
|
|
61
|
+
local cfg="${CC_CONFIG_FILE}"
|
|
62
|
+
|
|
63
|
+
TYPES=("${BUILTIN_TYPES[@]}")
|
|
64
|
+
CUSTOM_SCOPES=()
|
|
65
|
+
|
|
66
|
+
# rule defaults (overridden by .cmt.json "rules" block)
|
|
67
|
+
RULE_MAX_HEADER=72
|
|
68
|
+
RULE_REQUIRE_SCOPE=0
|
|
69
|
+
RULE_DISALLOW_UPPER=0
|
|
70
|
+
RULE_DISALLOW_PERIOD=0
|
|
71
|
+
RULE_ALLOW_BREAKING=("feat" "fix")
|
|
72
|
+
|
|
73
|
+
[[ -f "$cfg" ]] || return 0
|
|
74
|
+
|
|
75
|
+
# Anchored key patterns in variables — [[ =~ var ]] treats the value as ERE.
|
|
76
|
+
# Must be unquoted on the RHS of =~ to get regex behaviour (not literal match).
|
|
77
|
+
local _p_ct='^[[:space:]]+"customTypes"[[:space:]]*:'
|
|
78
|
+
local _p_sc='^[[:space:]]+"scopes"[[:space:]]*:'
|
|
79
|
+
local _p_ab='^[[:space:]]+"allowBreakingChanges"[[:space:]]*:'
|
|
80
|
+
local _p_ru='^[[:space:]]+"rules"[[:space:]]*:'
|
|
81
|
+
local _p_kv='"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"'
|
|
82
|
+
|
|
83
|
+
# single-pass state machine
|
|
84
|
+
local section="" # current top-level context
|
|
85
|
+
local in_obj=0 # inside a customTypes item object
|
|
86
|
+
local collecting_arr="" # key name of a multi-line array being collected
|
|
87
|
+
local array_buf="" # accumulated multi-line array content
|
|
88
|
+
|
|
89
|
+
local type_val="" emoji_val="⚙️ " semver_val="none" desc_val="Custom type"
|
|
90
|
+
|
|
91
|
+
while IFS= read -r line; do
|
|
92
|
+
|
|
93
|
+
# ── top-level section detection — anchored regex, zero subprocesses ───
|
|
94
|
+
if [[ "$line" =~ $_p_ct ]]; then
|
|
95
|
+
# handle empty inline: "customTypes": []
|
|
96
|
+
[[ "$line" == *"[]"* ]] && continue
|
|
97
|
+
section="customTypes"; continue
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
if [[ "$line" =~ $_p_sc ]]; then
|
|
101
|
+
if [[ "$line" =~ \[.*\] ]]; then
|
|
102
|
+
# fully inline: "scopes": ["auth","api"]
|
|
103
|
+
local _s="${line#*[}"; _s="${_s%%]*}"
|
|
104
|
+
while [[ "$_s" =~ '"([^"]+)"' ]]; do
|
|
105
|
+
CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
|
|
106
|
+
_s="${_s#*"${BASH_REMATCH[0]}"}"
|
|
107
|
+
done
|
|
108
|
+
elif [[ "$line" == *"["* ]]; then
|
|
109
|
+
collecting_arr="scopes"; array_buf=""
|
|
110
|
+
fi
|
|
111
|
+
section=""; continue
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
if [[ "$line" =~ $_p_ab ]]; then
|
|
115
|
+
if [[ "$line" =~ \[.*\] ]]; then
|
|
116
|
+
local _s="${line#*[}"; _s="${_s%%]*}"
|
|
117
|
+
RULE_ALLOW_BREAKING=()
|
|
118
|
+
while [[ "$_s" =~ '"([^"]+)"' ]]; do
|
|
119
|
+
RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
|
|
120
|
+
_s="${_s#*"${BASH_REMATCH[0]}"}"
|
|
121
|
+
done
|
|
122
|
+
[[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
|
|
123
|
+
elif [[ "$line" == *"["* ]]; then
|
|
124
|
+
collecting_arr="allowBreakingChanges"; array_buf=""
|
|
125
|
+
RULE_ALLOW_BREAKING=()
|
|
126
|
+
fi
|
|
127
|
+
continue
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [[ "$line" =~ $_p_ru ]]; then
|
|
131
|
+
section="rules"; continue
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# ── multi-line array collector ─────────────────────────────────────────
|
|
135
|
+
if [[ -n "$collecting_arr" ]]; then
|
|
136
|
+
if [[ "$line" == *"]"* ]]; then
|
|
137
|
+
# closing bracket — flush accumulated buffer
|
|
138
|
+
array_buf+="${line%%]*}"
|
|
139
|
+
local _s="$array_buf"
|
|
140
|
+
case "$collecting_arr" in
|
|
141
|
+
scopes)
|
|
142
|
+
while [[ "$_s" =~ '"([^"]+)"' ]]; do
|
|
143
|
+
CUSTOM_SCOPES+=("${BASH_REMATCH[1]}")
|
|
144
|
+
_s="${_s#*"${BASH_REMATCH[0]}"}"
|
|
145
|
+
done
|
|
146
|
+
;;
|
|
147
|
+
allowBreakingChanges)
|
|
148
|
+
while [[ "$_s" =~ '"([^"]+)"' ]]; do
|
|
149
|
+
RULE_ALLOW_BREAKING+=("${BASH_REMATCH[1]}")
|
|
150
|
+
_s="${_s#*"${BASH_REMATCH[0]}"}"
|
|
151
|
+
done
|
|
152
|
+
[[ ${#RULE_ALLOW_BREAKING[@]} -eq 0 ]] && RULE_ALLOW_BREAKING=("feat" "fix")
|
|
153
|
+
;;
|
|
154
|
+
esac
|
|
155
|
+
collecting_arr=""; array_buf=""
|
|
156
|
+
else
|
|
157
|
+
array_buf+="$line"
|
|
158
|
+
fi
|
|
159
|
+
continue
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
# ── customTypes object items ───────────────────────────────────────────
|
|
163
|
+
if [[ "$section" == "customTypes" ]]; then
|
|
164
|
+
if [[ "$line" =~ ^[[:space:]]*\] ]]; then
|
|
165
|
+
section=""; in_obj=0; continue
|
|
166
|
+
fi
|
|
167
|
+
# inline object: { "type": "wip", "emoji": "🚧", ... } on one line
|
|
168
|
+
if [[ "$line" =~ ^[[:space:]]*\{.*\} ]]; then
|
|
169
|
+
local _it="" _ie="⚙️ " _is="none" _id="Custom type" _tmp="$line"
|
|
170
|
+
while [[ "$_tmp" =~ $_p_kv ]]; do
|
|
171
|
+
case "${BASH_REMATCH[1]}" in
|
|
172
|
+
type) _it="${BASH_REMATCH[2]}" ;;
|
|
173
|
+
emoji) _ie="${BASH_REMATCH[2]}" ;;
|
|
174
|
+
semver) _is="${BASH_REMATCH[2]}" ;;
|
|
175
|
+
description) _id="${BASH_REMATCH[2]}" ;;
|
|
176
|
+
esac
|
|
177
|
+
_tmp="${_tmp#*"${BASH_REMATCH[0]}"}"
|
|
178
|
+
done
|
|
179
|
+
[[ -n "$_it" ]] && TYPES+=("${_it}|${_ie}|${_is}|${_id}")
|
|
180
|
+
continue
|
|
181
|
+
fi
|
|
182
|
+
# multi-line object: { opens on its own line
|
|
183
|
+
if [[ "$line" =~ ^[[:space:]]*\{ ]]; then
|
|
184
|
+
in_obj=1
|
|
185
|
+
type_val=""; emoji_val="⚙️ "; semver_val="none"; desc_val="Custom type"
|
|
186
|
+
continue
|
|
187
|
+
fi
|
|
188
|
+
if [[ $in_obj -eq 1 ]]; then
|
|
189
|
+
if [[ "$line" =~ ^[[:space:]]*\} ]]; then
|
|
190
|
+
[[ -n "$type_val" ]] && TYPES+=("${type_val}|${emoji_val}|${semver_val}|${desc_val}")
|
|
191
|
+
in_obj=0; continue
|
|
192
|
+
fi
|
|
193
|
+
if [[ "$line" =~ $_p_kv ]]; then
|
|
194
|
+
case "${BASH_REMATCH[1]}" in
|
|
195
|
+
type) type_val="${BASH_REMATCH[2]}" ;;
|
|
196
|
+
emoji) emoji_val="${BASH_REMATCH[2]}" ;;
|
|
197
|
+
semver) semver_val="${BASH_REMATCH[2]}" ;;
|
|
198
|
+
description) desc_val="${BASH_REMATCH[2]}" ;;
|
|
199
|
+
esac
|
|
200
|
+
fi
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# ── rules scalar fields ────────────────────────────────────────────────
|
|
205
|
+
if [[ "$section" == "rules" ]]; then
|
|
206
|
+
if [[ "$line" =~ ^[[:space:]]*\} ]]; then
|
|
207
|
+
section=""; continue
|
|
208
|
+
fi
|
|
209
|
+
# match: "key": true|false|number — no subprocess needed
|
|
210
|
+
if [[ "$line" =~ '"([^"]+)"'[[:space:]]*:[[:space:]]*(true|false|[0-9]+) ]]; then
|
|
211
|
+
case "${BASH_REMATCH[1]}" in
|
|
212
|
+
maxHeaderLength) RULE_MAX_HEADER="${BASH_REMATCH[2]}" ;;
|
|
213
|
+
requireScope) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_REQUIRE_SCOPE=1 ;;
|
|
214
|
+
disallowUpperCaseDescription) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_DISALLOW_UPPER=1 ;;
|
|
215
|
+
disallowTrailingPeriod) [[ "${BASH_REMATCH[2]}" == "true" ]] && RULE_DISALLOW_PERIOD=1 ;;
|
|
216
|
+
esac
|
|
217
|
+
fi
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
done < "$cfg"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# type selection UI
|
|
224
|
+
# arrow-key picker
|
|
225
|
+
# Usage: _pick <result_var> <prompt> <item1> <item2> ...
|
|
226
|
+
# Ssts result_var to chosen index (0-based). Renders on /dev/tty.
|
|
227
|
+
_pick() {
|
|
228
|
+
local _var="$1" _prompt="$2"; shift 2
|
|
229
|
+
local -a _items=("$@")
|
|
230
|
+
local _n=${#_items[@]} _cur=0 _top=0 _view=7 _k1 _k2 _k3
|
|
231
|
+
|
|
232
|
+
exec 9<>/dev/tty
|
|
233
|
+
|
|
234
|
+
local _old
|
|
235
|
+
_old=$(stty -g <&9 2>/dev/null) || { exec 9>&-; _pick_fallback "$_var" "$_prompt" "${_items[@]}"; return; }
|
|
236
|
+
stty -echo -icanon min 1 time 0 <&9 2>/dev/null || { exec 9>&-; _pick_fallback "$_var" "$_prompt" "${_items[@]}"; return; }
|
|
237
|
+
|
|
238
|
+
trap "stty '$_old' <&9 2>/dev/null; printf '\033[?25h' >&9 2>/dev/null; exec 9>&-" EXIT INT TERM
|
|
239
|
+
|
|
240
|
+
_pick_move_up() {
|
|
241
|
+
_cur=$(( (_cur - 1 + _n) % _n ))
|
|
242
|
+
if [[ $_cur -eq $(( _n - 1 )) ]]; then
|
|
243
|
+
# wrapped from first to last — show last page
|
|
244
|
+
_top=$(( _n - _view ))
|
|
245
|
+
[[ $_top -lt 0 ]] && _top=0 || true
|
|
246
|
+
elif [[ $_cur -lt $_top ]]; then
|
|
247
|
+
_top=$(( _cur ))
|
|
248
|
+
fi
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_pick_move_down() {
|
|
252
|
+
_cur=$(( (_cur + 1) % _n ))
|
|
253
|
+
if [[ $_cur -ge $(( _top + _view )) ]]; then
|
|
254
|
+
_top=$(( _cur - _view + 1 ))
|
|
255
|
+
elif [[ $_cur -eq 0 ]]; then
|
|
256
|
+
# wrapped from bottom to top
|
|
257
|
+
_top=0
|
|
258
|
+
fi
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# draw the visible window of _view items starting at _top
|
|
262
|
+
_draw() {
|
|
263
|
+
local _i _end
|
|
264
|
+
_end=$(( _top + _view ))
|
|
265
|
+
[[ $_end -gt $_n ]] && _end=$_n
|
|
266
|
+
local _visible=$(( _end - _top ))
|
|
267
|
+
|
|
268
|
+
# scroll hint above
|
|
269
|
+
if [[ $_top -gt 0 ]]; then
|
|
270
|
+
printf " ${MUTED}↑ %d more${RESET}\033[K\n" "$_top" >&9
|
|
271
|
+
else
|
|
272
|
+
printf "\033[K\n" >&9
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
for (( _i=_top; _i<_end; _i++ )); do
|
|
276
|
+
if (( _i == _cur )); then
|
|
277
|
+
printf " ${ACCENT_BOLD}❯${RESET} %s${RESET}\033[K\n" "${_items[$_i]}" >&9
|
|
278
|
+
else
|
|
279
|
+
printf " ${MUTED}·${RESET} ${MUTED}%s${RESET}\033[K\n" "${_items[$_i]}" >&9
|
|
280
|
+
fi
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
# scroll hint below
|
|
284
|
+
local _remaining=$(( _n - _end ))
|
|
285
|
+
if [[ $_remaining -gt 0 ]]; then
|
|
286
|
+
printf " ${MUTED}↓ %d more${RESET}\033[K\n" "$_remaining" >&9
|
|
287
|
+
else
|
|
288
|
+
printf "\033[K\n" >&9
|
|
289
|
+
fi
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# total drawn lines = _view items + 2 hint lines — fixed for this pick session
|
|
293
|
+
local _dl=$(( _view < _n ? _view + 2 : _n + 2 ))
|
|
294
|
+
|
|
295
|
+
printf "\033[?25l" >&9
|
|
296
|
+
printf "\n ${ACCENT_BOLD}%s${RESET}\n\n" "$_prompt" >&9
|
|
297
|
+
_draw
|
|
298
|
+
|
|
299
|
+
while true; do
|
|
300
|
+
IFS= read -rsn1 _k1 <&9
|
|
301
|
+
case "$_k1" in
|
|
302
|
+
$'\033')
|
|
303
|
+
IFS= read -rsn1 -t1 _k2 <&9 || true
|
|
304
|
+
IFS= read -rsn1 -t1 _k3 <&9 || true
|
|
305
|
+
if [[ "$_k2" == "[" ]]; then
|
|
306
|
+
case "$_k3" in
|
|
307
|
+
A) _pick_move_up ;;
|
|
308
|
+
B) _pick_move_down ;;
|
|
309
|
+
esac
|
|
310
|
+
fi
|
|
311
|
+
;;
|
|
312
|
+
$'\r'|$'\n'|'') break ;;
|
|
313
|
+
k) _pick_move_up ;;
|
|
314
|
+
j) _pick_move_down ;;
|
|
315
|
+
q|$'\003') stty "$_old" <&9 2>/dev/null; trap - EXIT INT TERM; exec 9>&-; exit 1 ;;
|
|
316
|
+
esac
|
|
317
|
+
printf "\033[%dA" "$_dl" >&9
|
|
318
|
+
_draw
|
|
319
|
+
done
|
|
320
|
+
|
|
321
|
+
# collapse to just the selected line
|
|
322
|
+
printf "\033[%dA" "$_dl" >&9
|
|
323
|
+
printf "\033[J" >&9
|
|
324
|
+
printf " ${ACCENT_BOLD}❯${RESET} ${BOLD}%s${RESET}\n" "${_items[$_cur]}" >&9
|
|
325
|
+
|
|
326
|
+
stty "$_old" <&9 2>/dev/null
|
|
327
|
+
trap - EXIT INT TERM
|
|
328
|
+
printf "\033[?25h" >&9
|
|
329
|
+
exec 9>&-
|
|
330
|
+
|
|
331
|
+
printf -v "$_var" '%d' "$_cur"
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_pick_fallback() {
|
|
336
|
+
local _var="$1" _prompt="$2"; shift 2
|
|
337
|
+
local -a _items=("$@") _i=1 _choice
|
|
338
|
+
printf "\n\033[1m%s\033[0m\n\n" "$_prompt" >/dev/tty
|
|
339
|
+
for _item in "${_items[@]}"; do
|
|
340
|
+
printf " %2d) %s\n" "$_i" "$_item" >/dev/tty
|
|
341
|
+
_i=$(( _i + 1 ))
|
|
342
|
+
done
|
|
343
|
+
printf "\nChoice (1-%d): " "${#_items[@]}" >/dev/tty
|
|
344
|
+
read -r _choice </dev/tty
|
|
345
|
+
[[ "$_choice" =~ ^[0-9]+$ ]] && (( _choice >= 1 && _choice <= ${#_items[@]} )) \
|
|
346
|
+
&& printf -v "$_var" '%d' "$(( _choice - 1 ))" \
|
|
347
|
+
|| printf -v "$_var" '%d' 0
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# type selection
|
|
352
|
+
select_type() {
|
|
353
|
+
load_config
|
|
354
|
+
local -a labels keys
|
|
355
|
+
for entry in "${TYPES[@]}"; do
|
|
356
|
+
local t e s d impact
|
|
357
|
+
IFS='|' read -r t e s d <<< "$entry"
|
|
358
|
+
case "$s" in
|
|
359
|
+
minor) impact="${GREEN}minor${RESET}" ;;
|
|
360
|
+
patch) impact="${YELLOW}patch${RESET}" ;;
|
|
361
|
+
*) impact="" ;;
|
|
362
|
+
esac
|
|
363
|
+
# fixed 9-char visual slot for the badge so descriptions always align
|
|
364
|
+
local _badge
|
|
365
|
+
case "$s" in
|
|
366
|
+
minor) _badge=" ${MUTED}[${RESET}${GREEN}minor${RESET}${MUTED}]${RESET}" ;;
|
|
367
|
+
patch) _badge=" ${MUTED}[${RESET}${YELLOW}patch${RESET}${MUTED}]${RESET}" ;;
|
|
368
|
+
*) _badge=" " ;; # 9 spaces — same visual width as [minor]
|
|
369
|
+
esac
|
|
370
|
+
local _tp; printf -v _tp '%-10s' "$t"
|
|
371
|
+
labels+=("$e ${BOLD}${_tp}${RESET}${_badge} ${MUTED}$d${RESET}")
|
|
372
|
+
keys+=("$t")
|
|
373
|
+
done
|
|
374
|
+
local idx
|
|
375
|
+
_pick idx "Select commit type:" "${labels[@]}"
|
|
376
|
+
SELECTED_TYPE="${keys[$idx]}"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# scope selection
|
|
380
|
+
select_scope() {
|
|
381
|
+
if [[ ${#CUSTOM_SCOPES[@]} -gt 0 ]]; then
|
|
382
|
+
local -a labels
|
|
383
|
+
for _sc in "${CUSTOM_SCOPES[@]}"; do
|
|
384
|
+
labels+=("${BOLD}$_sc${RESET}")
|
|
385
|
+
done
|
|
386
|
+
labels+=("${MUTED}─ custom${RESET} ${MUTED}type your own${RESET}")
|
|
387
|
+
labels+=("${MUTED}─ skip${RESET}")
|
|
388
|
+
local idx
|
|
389
|
+
_pick idx "scope" "${labels[@]}"
|
|
390
|
+
local _custom_idx=$(( ${#CUSTOM_SCOPES[@]} ))
|
|
391
|
+
local _skip_idx=$(( ${#CUSTOM_SCOPES[@]} + 1 ))
|
|
392
|
+
if (( idx < _custom_idx )); then
|
|
393
|
+
# predefined scope chosen
|
|
394
|
+
SELECTED_SCOPE="${CUSTOM_SCOPES[$idx]}"
|
|
395
|
+
return 0
|
|
396
|
+
elif (( idx == _custom_idx )); then
|
|
397
|
+
# custom free-text
|
|
398
|
+
printf "\n ${ACCENT_BOLD}scope${RESET} ${MUTED}enter custom scope${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
399
|
+
read -r SELECTED_SCOPE </dev/tty
|
|
400
|
+
return 0
|
|
401
|
+
else
|
|
402
|
+
# skip
|
|
403
|
+
SELECTED_SCOPE=""
|
|
404
|
+
return 0
|
|
405
|
+
fi
|
|
406
|
+
fi
|
|
407
|
+
# no configured scopes — free-text input
|
|
408
|
+
printf "\n ${ACCENT_BOLD}scope${RESET} ${MUTED}leave blank to omit${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
409
|
+
read -r SELECTED_SCOPE </dev/tty
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
# description input
|
|
413
|
+
input_description() {
|
|
414
|
+
printf "\n ${ACCENT_BOLD}description${RESET} ${MUTED}imperative, present tense${RESET}\n" >/dev/tty
|
|
415
|
+
local desc
|
|
416
|
+
while true; do
|
|
417
|
+
printf " ${ACCENT}›${RESET} " >/dev/tty
|
|
418
|
+
read -r desc </dev/tty
|
|
419
|
+
if [[ -z "$desc" ]]; then
|
|
420
|
+
warn "Description is required"
|
|
421
|
+
elif (( ${#desc} > 100 )); then
|
|
422
|
+
warn "Description is ${#desc} chars — keep it under 72 for readability"
|
|
423
|
+
printf " ${YELLOW}use anyway?${RESET} ${MUTED}[y/N]${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
424
|
+
read -r confirm </dev/tty
|
|
425
|
+
[[ "$confirm" =~ ^[yY]$ ]] && { SELECTED_DESC="$desc"; return 0; }
|
|
426
|
+
else
|
|
427
|
+
SELECTED_DESC="$desc"
|
|
428
|
+
return 0
|
|
429
|
+
fi
|
|
430
|
+
done
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
# body input
|
|
434
|
+
input_body() {
|
|
435
|
+
printf "\n ${ACCENT_BOLD}body${RESET} ${MUTED}optional — press Enter to skip${RESET}\n" >/dev/tty
|
|
436
|
+
printf " ${ACCENT}›${RESET} " >/dev/tty
|
|
437
|
+
local line
|
|
438
|
+
read -r line </dev/tty
|
|
439
|
+
SELECTED_BODY="$line"
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
# breaking change
|
|
443
|
+
input_breaking() {
|
|
444
|
+
printf "\n ${ACCENT_BOLD}breaking change?${RESET} ${MUTED}[y/N]${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
445
|
+
read -r ans </dev/tty
|
|
446
|
+
if [[ "$ans" =~ ^[yY]$ ]]; then
|
|
447
|
+
IS_BREAKING=1
|
|
448
|
+
printf " ${RED}breaking change${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
449
|
+
read -r BREAKING_DESC </dev/tty
|
|
450
|
+
else
|
|
451
|
+
IS_BREAKING=0
|
|
452
|
+
BREAKING_DESC=""
|
|
453
|
+
fi
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# footer (issue refs)
|
|
457
|
+
input_footer() {
|
|
458
|
+
printf "\n ${ACCENT_BOLD}footer${RESET} ${MUTED}e.g. Closes #42 — leave blank to skip${RESET}\n" >/dev/tty
|
|
459
|
+
printf " ${ACCENT}›${RESET} " >/dev/tty
|
|
460
|
+
read -r SELECTED_FOOTER </dev/tty
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# assemble commit message
|
|
464
|
+
assemble_message() {
|
|
465
|
+
local header
|
|
466
|
+
if [[ -n "$SELECTED_SCOPE" ]]; then
|
|
467
|
+
header="${SELECTED_TYPE}(${SELECTED_SCOPE})"
|
|
468
|
+
else
|
|
469
|
+
header="${SELECTED_TYPE}"
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
if [[ $IS_BREAKING -eq 1 ]]; then
|
|
473
|
+
header="${header}!"
|
|
474
|
+
fi
|
|
475
|
+
|
|
476
|
+
header="${header}: ${SELECTED_DESC}"
|
|
477
|
+
|
|
478
|
+
local msg="$header"
|
|
479
|
+
|
|
480
|
+
if [[ -n "$SELECTED_BODY" ]]; then
|
|
481
|
+
msg="${msg}"$'\n\n'"${SELECTED_BODY}"
|
|
482
|
+
fi
|
|
483
|
+
|
|
484
|
+
if [[ $IS_BREAKING -eq 1 && -n "$BREAKING_DESC" ]]; then
|
|
485
|
+
msg="${msg}"$'\n\n'"BREAKING CHANGE: ${BREAKING_DESC}"
|
|
486
|
+
fi
|
|
487
|
+
|
|
488
|
+
if [[ -n "$SELECTED_FOOTER" ]]; then
|
|
489
|
+
msg="${msg}"$'\n\n'"${SELECTED_FOOTER}"
|
|
490
|
+
fi
|
|
491
|
+
|
|
492
|
+
COMMIT_MESSAGE="$msg"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
# confirm and commit
|
|
496
|
+
confirm_and_commit() {
|
|
497
|
+
printf "\n ${MUTED}╭─ commit message ──────────────────────────────╮${RESET}\n" >/dev/tty
|
|
498
|
+
while IFS= read -r _line; do
|
|
499
|
+
printf " ${MUTED}│${RESET} ${BOLD}%s${RESET}\n" "$_line" >/dev/tty
|
|
500
|
+
done <<< "$COMMIT_MESSAGE"
|
|
501
|
+
printf " ${MUTED}╰───────────────────────────────────────────────╯${RESET}\n" >/dev/tty
|
|
502
|
+
|
|
503
|
+
printf "\n ${ACCENT_BOLD}commit?${RESET} ${MUTED}[Y/n/e]${RESET} ${ACCENT}›${RESET} " >/dev/tty
|
|
504
|
+
read -r ans </dev/tty
|
|
505
|
+
|
|
506
|
+
case "$ans" in
|
|
507
|
+
""|y|Y|yes|Yes|YES)
|
|
508
|
+
git commit -m "$COMMIT_MESSAGE"
|
|
509
|
+
printf "\n"; success "${BOLD}committed${RESET}"
|
|
510
|
+
;;
|
|
511
|
+
e|E|edit|Edit|EDIT)
|
|
512
|
+
local tmpfile
|
|
513
|
+
tmpfile=$(mktemp /tmp/cmt-commit-XXXXXX)
|
|
514
|
+
echo "$COMMIT_MESSAGE" > "$tmpfile"
|
|
515
|
+
"${EDITOR:-vi}" "$tmpfile"
|
|
516
|
+
COMMIT_MESSAGE=$(cat "$tmpfile")
|
|
517
|
+
rm -f "$tmpfile"
|
|
518
|
+
git commit -m "$COMMIT_MESSAGE"
|
|
519
|
+
printf "\n"; success "${BOLD}committed${RESET}"
|
|
520
|
+
;;
|
|
521
|
+
*)
|
|
522
|
+
warn "Commit aborted."
|
|
523
|
+
exit 0
|
|
524
|
+
;;
|
|
525
|
+
esac
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
# cmd: commit
|
|
529
|
+
cmd_commit() {
|
|
530
|
+
# --write-only: used by prepare-commit-msg hook — run picker, print message to
|
|
531
|
+
# stdout, do NOT call git commit (git will do that after the hook returns).
|
|
532
|
+
local write_only=0
|
|
533
|
+
[[ "${1:-}" == "--write-only" ]] && write_only=1
|
|
534
|
+
|
|
535
|
+
if [[ $write_only -eq 0 ]]; then
|
|
536
|
+
git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
|
|
537
|
+
if git diff --cached --quiet 2>/dev/null; then
|
|
538
|
+
warn "No staged changes detected. Did you forget \`git add\`?"
|
|
539
|
+
printf "${DIM}Continue anyway? [y/N]:${RESET} " >/dev/tty
|
|
540
|
+
read -r cont </dev/tty
|
|
541
|
+
[[ "$cont" =~ ^[yY]$ ]] || exit 0
|
|
542
|
+
fi
|
|
543
|
+
fi
|
|
544
|
+
|
|
545
|
+
printf "\n ${ACCENT_BOLD}cmt${RESET} ${MUTED}conventional commits v%s${RESET}\n" "$CC_VERSION" >/dev/tty
|
|
546
|
+
printf " ${MUTED}─────────────────────────────────────${RESET}\n" >/dev/tty
|
|
547
|
+
|
|
548
|
+
SELECTED_TYPE="" SELECTED_SCOPE="" SELECTED_DESC=""
|
|
549
|
+
SELECTED_BODY="" SELECTED_FOOTER="" IS_BREAKING=0 BREAKING_DESC=""
|
|
550
|
+
|
|
551
|
+
select_type
|
|
552
|
+
select_scope
|
|
553
|
+
input_description
|
|
554
|
+
input_body
|
|
555
|
+
input_breaking
|
|
556
|
+
input_footer
|
|
557
|
+
assemble_message
|
|
558
|
+
|
|
559
|
+
if [[ $write_only -eq 1 ]]; then
|
|
560
|
+
# show preview on /dev/tty (git won't capture it — only stdout goes to the msg file)
|
|
561
|
+
printf "\n ${MUTED}╭─ commit message ──────────────────────────────╮${RESET}\n" >/dev/tty
|
|
562
|
+
while IFS= read -r _line; do
|
|
563
|
+
printf " ${MUTED}│${RESET} ${BOLD}%s${RESET}\n" "$_line" >/dev/tty
|
|
564
|
+
done <<< "$COMMIT_MESSAGE"
|
|
565
|
+
printf " ${MUTED}╰───────────────────────────────────────────────╯${RESET}\n\n" >/dev/tty
|
|
566
|
+
printf '%s' "$COMMIT_MESSAGE"
|
|
567
|
+
return 0
|
|
568
|
+
fi
|
|
569
|
+
|
|
570
|
+
confirm_and_commit
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
# cmd: lint
|
|
574
|
+
cmd_lint() {
|
|
575
|
+
local msg_file="${1:-}"
|
|
576
|
+
local msg
|
|
577
|
+
|
|
578
|
+
if [[ -n "$msg_file" ]]; then
|
|
579
|
+
[[ -f "$msg_file" ]] || die "File not found: $msg_file"
|
|
580
|
+
msg=$(cat "$msg_file")
|
|
581
|
+
else
|
|
582
|
+
printf "${BOLD}Paste commit message to lint:${RESET}\n"
|
|
583
|
+
msg=$(cat)
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
lint_message "$msg"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
lint_message() {
|
|
590
|
+
local msg="$1"
|
|
591
|
+
# extract first line — pure bash, no subprocess
|
|
592
|
+
local header="${msg%%$'\n'*}"
|
|
593
|
+
|
|
594
|
+
local errors=() warnings=()
|
|
595
|
+
load_config
|
|
596
|
+
|
|
597
|
+
# rule #1: header format
|
|
598
|
+
local _p_hdr='^[a-z][a-z0-9_-]*(\([^)]*\))?!?:[[:space:]].+'
|
|
599
|
+
if ! [[ "$header" =~ $_p_hdr ]]; then
|
|
600
|
+
errors+=("Header must match: type(scope)?: description")
|
|
601
|
+
else
|
|
602
|
+
# rule #2: valid type — extract via BASH_REMATCH from a fresh match
|
|
603
|
+
local _p_type='^([a-z][a-z0-9_-]*)'
|
|
604
|
+
local commit_type=""
|
|
605
|
+
[[ "$header" =~ $_p_type ]] && commit_type="${BASH_REMATCH[1]}"
|
|
606
|
+
local valid_type=0
|
|
607
|
+
for entry in "${TYPES[@]}"; do
|
|
608
|
+
local t; t="${entry%%|*}"
|
|
609
|
+
[[ "$t" == "$commit_type" ]] && { valid_type=1; break; }
|
|
610
|
+
done
|
|
611
|
+
[[ $valid_type -eq 0 ]] && errors+=("Unknown type '${commit_type}'. Valid: $(IFS=, ; echo "${TYPES[*]%%|*}")")
|
|
612
|
+
|
|
613
|
+
# rule #3: description not empty — strip type(scope)!: prefix via parameter expansion
|
|
614
|
+
local desc="${header#*: }"
|
|
615
|
+
[[ -z "$desc" ]] && errors+=("Description must not be empty")
|
|
616
|
+
|
|
617
|
+
# rule #4: description not capitalized
|
|
618
|
+
if [[ "${desc:0:1}" =~ [A-Z] ]]; then
|
|
619
|
+
if [[ $RULE_DISALLOW_UPPER -eq 1 ]]; then
|
|
620
|
+
errors+=("Description must start with a lowercase letter")
|
|
621
|
+
else
|
|
622
|
+
warnings+=("Description starts with uppercase — prefer lowercase")
|
|
623
|
+
fi
|
|
624
|
+
fi
|
|
625
|
+
|
|
626
|
+
# rule #5: no trailing period
|
|
627
|
+
if [[ "$desc" =~ \.$ ]]; then
|
|
628
|
+
if [[ $RULE_DISALLOW_PERIOD -eq 1 ]]; then
|
|
629
|
+
errors+=("Description must not end with a period")
|
|
630
|
+
else
|
|
631
|
+
warnings+=("Description ends with a period — omit it")
|
|
632
|
+
fi
|
|
633
|
+
fi
|
|
634
|
+
|
|
635
|
+
# rule #6: header length
|
|
636
|
+
(( ${#header} > RULE_MAX_HEADER )) && warnings+=("Header is ${#header} chars — aim for ≤${RULE_MAX_HEADER}")
|
|
637
|
+
|
|
638
|
+
# rule #7: requireScope
|
|
639
|
+
if [[ $RULE_REQUIRE_SCOPE -eq 1 ]]; then
|
|
640
|
+
local _p_scope='^[a-z][a-z0-9_-]*\([^)]+\)'
|
|
641
|
+
[[ "$header" =~ $_p_scope ]] || errors+=("Scope is required — e.g. feat(auth): ...")
|
|
642
|
+
fi
|
|
643
|
+
fi
|
|
644
|
+
|
|
645
|
+
# rule #8: blank line between header and body — pure bash string ops
|
|
646
|
+
if [[ "$msg" == *$'\n'* ]]; then
|
|
647
|
+
local _rest="${msg#*$'\n'}"
|
|
648
|
+
local _second="${_rest%%$'\n'*}"
|
|
649
|
+
[[ -n "$_second" ]] && errors+=("Blank line required between header and body")
|
|
650
|
+
fi
|
|
651
|
+
|
|
652
|
+
# report
|
|
653
|
+
local exit_code=0
|
|
654
|
+
printf "\n ${ACCENT_BOLD}lint${RESET} ${MUTED}%s${RESET}\n\n" "$header"
|
|
655
|
+
|
|
656
|
+
if [[ ${#errors[@]} -gt 0 ]]; then
|
|
657
|
+
for e in "${errors[@]}"; do
|
|
658
|
+
printf " ${RED}✖${RESET} %s\n" "$e"
|
|
659
|
+
done
|
|
660
|
+
exit_code=1
|
|
661
|
+
fi
|
|
662
|
+
|
|
663
|
+
if [[ ${#warnings[@]} -gt 0 ]]; then
|
|
664
|
+
for w in "${warnings[@]}"; do
|
|
665
|
+
printf " ${YELLOW}⚑${RESET} ${YELLOW}%s${RESET}\n" "$w"
|
|
666
|
+
done
|
|
667
|
+
fi
|
|
668
|
+
|
|
669
|
+
if [[ ${#errors[@]} -eq 0 && ${#warnings[@]} -eq 0 ]]; then
|
|
670
|
+
success "valid conventional commit"
|
|
671
|
+
elif [[ ${#errors[@]} -eq 0 ]]; then
|
|
672
|
+
printf "\n"; success "valid — with ${#warnings[@]} suggestion(s)"
|
|
673
|
+
fi
|
|
674
|
+
|
|
675
|
+
printf "\n"
|
|
676
|
+
return $exit_code
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
# hook snippet
|
|
680
|
+
# The only hook cmt installs is prepare-commit-msg.
|
|
681
|
+
# It intercepts plain `git commit`, runs the picker, writes the message.
|
|
682
|
+
# commit-msg is not needed — the message is already conventional when we write it.
|
|
683
|
+
_hook_snippet() {
|
|
684
|
+
local _cmt_path="$1"
|
|
685
|
+
# variables prefixed with \ are meant for the hook at runtime (not expanded now).
|
|
686
|
+
# _cmt_path IS expanded now; it records the install-time absolute path.
|
|
687
|
+
cat << SNIPPET
|
|
688
|
+
# >>> cmt — Conventional Commits CLI
|
|
689
|
+
[ -n "\${2:-}" ] && exit 0
|
|
690
|
+
for _p in "${_cmt_path}" "\$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
|
|
691
|
+
[ -x "\$_p" ] && { "\$_p" commit --write-only > "\$1"; exit 0; }
|
|
692
|
+
done
|
|
693
|
+
# <<< cmt
|
|
694
|
+
SNIPPET
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
# lint snippet (commit-msg hook)
|
|
698
|
+
_lint_snippet() {
|
|
699
|
+
local _cmt_path="$1"
|
|
700
|
+
cat << SNIPPET
|
|
701
|
+
# >>> cmt-lint — Conventional Commits CLI
|
|
702
|
+
for _p in "${_cmt_path}" "\$HOME/.local/bin/cmt" "/usr/local/bin/cmt" "/opt/homebrew/bin/cmt"; do
|
|
703
|
+
[ -x "\$_p" ] && { "\$_p" lint "\$1"; exit \$?; }
|
|
704
|
+
done
|
|
705
|
+
# <<< cmt-lint
|
|
706
|
+
SNIPPET
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
# install or append cmt block to a hook file
|
|
710
|
+
# usage: _install_hook <path> <needs_shebang> <cmt_path> [snippet_fn]
|
|
711
|
+
_install_hook() {
|
|
712
|
+
local path="$1" shebang="${2:-1}" cc_path="$3" snippet_fn="${4:-_hook_snippet}"
|
|
713
|
+
local marker; [[ "$snippet_fn" == "_lint_snippet" ]] && marker="cmt-lint" || marker="cmt"
|
|
714
|
+
|
|
715
|
+
if [[ -f "$path" ]]; then
|
|
716
|
+
if grep -q ">>> ${marker}" "$path" 2>/dev/null; then
|
|
717
|
+
local tmp; tmp=$(mktemp)
|
|
718
|
+
awk "/# >>> ${marker}/{skip=1} !skip{print} /# <<< ${marker}/{skip=0}" "$path" > "$tmp"
|
|
719
|
+
"$snippet_fn" "$cc_path" >> "$tmp"
|
|
720
|
+
mv "$tmp" "$path"
|
|
721
|
+
success "Updated ${marker} block in $(basename "$(dirname "$path")")/$(basename "$path")"
|
|
722
|
+
else
|
|
723
|
+
"$snippet_fn" "$cc_path" >> "$path"
|
|
724
|
+
success "Appended to $(basename "$(dirname "$path")")/$(basename "$path")"
|
|
725
|
+
fi
|
|
726
|
+
else
|
|
727
|
+
[[ "$shebang" == "1" ]] && printf '#!/usr/bin/env bash\n' > "$path"
|
|
728
|
+
"$snippet_fn" "$cc_path" >> "$path"
|
|
729
|
+
chmod +x "$path"
|
|
730
|
+
success "Created $(basename "$(dirname "$path")")/$(basename "$path")"
|
|
731
|
+
fi
|
|
732
|
+
chmod +x "$path"
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
# cmd: log
|
|
736
|
+
cmd_log() {
|
|
737
|
+
git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
|
|
738
|
+
load_config
|
|
739
|
+
|
|
740
|
+
local limit="${1:-20}"
|
|
741
|
+
printf "\n ${ACCENT_BOLD}log${RESET} ${MUTED}last %s commits${RESET}\n\n" "$limit"
|
|
742
|
+
|
|
743
|
+
local _p_log_type='^([a-z][a-z0-9_-]*)[(:!]'
|
|
744
|
+
while IFS='|' read -r hash short_hash date author subject; do
|
|
745
|
+
local type="" emoji color
|
|
746
|
+
[[ "$subject" =~ $_p_log_type ]] && type="${BASH_REMATCH[1]}"
|
|
747
|
+
emoji="·"; color="$RESET"
|
|
748
|
+
|
|
749
|
+
if [[ -n "$type" ]]; then
|
|
750
|
+
for entry in "${TYPES[@]}"; do
|
|
751
|
+
local t e
|
|
752
|
+
IFS='|' read -r t e _ _ <<< "$entry"
|
|
753
|
+
if [[ "$t" == "$type" ]]; then emoji="$e"; break; fi
|
|
754
|
+
done
|
|
755
|
+
case "$type" in
|
|
756
|
+
feat) color="$GREEN" ;;
|
|
757
|
+
fix|perf) color="$YELLOW" ;;
|
|
758
|
+
revert) color="$RED" ;;
|
|
759
|
+
*) color="$MUTED" ;;
|
|
760
|
+
esac
|
|
761
|
+
fi
|
|
762
|
+
|
|
763
|
+
local breaking=""
|
|
764
|
+
[[ "$subject" == *"!:"* ]] && breaking=" ${RED}breaking${RESET}"
|
|
765
|
+
|
|
766
|
+
printf " ${MUTED}%s${RESET} %s ${color}${BOLD}%s${RESET}%s\n" "$short_hash" "$emoji" "$subject" "$breaking"
|
|
767
|
+
printf " ${MUTED}%s %s${RESET}\n\n" "$date" "$author"
|
|
768
|
+
|
|
769
|
+
done < <(git log -"$limit" --format="%H|%h|%ad|%an|%s" --date=short 2>/dev/null)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
# cmd: types
|
|
773
|
+
cmd_types() {
|
|
774
|
+
load_config
|
|
775
|
+
printf "\n ${ACCENT_BOLD}commit types${RESET}\n\n"
|
|
776
|
+
for entry in "${TYPES[@]}"; do
|
|
777
|
+
local t e s d badge
|
|
778
|
+
IFS='|' read -r t e s d <<< "$entry"
|
|
779
|
+
badge=""
|
|
780
|
+
case "$s" in
|
|
781
|
+
minor) badge=" ${GREEN}minor${RESET}" ;;
|
|
782
|
+
patch) badge=" ${YELLOW}patch${RESET}" ;;
|
|
783
|
+
esac
|
|
784
|
+
printf " %s ${BOLD}%-12s${RESET}${badge} ${MUTED}%s${RESET}\n" "$e" "$t" "$d"
|
|
785
|
+
done
|
|
786
|
+
printf "\n"
|
|
787
|
+
if [[ -f "$CC_CONFIG_FILE" ]]; then
|
|
788
|
+
printf " ${MUTED}custom types from .cmt.json included above${RESET}\n\n"
|
|
789
|
+
else
|
|
790
|
+
printf " ${MUTED}run cmt init to configure custom types${RESET}\n\n"
|
|
791
|
+
fi
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# cmd: init
|
|
796
|
+
cmd_init() {
|
|
797
|
+
git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
|
|
798
|
+
|
|
799
|
+
local git_root cc_path
|
|
800
|
+
git_root=$(git rev-parse --show-toplevel)
|
|
801
|
+
cc_path=$(realpath "$0")
|
|
802
|
+
|
|
803
|
+
# .cmt.json
|
|
804
|
+
if [[ ! -f "${git_root}/${CC_CONFIG_FILE}" ]]; then
|
|
805
|
+
cat > "${git_root}/${CC_CONFIG_FILE}" << JSONEOF
|
|
806
|
+
{
|
|
807
|
+
"\$schema": "${CC_SCHEMA_URL}",
|
|
808
|
+
"version": "1.0.0",
|
|
809
|
+
"customTypes": [
|
|
810
|
+
{ "type": "wip", "emoji": "🚧", "semver": "none", "description": "Work in progress" }
|
|
811
|
+
],
|
|
812
|
+
"scopes": [],
|
|
813
|
+
"rules": {
|
|
814
|
+
"maxHeaderLength": 72,
|
|
815
|
+
"requireScope": false,
|
|
816
|
+
"allowBreakingChanges": ["feat", "fix"],
|
|
817
|
+
"disallowUpperCaseDescription": false,
|
|
818
|
+
"disallowTrailingPeriod": false
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
JSONEOF
|
|
822
|
+
success "Created .cmt.json"
|
|
823
|
+
fi
|
|
824
|
+
|
|
825
|
+
local use_husky=0 use_lint=0
|
|
826
|
+
for arg in "$@"; do
|
|
827
|
+
[[ "$arg" == "--husky" ]] && use_husky=1 || true
|
|
828
|
+
[[ "$arg" == "--lint" ]] && use_lint=1 || true
|
|
829
|
+
done
|
|
830
|
+
|
|
831
|
+
if [[ $use_husky -eq 1 ]]; then
|
|
832
|
+
mkdir -p "${git_root}/.husky"
|
|
833
|
+
_install_hook "${git_root}/.husky/prepare-commit-msg" 0 "$cc_path"
|
|
834
|
+
if [[ $use_lint -eq 1 ]]; then
|
|
835
|
+
_install_hook "${git_root}/.husky/commit-msg" 0 "$cc_path" _lint_snippet
|
|
836
|
+
fi
|
|
837
|
+
printf " ${DIM}Commit .husky/ hooks to share with your team.${RESET}\n"
|
|
838
|
+
else
|
|
839
|
+
_install_hook "${git_root}/.git/hooks/prepare-commit-msg" 1 "$cc_path"
|
|
840
|
+
if [[ $use_lint -eq 1 ]]; then
|
|
841
|
+
_install_hook "${git_root}/.git/hooks/commit-msg" 1 "$cc_path" _lint_snippet
|
|
842
|
+
fi
|
|
843
|
+
fi
|
|
844
|
+
|
|
845
|
+
printf "\n"
|
|
846
|
+
success "Done."
|
|
847
|
+
printf " ${DIM}git commit or cmt commit — both work${RESET}\n\n"
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
# cmd: uninstall
|
|
851
|
+
cmd_uninstall() {
|
|
852
|
+
git rev-parse --git-dir > /dev/null 2>&1 || die "Not inside a git repository"
|
|
853
|
+
local git_root; git_root=$(git rev-parse --show-toplevel)
|
|
854
|
+
local removed=0
|
|
855
|
+
|
|
856
|
+
# remove a named cmt block from a hook file.
|
|
857
|
+
# deletes the file entirely if the block was the only content.
|
|
858
|
+
_remove_block() {
|
|
859
|
+
local path="$1" marker="$2"
|
|
860
|
+
[[ -f "$path" ]] || return 0
|
|
861
|
+
grep -q "# >>> ${marker} " "$path" 2>/dev/null || return 0
|
|
862
|
+
|
|
863
|
+
local tmp
|
|
864
|
+
tmp=$(mktemp)
|
|
865
|
+
# match opening line as prefix ("# >>> cmt " catches "cmt" but not "cmt-lint")
|
|
866
|
+
awk "/^# >>> ${marker} /{skip=1} !skip{print} /^# <<< ${marker}\$/{skip=0}" "$path" > "$tmp"
|
|
867
|
+
|
|
868
|
+
local has_content
|
|
869
|
+
has_content=$(grep -v '^#!' "$tmp" | grep -v '^#' | grep -cv '^[[:space:]]*$' || true)
|
|
870
|
+
if [[ "$has_content" -eq 0 ]]; then
|
|
871
|
+
rm "$path"
|
|
872
|
+
success "Removed $(basename "$(dirname "$path")")/$(basename "$path")"
|
|
873
|
+
else
|
|
874
|
+
mv "$tmp" "$path"
|
|
875
|
+
success "Removed ${marker} block from $(basename "$(dirname "$path")")/$(basename "$path")"
|
|
876
|
+
fi
|
|
877
|
+
rm -f "$tmp"
|
|
878
|
+
removed=1
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
for dir in "${git_root}/.git/hooks" "${git_root}/.husky"; do
|
|
882
|
+
_remove_block "${dir}/prepare-commit-msg" "cmt"
|
|
883
|
+
_remove_block "${dir}/commit-msg" "cmt-lint"
|
|
884
|
+
done
|
|
885
|
+
|
|
886
|
+
# clean up legacy commit-msg hooks from older cmt versions
|
|
887
|
+
for path in "${git_root}/.git/hooks/commit-msg" "${git_root}/.husky/commit-msg"; do
|
|
888
|
+
if [[ -f "$path" ]] && grep -q "Installed by cmt" "$path" 2>/dev/null; then
|
|
889
|
+
rm "$path"
|
|
890
|
+
success "Removed legacy $(basename "$path")"
|
|
891
|
+
removed=1
|
|
892
|
+
fi
|
|
893
|
+
done
|
|
894
|
+
|
|
895
|
+
# remove .cmt.json config
|
|
896
|
+
if [[ -f "${git_root}/${CC_CONFIG_FILE}" ]]; then
|
|
897
|
+
rm "${git_root}/${CC_CONFIG_FILE}"
|
|
898
|
+
success "Removed .cmt.json"
|
|
899
|
+
removed=1
|
|
900
|
+
fi
|
|
901
|
+
|
|
902
|
+
[[ $removed -eq 0 ]] && info "Nothing to remove."
|
|
903
|
+
printf "\n"
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# cmd: version
|
|
908
|
+
cmd_version() {
|
|
909
|
+
printf "cmt version %s\n" "$CC_VERSION"
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
# cmd: help
|
|
913
|
+
cmd_help() {
|
|
914
|
+
printf "
|
|
915
|
+
${BOLD}${CYAN}cmt${RESET} v${CC_VERSION} — Conventional Commits CLI
|
|
916
|
+
|
|
917
|
+
${BOLD}USAGE${RESET}
|
|
918
|
+
cmt <command> [options]
|
|
919
|
+
|
|
920
|
+
${BOLD}COMMANDS${RESET}
|
|
921
|
+
${CYAN}init${RESET} [--husky] [--lint] Create .cmt.json + install prepare-commit-msg hook
|
|
922
|
+
--husky write to .husky/ (husky v9, committable)
|
|
923
|
+
--lint also install commit-msg linting hook
|
|
924
|
+
default write to .git/hooks/ (local)
|
|
925
|
+
|
|
926
|
+
${CYAN}commit${RESET} Interactive commit builder
|
|
927
|
+
|
|
928
|
+
${CYAN}lint${RESET} [file] Lint a commit message file or stdin
|
|
929
|
+
|
|
930
|
+
${CYAN}log${RESET} [n] Pretty log — last n commits (default: 20)
|
|
931
|
+
|
|
932
|
+
${CYAN}types${RESET} List available types
|
|
933
|
+
|
|
934
|
+
${CYAN}uninstall${RESET} Remove cmt-managed hooks
|
|
935
|
+
|
|
936
|
+
${BOLD}EXAMPLES${RESET}
|
|
937
|
+
cmt init # set up repo — done in one step
|
|
938
|
+
cmt init --husky # same, but writes to .husky/ for husky v9
|
|
939
|
+
cmt commit # guided commit
|
|
940
|
+
echo 'fix: typo' | cmt lint
|
|
941
|
+
git log --format=%s | cmt lint # lint every commit on a branch
|
|
942
|
+
|
|
943
|
+
${BOLD}INTEGRATIONS${RESET}
|
|
944
|
+
Husky v9 cmt init --husky → .husky/prepare-commit-msg (commit it, team shares it)
|
|
945
|
+
lint-staged add to .husky/pre-commit: npx lint-staged
|
|
946
|
+
GitHub CI echo \"\$(git log -1 --format=%s)\" | cmt lint
|
|
947
|
+
|
|
948
|
+
${BOLD}CONFIG${RESET} .cmt.json (JSON Schema → intellisense in VS Code / JetBrains)
|
|
949
|
+
{
|
|
950
|
+
\"customTypes\": [{ \"type\": \"wip\", \"emoji\": \"🚧\", \"semver\": \"none\", \"description\": \"...\" }],
|
|
951
|
+
\"scopes\": [\"auth\", \"api\", \"ui\"],
|
|
952
|
+
\"rules\": { \"maxHeaderLength\": 72, \"requireScope\": false }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
"
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
# dispatch
|
|
959
|
+
main() {
|
|
960
|
+
case "${1:-help}" in
|
|
961
|
+
commit|c) shift; cmd_commit "$@" ;;
|
|
962
|
+
lint|l) shift; cmd_lint "$@" ;;
|
|
963
|
+
init) shift; cmd_init "$@" ;;
|
|
964
|
+
log) shift; cmd_log "$@" ;;
|
|
965
|
+
types|t) shift; cmd_types "$@" ;;
|
|
966
|
+
uninstall) shift; cmd_uninstall "$@" ;;
|
|
967
|
+
version|-v|--version) cmd_version ;;
|
|
968
|
+
help|-h|--help|*) cmd_help ;;
|
|
969
|
+
esac
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
main "$@"
|