lechnerio-git-hooks 1.1.9 โ 1.3.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/README.md +8 -1
- package/hooks/post-commit +46 -7
- package/hooks/pre-push +35 -0
- package/package.json +1 -1
- package/scripts/generate-changelog.mjs +78 -3
- package/setup.mjs +1 -0
package/README.md
CHANGED
|
@@ -32,7 +32,11 @@ On install, the package automatically:
|
|
|
32
32
|
| ------------- | -------------------------------------------------------------------------------------------- |
|
|
33
33
|
| `pre-commit` | Runs lint-staged (eslint --fix + prettier) |
|
|
34
34
|
| `commit-msg` | Enforces conventional commits via commitlint |
|
|
35
|
-
| `post-commit` | Interactive version bump (subtle/patch/minor/major), changelog generation, push/merge prompt |
|
|
35
|
+
| `post-commit` | Interactive version bump (subtle/patch/minor/major), changelog generation with optional skip flag, push/merge prompt |
|
|
36
|
+
|
|
37
|
+
## AI release summaries
|
|
38
|
+
|
|
39
|
+
On Minor and Major version bumps, the `post-commit` hook sends the version's non-skipped commit messages to the Gemini API and stores the result as a `summary` field on the version entry in `data/changelog.json`. This requires `GITHOOKS_GEMINI_API_KEY` in the project's `.env` file or environment; without it the summary step is skipped silently.
|
|
36
40
|
|
|
37
41
|
## Peer dependencies (auto-installed)
|
|
38
42
|
|
|
@@ -55,6 +59,9 @@ For non-interactive usage (CI, scripts):
|
|
|
55
59
|
| -------------- | --------------------------------------------------------------- |
|
|
56
60
|
| `VERSION_BUMP` | `1` = subtle, `2` = patch, `3` = minor, `4` = major, `0` = skip |
|
|
57
61
|
| `POST_ACTION` | `1` = push, `2` = merge to main, `0` = skip |
|
|
62
|
+
| `CHANGELOG_ADD` | `1` = keep commit in changelog, `0` = mark it skipped (asked when version bump is skipped) |
|
|
63
|
+
| `GITHOOKS_GEMINI_API_KEY` | Gemini API key for AI release summaries; unset = feature disabled |
|
|
64
|
+
| `GITHOOKS_GEMINI_MODEL` | Optional Gemini model for summaries, defaults to `gemini-flash-latest` |
|
|
58
65
|
|
|
59
66
|
## Update
|
|
60
67
|
|
package/hooks/post-commit
CHANGED
|
@@ -76,21 +76,59 @@ bump_and_amend() {
|
|
|
76
76
|
git add package.json
|
|
77
77
|
git commit --amend --no-edit
|
|
78
78
|
git tag -f -a "v${new_ver}" -m "v${new_ver}"
|
|
79
|
-
|
|
79
|
+
local summarize_flag=""
|
|
80
|
+
if [ "$label" = "Minor" ] || [ "$label" = "Major" ]; then
|
|
81
|
+
summarize_flag="--summarize"
|
|
82
|
+
echo -e "${CYAN}๐ค Generating AI summary...${NC}"
|
|
83
|
+
fi
|
|
84
|
+
node scripts/generate-changelog.mjs $summarize_flag
|
|
80
85
|
git add data/changelog.json
|
|
81
86
|
git commit --amend --no-edit --no-verify
|
|
82
87
|
git tag -f -a "v${new_ver}" -m "v${new_ver}"
|
|
83
88
|
echo -e "${GREEN}โ
$label version bumped to ${new_ver}${NC}"
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
prompt_changelog_skip() {
|
|
92
|
+
local choice
|
|
93
|
+
if [ -n "$CHANGELOG_ADD" ]; then
|
|
94
|
+
choice="$CHANGELOG_ADD"
|
|
95
|
+
elif exec < /dev/tty 2>/dev/null; then
|
|
96
|
+
echo -e "${CYAN}๐ Changelog${NC}"
|
|
97
|
+
echo
|
|
98
|
+
echo -e "${CYAN}Should we add this message to the changelog?${NC}"
|
|
99
|
+
echo
|
|
100
|
+
echo -e "1) Yes ${YELLOW}(default)${NC}"
|
|
101
|
+
echo -e "${GRAY}0) No - Skip Changelog${NC}"
|
|
102
|
+
echo
|
|
103
|
+
echo -n "Select option (Enter for Default or 0 to skip): "
|
|
104
|
+
read -r choice
|
|
105
|
+
choice=${choice:-1}
|
|
106
|
+
else
|
|
107
|
+
choice=1
|
|
108
|
+
fi
|
|
109
|
+
echo
|
|
110
|
+
|
|
111
|
+
if [ "$choice" = "0" ]; then
|
|
112
|
+
if [ "$DRY_RUN" = "true" ]; then
|
|
113
|
+
echo -e "${YELLOW}[DRY RUN] Would mark commit as skipped in changelog${NC}"
|
|
114
|
+
else
|
|
115
|
+
node scripts/generate-changelog.mjs --skip-last 2>/dev/null
|
|
116
|
+
git add data/changelog.json
|
|
117
|
+
git commit --amend --no-edit --no-verify
|
|
118
|
+
echo -e "${CYAN}๐ Commit marked as skipped in changelog${NC}"
|
|
119
|
+
echo
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
do_push() {
|
|
87
125
|
echo -e "${YELLOW}โ๏ธ Pushing to GitHub...${NC}"
|
|
88
126
|
if [ "$DRY_RUN" = "true" ]; then
|
|
89
|
-
echo -e "${YELLOW}[DRY RUN] Would execute: git push --follow-tags
|
|
127
|
+
echo -e "${YELLOW}[DRY RUN] Would execute: git push --follow-tags${NC}"
|
|
90
128
|
return
|
|
91
129
|
fi
|
|
92
130
|
typecheck_before_push || return
|
|
93
|
-
git push --follow-tags
|
|
131
|
+
git push --follow-tags
|
|
94
132
|
if [ $? -eq 0 ]; then
|
|
95
133
|
echo -e "${GREEN}โ
Successfully pushed to GitHub${NC}"
|
|
96
134
|
else
|
|
@@ -118,14 +156,14 @@ do_merge() {
|
|
|
118
156
|
do_merge_and_push() {
|
|
119
157
|
echo -e "${YELLOW}๐ Merging to main + pushing...${NC}"
|
|
120
158
|
if [ "$DRY_RUN" = "true" ]; then
|
|
121
|
-
echo -e "${YELLOW}[DRY RUN] Would execute: git push --follow-tags
|
|
159
|
+
echo -e "${YELLOW}[DRY RUN] Would execute: git push --follow-tags${NC}"
|
|
122
160
|
echo -e "${YELLOW}[DRY RUN] Would execute: git checkout main && git pull && git merge <branch> && git push${NC}"
|
|
123
161
|
return
|
|
124
162
|
fi
|
|
125
163
|
typecheck_before_push || return
|
|
126
164
|
local current_branch=$(git branch --show-current)
|
|
127
165
|
echo -e "${YELLOW}โ๏ธ Pushing ${current_branch}...${NC}"
|
|
128
|
-
if ! git push --follow-tags
|
|
166
|
+
if ! git push --follow-tags; then
|
|
129
167
|
echo -e "${RED}โ Failed to push ${current_branch}${NC}"
|
|
130
168
|
return
|
|
131
169
|
fi
|
|
@@ -277,7 +315,7 @@ case $version_choice in
|
|
|
277
315
|
3)
|
|
278
316
|
echo -e "${YELLOW}๐ฆ Bumping minor version...${NC}"
|
|
279
317
|
if [ "$DRY_RUN" = "true" ]; then
|
|
280
|
-
echo -e "${YELLOW}[DRY RUN] Would bump to ${minor_ver}${NC}"
|
|
318
|
+
echo -e "${YELLOW}[DRY RUN] Would bump to ${minor_ver} and generate AI summary${NC}"
|
|
281
319
|
else
|
|
282
320
|
bump_and_amend "$minor_ver" "Minor"
|
|
283
321
|
fi
|
|
@@ -285,12 +323,13 @@ case $version_choice in
|
|
|
285
323
|
4)
|
|
286
324
|
echo -e "${YELLOW}๐ฆ Bumping major version...${NC}"
|
|
287
325
|
if [ "$DRY_RUN" = "true" ]; then
|
|
288
|
-
echo -e "${YELLOW}[DRY RUN] Would bump to ${major_ver}${NC}"
|
|
326
|
+
echo -e "${YELLOW}[DRY RUN] Would bump to ${major_ver} and generate AI summary${NC}"
|
|
289
327
|
else
|
|
290
328
|
bump_and_amend "$major_ver" "Major"
|
|
291
329
|
fi
|
|
292
330
|
;;
|
|
293
331
|
0)
|
|
332
|
+
prompt_changelog_skip
|
|
294
333
|
echo -e "${YELLOW}โญ๏ธ Skipping version bump${NC}"
|
|
295
334
|
;;
|
|
296
335
|
*)
|
package/hooks/pre-push
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
RED='\033[0;31m'
|
|
4
|
+
GREEN='\033[0;32m'
|
|
5
|
+
YELLOW='\033[1;33m'
|
|
6
|
+
CYAN='\033[0;36m'
|
|
7
|
+
NC='\033[0m'
|
|
8
|
+
|
|
9
|
+
if [ ! -f "package.json" ]; then
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
has_test_script=$(node -p "!!(require('./package.json').scripts && require('./package.json').scripts.test)" 2>/dev/null)
|
|
14
|
+
|
|
15
|
+
if [ "$has_test_script" != "true" ]; then
|
|
16
|
+
echo -e "${YELLOW}โญ๏ธ No test script found, skipping tests${NC}"
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
if [ -f "pnpm-lock.yaml" ]; then
|
|
21
|
+
pm="pnpm"
|
|
22
|
+
elif [ -f "yarn.lock" ]; then
|
|
23
|
+
pm="yarn"
|
|
24
|
+
else
|
|
25
|
+
pm="npm"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
echo -e "${CYAN}๐งช Running ${pm} test...${NC}"
|
|
29
|
+
if $pm test; then
|
|
30
|
+
echo -e "${GREEN}โ
Tests passed${NC}"
|
|
31
|
+
exit 0
|
|
32
|
+
else
|
|
33
|
+
echo -e "${RED}โ Tests failed โ push aborted${NC}"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
package/package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { execSync } from "child_process"
|
|
2
|
-
import { writeFileSync, mkdirSync } from "fs"
|
|
2
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync } from "fs"
|
|
3
3
|
|
|
4
4
|
const run = (cmd) => execSync(cmd, { encoding: "utf-8", shell: "bash" }).trim()
|
|
5
5
|
|
|
6
|
+
const loadEnvKey = (name) => {
|
|
7
|
+
if (process.env[name]) return process.env[name]
|
|
8
|
+
try {
|
|
9
|
+
const line = readFileSync(".env", "utf-8")
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map((l) => l.trim())
|
|
12
|
+
.find((l) => l.startsWith(`${name}=`))
|
|
13
|
+
return line ? line.slice(name.length + 1).trim().replace(/^["']|["']$/g, "") : null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
try {
|
|
7
20
|
run("git --version")
|
|
8
21
|
run("git rev-parse --git-dir")
|
|
@@ -17,12 +30,31 @@ if (tags.length === 0) {
|
|
|
17
30
|
process.exit(0)
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
const skipFlags = new Map()
|
|
34
|
+
const summaries = new Map()
|
|
35
|
+
if (existsSync("data/changelog.json")) {
|
|
36
|
+
try {
|
|
37
|
+
const existing = JSON.parse(readFileSync("data/changelog.json", "utf-8"))
|
|
38
|
+
for (const entry of existing) {
|
|
39
|
+
if (typeof entry.summary === "string") summaries.set(entry.version, entry.summary)
|
|
40
|
+
for (const commit of entry.commits ?? []) {
|
|
41
|
+
skipFlags.set(commit.message, commit.skip === true)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (process.argv.includes("--skip-last")) {
|
|
48
|
+
const lastSubject = run('git log -1 --format="%s"')
|
|
49
|
+
skipFlags.set(lastSubject, true)
|
|
50
|
+
}
|
|
51
|
+
|
|
20
52
|
const changelog = []
|
|
21
53
|
|
|
22
54
|
const currentVersion = tags[0].replace(/^v/, "")
|
|
23
55
|
const unreleasedRaw = run(`git log --format="%s" ${tags[0]}..HEAD`)
|
|
24
56
|
if (unreleasedRaw) {
|
|
25
|
-
const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message }))
|
|
57
|
+
const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
26
58
|
if (commits.length > 0) {
|
|
27
59
|
const date = run(`git log --format=%ai HEAD -1`).split(" ")[0]
|
|
28
60
|
changelog.push({ version: currentVersion, tag: null, date, commits })
|
|
@@ -38,7 +70,7 @@ for (let i = 0; i < tags.length; i++) {
|
|
|
38
70
|
const raw = run(`git log --format="%s" ${range}`)
|
|
39
71
|
|
|
40
72
|
const commits = raw
|
|
41
|
-
? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message }))
|
|
73
|
+
? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
42
74
|
: []
|
|
43
75
|
|
|
44
76
|
changelog.push({
|
|
@@ -72,6 +104,49 @@ for (const entry of grouped) {
|
|
|
72
104
|
})
|
|
73
105
|
}
|
|
74
106
|
|
|
107
|
+
for (const entry of grouped) {
|
|
108
|
+
const saved = summaries.get(entry.version)
|
|
109
|
+
if (saved && !entry.summary) entry.summary = saved
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.argv.includes("--summarize")) {
|
|
113
|
+
const apiKey = loadEnvKey("GITHOOKS_GEMINI_API_KEY")
|
|
114
|
+
if (!apiKey) {
|
|
115
|
+
console.warn("GITHOOKS_GEMINI_API_KEY not set, skipping AI summary")
|
|
116
|
+
} else {
|
|
117
|
+
const target = grouped.find((e) => e.tag === tags[0])
|
|
118
|
+
if (target && !target.summary) {
|
|
119
|
+
const messages = target.commits.filter((c) => !c.skip).map((c) => c.message)
|
|
120
|
+
if (messages.length === 0) {
|
|
121
|
+
console.warn("no commits to summarize")
|
|
122
|
+
} else {
|
|
123
|
+
try {
|
|
124
|
+
const model = loadEnvKey("GITHOOKS_GEMINI_MODEL") ?? "gemini-flash-latest"
|
|
125
|
+
const prompt = `Summarize these commit messages into a short user-facing release summary for version ${target.version}. Write 1-3 plain sentences, no markdown, no bullet points, no preamble.\n\n${messages.join("\n")}`
|
|
126
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
131
|
+
generationConfig: { thinkingConfig: { thinkingBudget: 0 } },
|
|
132
|
+
}),
|
|
133
|
+
signal: AbortSignal.timeout(60000),
|
|
134
|
+
})
|
|
135
|
+
if (!res.ok) throw new Error(`gemini api returned ${res.status}`)
|
|
136
|
+
const data = await res.json()
|
|
137
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
|
138
|
+
if (text) {
|
|
139
|
+
target.summary = text
|
|
140
|
+
console.log(`ai summary generated for ${target.version}`)
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.warn(`ai summary failed: ${err.message}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
75
150
|
mkdirSync("data", { recursive: true })
|
|
76
151
|
writeFileSync("data/changelog.json", JSON.stringify(grouped, null, 2))
|
|
77
152
|
console.log(`changelog generated: ${grouped.length} versions`)
|
package/setup.mjs
CHANGED
|
@@ -43,6 +43,7 @@ function copyWithLF(src, dest) {
|
|
|
43
43
|
|
|
44
44
|
copyWithLF(resolve(hooksSource, "post-commit"), resolve(huskyDest, "post-commit"))
|
|
45
45
|
copyWithLF(resolve(hooksSource, "pre-commit"), resolve(huskyDest, "pre-commit"))
|
|
46
|
+
copyWithLF(resolve(hooksSource, "pre-push"), resolve(huskyDest, "pre-push"))
|
|
46
47
|
copyWithLF(resolve(hooksSource, "commit-msg"), resolve(huskyDest, "commit-msg"))
|
|
47
48
|
copyWithLF(resolve(scriptsSource, "generate-changelog.mjs"), resolve(scriptsDest, "generate-changelog.mjs"))
|
|
48
49
|
copyWithLF(resolve(configSource, "commitlint.config.js"), resolve(projectRoot, "commitlint.config.js"))
|