@webywz/vibe-commit 0.0.0-stage → 0.1.1
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 +156 -2
- package/dist/ai.d.ts +9 -0
- package/dist/ai.js +83 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +95 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.js +72 -0
- package/dist/git.d.ts +7 -0
- package/dist/git.js +55 -0
- package/dist/heuristic.d.ts +15 -0
- package/dist/heuristic.js +336 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/parse-args.d.ts +10 -0
- package/dist/parse-args.js +129 -0
- package/dist/parse-diff.d.ts +5 -0
- package/dist/parse-diff.js +113 -0
- package/dist/propose.d.ts +2 -0
- package/dist/propose.js +23 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.js +59 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +14 -0
- package/dist/vibe.d.ts +10 -0
- package/dist/vibe.js +116 -0
- package/package.json +61 -4
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { normalizeSubject } from "./format.js";
|
|
2
|
+
import { applyVibe } from "./vibe.js";
|
|
3
|
+
const SKIP_SCOPE_DIRS = new Set([
|
|
4
|
+
".",
|
|
5
|
+
"src",
|
|
6
|
+
"lib",
|
|
7
|
+
"app",
|
|
8
|
+
"apps",
|
|
9
|
+
"dist",
|
|
10
|
+
"build",
|
|
11
|
+
"source",
|
|
12
|
+
"sources",
|
|
13
|
+
"packages",
|
|
14
|
+
"test",
|
|
15
|
+
"tests",
|
|
16
|
+
"__tests__",
|
|
17
|
+
"bin",
|
|
18
|
+
"scripts",
|
|
19
|
+
]);
|
|
20
|
+
const IDENT_RE = /^\+\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:pub(?:lic|\(crate\))?\s+)?(?:function|const|let|class|type|interface|enum|fn|struct|trait|impl|def|func)\s+([A-Za-z_][\w]*)/;
|
|
21
|
+
const FIX_WORD_RE = /\b(fix|fixed|fixes|bug|bugs|hotfix|crash|broken|error|regression|issue|#\d+)\b/i;
|
|
22
|
+
const FEAT_WORD_RE = /\b(add|added|feat|feature|implement|introduce|support|enable)\b/i;
|
|
23
|
+
const REFACTOR_WORD_RE = /\b(refactor|rename|move|extract|simplify|cleanup|clean up|restructure)\b/i;
|
|
24
|
+
const PERF_WORD_RE = /\b(perf|performance|optimiz|cache|faster|speed)\b/i;
|
|
25
|
+
export function proposeHeuristic(input) {
|
|
26
|
+
const { diff, vibe } = input;
|
|
27
|
+
const type = input.type ?? inferType(diff);
|
|
28
|
+
const scope = input.scope ?? inferScope(diff.files);
|
|
29
|
+
const subject = inferSubject(diff, type, scope);
|
|
30
|
+
const body = inferBody(diff, type);
|
|
31
|
+
const breaking = inferBreaking(diff);
|
|
32
|
+
const styled = applyVibe({ type, scope, subject, body, breaking }, vibe);
|
|
33
|
+
return { ...styled, engine: "heuristic" };
|
|
34
|
+
}
|
|
35
|
+
export function inferType(diff) {
|
|
36
|
+
const files = diff.files;
|
|
37
|
+
if (files.length === 0)
|
|
38
|
+
return "chore";
|
|
39
|
+
const roles = files.map((file) => primaryRole(file.path));
|
|
40
|
+
const unique = new Set(roles);
|
|
41
|
+
const keyword = keywordType(diff);
|
|
42
|
+
if (unique.size === 1) {
|
|
43
|
+
const role = roles[0] ?? "chore";
|
|
44
|
+
if (role !== "feat")
|
|
45
|
+
return role;
|
|
46
|
+
if (files.every((file) => file.status === "R"))
|
|
47
|
+
return "refactor";
|
|
48
|
+
return keyword ?? "feat";
|
|
49
|
+
}
|
|
50
|
+
if (keyword === "fix" || keyword === "perf" || keyword === "refactor") {
|
|
51
|
+
return keyword;
|
|
52
|
+
}
|
|
53
|
+
const codeRoles = roles.filter((role) => role === "feat" || role === "style" || role === "build");
|
|
54
|
+
if (codeRoles.length > 0)
|
|
55
|
+
return "feat";
|
|
56
|
+
if (roles.includes("test"))
|
|
57
|
+
return "test";
|
|
58
|
+
if (roles.includes("ci"))
|
|
59
|
+
return "ci";
|
|
60
|
+
if (roles.includes("docs"))
|
|
61
|
+
return "docs";
|
|
62
|
+
return "chore";
|
|
63
|
+
}
|
|
64
|
+
export function primaryRole(path) {
|
|
65
|
+
if (isTestPath(path))
|
|
66
|
+
return "test";
|
|
67
|
+
if (isCiPath(path))
|
|
68
|
+
return "ci";
|
|
69
|
+
if (isDocsPath(path))
|
|
70
|
+
return "docs";
|
|
71
|
+
if (isBuildPath(path))
|
|
72
|
+
return "build";
|
|
73
|
+
if (isChorePath(path))
|
|
74
|
+
return "chore";
|
|
75
|
+
if (isStylePath(path))
|
|
76
|
+
return "style";
|
|
77
|
+
return "feat";
|
|
78
|
+
}
|
|
79
|
+
export function classifyPath(path) {
|
|
80
|
+
return [primaryRole(path)];
|
|
81
|
+
}
|
|
82
|
+
function isTestPath(path) {
|
|
83
|
+
return (/(^|\/)(__tests__|tests?|spec)\//i.test(path) ||
|
|
84
|
+
/\.(tests?|spec)\.[cm]?[jt]sx?$/i.test(path));
|
|
85
|
+
}
|
|
86
|
+
function isCiPath(path) {
|
|
87
|
+
return /(^|\/)(\.github\/workflows|\.gitlab-ci|\.circleci)(\/|$)/i.test(path);
|
|
88
|
+
}
|
|
89
|
+
function isDocsPath(path) {
|
|
90
|
+
return (/(^|\/)(docs?|documentation)\//i.test(path) ||
|
|
91
|
+
/\.(md|mdx|rst|adoc)$/i.test(path) ||
|
|
92
|
+
/readme/i.test(path));
|
|
93
|
+
}
|
|
94
|
+
function isBuildPath(path) {
|
|
95
|
+
return /(^|\/)(Dockerfile|Makefile|webpack\.|vite\.config|rollup\.config|tsup\.config|esbuild)/i.test(path);
|
|
96
|
+
}
|
|
97
|
+
function isChorePath(path) {
|
|
98
|
+
return /(^|\/)(\.gitignore|\.editorconfig|\.nvmrc|eslint|prettier|tsconfig|package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)(\b|$)/i.test(path);
|
|
99
|
+
}
|
|
100
|
+
function isStylePath(path) {
|
|
101
|
+
return /\.(css|scss|sass|less)$/i.test(path);
|
|
102
|
+
}
|
|
103
|
+
function keywordType(diff) {
|
|
104
|
+
const blob = keywordCorpus(diff);
|
|
105
|
+
if (FIX_WORD_RE.test(blob))
|
|
106
|
+
return "fix";
|
|
107
|
+
if (PERF_WORD_RE.test(blob))
|
|
108
|
+
return "perf";
|
|
109
|
+
if (REFACTOR_WORD_RE.test(blob))
|
|
110
|
+
return "refactor";
|
|
111
|
+
if (FEAT_WORD_RE.test(blob))
|
|
112
|
+
return "feat";
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
export function inferScope(files) {
|
|
116
|
+
if (files.length === 0)
|
|
117
|
+
return undefined;
|
|
118
|
+
const candidates = files
|
|
119
|
+
.map((file) => scopeFromPath(file.path))
|
|
120
|
+
.filter((value) => Boolean(value));
|
|
121
|
+
if (candidates.length === 0)
|
|
122
|
+
return undefined;
|
|
123
|
+
const counts = new Map();
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
counts.set(candidate, (counts.get(candidate) ?? 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
128
|
+
const top = ranked[0];
|
|
129
|
+
if (!top)
|
|
130
|
+
return undefined;
|
|
131
|
+
const [name, count] = top;
|
|
132
|
+
if (count / files.length < 0.5 && files.length > 2)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (SKIP_SCOPE_DIRS.has(name))
|
|
135
|
+
return undefined;
|
|
136
|
+
return name.slice(0, 32);
|
|
137
|
+
}
|
|
138
|
+
export function inferSubject(diff, type, scope) {
|
|
139
|
+
const files = diff.files;
|
|
140
|
+
if (files.length === 0)
|
|
141
|
+
return "update project files";
|
|
142
|
+
const added = files.filter((file) => file.status === "A");
|
|
143
|
+
const deleted = files.filter((file) => file.status === "D");
|
|
144
|
+
const renamed = files.filter((file) => file.status === "R");
|
|
145
|
+
const idents = files.length <= 3 ? collectAddedIdentifiers(diff.hunks).slice(0, 2) : [];
|
|
146
|
+
if (added.length >= 4 &&
|
|
147
|
+
added.length >= files.length - 1 &&
|
|
148
|
+
!scope &&
|
|
149
|
+
type === "feat") {
|
|
150
|
+
return normalizeSubject("add initial implementation");
|
|
151
|
+
}
|
|
152
|
+
if (renamed.length === 1 && files.length === 1) {
|
|
153
|
+
const file = renamed[0];
|
|
154
|
+
return normalizeSubject(`rename ${stem(file.fromPath ?? file.path)} to ${stem(file.path)}`);
|
|
155
|
+
}
|
|
156
|
+
if (deleted.length === files.length) {
|
|
157
|
+
return normalizeSubject(`remove ${joinNames(deleted.map((file) => stem(file.path)))}`);
|
|
158
|
+
}
|
|
159
|
+
if (idents.length > 0 && (type === "feat" || type === "fix")) {
|
|
160
|
+
const verb = type === "fix" ? "fix" : "add";
|
|
161
|
+
return normalizeSubject(`${verb} ${joinNames(idents.map(humanizeIdent))}`);
|
|
162
|
+
}
|
|
163
|
+
if (added.length === 1 && files.length === 1) {
|
|
164
|
+
return normalizeSubject(`add ${stem(added[0].path)}`);
|
|
165
|
+
}
|
|
166
|
+
if (added.length > 1 && added.length === files.length) {
|
|
167
|
+
return normalizeSubject(`add ${summarizeFiles(added, scope)}`);
|
|
168
|
+
}
|
|
169
|
+
const focus = files.length === 1 ? stem(files[0].path) : summarizeFiles(files, scope);
|
|
170
|
+
return normalizeSubject(verbFor(type, focus, files));
|
|
171
|
+
}
|
|
172
|
+
function verbFor(type, focus, files) {
|
|
173
|
+
const mostlyAdd = files.reduce((sum, file) => sum + file.additions, 0) >
|
|
174
|
+
files.reduce((sum, file) => sum + file.deletions, 0) * 1.4;
|
|
175
|
+
switch (type) {
|
|
176
|
+
case "feat":
|
|
177
|
+
return mostlyAdd ? `add ${focus}` : `update ${focus}`;
|
|
178
|
+
case "fix":
|
|
179
|
+
return `fix ${focus}`;
|
|
180
|
+
case "docs":
|
|
181
|
+
return files.some((file) => /readme/i.test(file.path))
|
|
182
|
+
? "update readme"
|
|
183
|
+
: `update ${focus}`;
|
|
184
|
+
case "test":
|
|
185
|
+
return mostlyAdd ? `add ${focus} tests` : `update ${focus} tests`;
|
|
186
|
+
case "refactor":
|
|
187
|
+
return `refactor ${focus}`;
|
|
188
|
+
case "perf":
|
|
189
|
+
return `improve ${focus} performance`;
|
|
190
|
+
case "style":
|
|
191
|
+
return `format ${focus}`;
|
|
192
|
+
case "ci":
|
|
193
|
+
return `update ${focus} workflow`;
|
|
194
|
+
case "build":
|
|
195
|
+
return `update ${focus} build`;
|
|
196
|
+
case "chore":
|
|
197
|
+
if (files.some((file) => /package(-lock)?\.json|pnpm-lock|yarn\.lock/.test(file.path))) {
|
|
198
|
+
return "update dependencies";
|
|
199
|
+
}
|
|
200
|
+
return `update ${focus}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function inferBody(diff, type) {
|
|
204
|
+
const files = diff.files;
|
|
205
|
+
if (files.length < 3)
|
|
206
|
+
return undefined;
|
|
207
|
+
const listed = files
|
|
208
|
+
.slice(0, 8)
|
|
209
|
+
.map((file) => `- ${statusLabel(file.status)} ${file.path}`)
|
|
210
|
+
.join("\n");
|
|
211
|
+
const extra = files.length > 8 ? `\n- …and ${files.length - 8} more` : "";
|
|
212
|
+
const lead = type === "feat"
|
|
213
|
+
? "Touches:"
|
|
214
|
+
: type === "fix"
|
|
215
|
+
? "Files in this fix:"
|
|
216
|
+
: "Changed files:";
|
|
217
|
+
return `${lead}\n${listed}${extra}`;
|
|
218
|
+
}
|
|
219
|
+
function inferBreaking(diff) {
|
|
220
|
+
return diff.hunks.some((hunk) => hunk.lines.some((line) => /^\+\s*BREAKING CHANGE:/i.test(line)));
|
|
221
|
+
}
|
|
222
|
+
export function collectAddedIdentifiers(hunks) {
|
|
223
|
+
const names = [];
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
for (const hunk of hunks) {
|
|
226
|
+
for (const line of hunk.lines) {
|
|
227
|
+
if (!line.startsWith("+") || line.startsWith("+++"))
|
|
228
|
+
continue;
|
|
229
|
+
const match = line.match(IDENT_RE);
|
|
230
|
+
if (!match?.[1])
|
|
231
|
+
continue;
|
|
232
|
+
const name = match[1];
|
|
233
|
+
if (seen.has(name) || name.length < 2)
|
|
234
|
+
continue;
|
|
235
|
+
seen.add(name);
|
|
236
|
+
names.push(name);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return names;
|
|
240
|
+
}
|
|
241
|
+
function scopeFromPath(path) {
|
|
242
|
+
const parts = path.split("/").filter(Boolean);
|
|
243
|
+
if (parts.length === 0)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (parts[0] === "packages" && parts[1])
|
|
246
|
+
return sanitizeIdent(parts[1]);
|
|
247
|
+
const start = SKIP_SCOPE_DIRS.has(parts[0] ?? "") ? 1 : 0;
|
|
248
|
+
const candidate = parts[start];
|
|
249
|
+
if (!candidate)
|
|
250
|
+
return undefined;
|
|
251
|
+
if (parts.length < start + 2) {
|
|
252
|
+
if (parts.length === start + 1 &&
|
|
253
|
+
!candidate.startsWith(".") &&
|
|
254
|
+
!isDocsPath(path) &&
|
|
255
|
+
!isChorePath(path) &&
|
|
256
|
+
!isCiPath(path)) {
|
|
257
|
+
const name = sanitizeIdent(candidate.replace(/\.[^.]+$/, ""));
|
|
258
|
+
if (name && name !== "index")
|
|
259
|
+
return name;
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (SKIP_SCOPE_DIRS.has(candidate) || candidate.startsWith(".")) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
return sanitizeIdent(candidate.replace(/\.[^.]+$/, ""));
|
|
267
|
+
}
|
|
268
|
+
function sanitizeIdent(value) {
|
|
269
|
+
return value
|
|
270
|
+
.replace(/[^\w.-]+/g, "-")
|
|
271
|
+
.replace(/^-+|-+$/g, "")
|
|
272
|
+
.toLowerCase();
|
|
273
|
+
}
|
|
274
|
+
export function stem(path) {
|
|
275
|
+
const parts = path.split("/").filter(Boolean);
|
|
276
|
+
let base = parts.at(-1) ?? path;
|
|
277
|
+
if (/^(index|main|mod|__init__)\.[^.]+$/i.test(base) && parts.length >= 2) {
|
|
278
|
+
base = parts.at(-2) ?? base;
|
|
279
|
+
}
|
|
280
|
+
base = base.replace(/\.(tests?|spec)\.[^.]+$/i, "");
|
|
281
|
+
base = base.replace(/\.[^.]+$/, "");
|
|
282
|
+
return humanizeIdent(base);
|
|
283
|
+
}
|
|
284
|
+
function humanizeIdent(name) {
|
|
285
|
+
return name
|
|
286
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
287
|
+
.replace(/[_./-]+/g, " ")
|
|
288
|
+
.replace(/\s+/g, " ")
|
|
289
|
+
.trim()
|
|
290
|
+
.toLowerCase();
|
|
291
|
+
}
|
|
292
|
+
function joinNames(names) {
|
|
293
|
+
const unique = [...new Set(names.filter(Boolean))];
|
|
294
|
+
if (unique.length === 0)
|
|
295
|
+
return "files";
|
|
296
|
+
if (unique.length === 1)
|
|
297
|
+
return unique[0];
|
|
298
|
+
if (unique.length === 2)
|
|
299
|
+
return `${unique[0]} and ${unique[1]}`;
|
|
300
|
+
return `${unique[0]}, ${unique[1]}, and more`;
|
|
301
|
+
}
|
|
302
|
+
function summarizeFiles(files, scope) {
|
|
303
|
+
if (scope)
|
|
304
|
+
return scope;
|
|
305
|
+
return joinNames(files.map((file) => stem(file.path)).slice(0, 2));
|
|
306
|
+
}
|
|
307
|
+
function statusLabel(status) {
|
|
308
|
+
switch (status) {
|
|
309
|
+
case "A":
|
|
310
|
+
return "add";
|
|
311
|
+
case "D":
|
|
312
|
+
return "delete";
|
|
313
|
+
case "R":
|
|
314
|
+
return "rename";
|
|
315
|
+
default:
|
|
316
|
+
return "edit";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function keywordCorpus(diff) {
|
|
320
|
+
const paths = diff.files.map((file) => file.path).join("\n");
|
|
321
|
+
const onlyDocs = diff.files.every((file) => isDocsPath(file.path));
|
|
322
|
+
const docPaths = new Set(diff.files.filter((file) => isDocsPath(file.path)).map((file) => file.path));
|
|
323
|
+
const lines = [];
|
|
324
|
+
for (const hunk of diff.hunks) {
|
|
325
|
+
const fromDocs = onlyDocs && docPaths.has(hunk.path);
|
|
326
|
+
for (const line of hunk.lines) {
|
|
327
|
+
if (!line.startsWith("+") || line.startsWith("+++"))
|
|
328
|
+
continue;
|
|
329
|
+
if (fromDocs || /^\+\s*(?:\/\/|#|\/\*|\*|<!--)/.test(line)) {
|
|
330
|
+
lines.push(line);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return `${paths}\n${lines.join("\n")}`;
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=heuristic.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { proposeWithAi } from "./ai.ts";
|
|
2
|
+
export { formatCommit, parseCommit, normalizeSubject } from "./format.ts";
|
|
3
|
+
export { inspectStaged, commitWithMessage, GitError } from "./git.ts";
|
|
4
|
+
export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.ts";
|
|
5
|
+
export { parseArgs, helpText, UsageError } from "./parse-args.ts";
|
|
6
|
+
export { assembleDiff, parseUnifiedDiff } from "./parse-diff.ts";
|
|
7
|
+
export { proposeMessage } from "./propose.ts";
|
|
8
|
+
export { applyVibe } from "./vibe.ts";
|
|
9
|
+
export type { CliOptions, CommitType, ProposedMessage, ProposeOptions, StagedDiff, StagedFile, Vibe, } from "./types.ts";
|
|
10
|
+
export { COMMIT_TYPES, VIBES } from "./types.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { proposeWithAi } from "./ai.js";
|
|
2
|
+
export { formatCommit, parseCommit, normalizeSubject } from "./format.js";
|
|
3
|
+
export { inspectStaged, commitWithMessage, GitError } from "./git.js";
|
|
4
|
+
export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.js";
|
|
5
|
+
export { parseArgs, helpText, UsageError } from "./parse-args.js";
|
|
6
|
+
export { assembleDiff, parseUnifiedDiff } from "./parse-diff.js";
|
|
7
|
+
export { proposeMessage } from "./propose.js";
|
|
8
|
+
export { applyVibe } from "./vibe.js";
|
|
9
|
+
export { COMMIT_TYPES, VIBES } from "./types.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CliOptions, type CommitType, type Vibe } from "./types.ts";
|
|
2
|
+
export declare function defaultVibeFromEnv(env?: NodeJS.ProcessEnv): Vibe;
|
|
3
|
+
export declare function parseArgs(argv: string[], env?: NodeJS.ProcessEnv): CliOptions;
|
|
4
|
+
export declare function parseType(value: string | undefined): CommitType;
|
|
5
|
+
export declare function parseVibe(value: string | undefined): Vibe;
|
|
6
|
+
export declare function sanitizeScope(value: string): string;
|
|
7
|
+
export declare class UsageError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
export declare function helpText(): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { COMMIT_TYPES, VIBES, } from "./types.js";
|
|
2
|
+
const HELP = `vibe-commit — stylish conventional commits, offline-first
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
vibe-commit [options]
|
|
6
|
+
|
|
7
|
+
Options
|
|
8
|
+
--commit Create the commit with the proposed message
|
|
9
|
+
--dry-run Print the message only (default)
|
|
10
|
+
--type <type> feat | fix | docs | style | refactor | perf | test | build | ci | chore
|
|
11
|
+
--scope <scope> Override inferred scope
|
|
12
|
+
--vibe <style> playful | pro | terse (default: pro)
|
|
13
|
+
--no-ai Skip AI even if OPENAI_API_KEY is set
|
|
14
|
+
--json Print a machine-readable proposal
|
|
15
|
+
-h, --help Show help
|
|
16
|
+
-v, --version Show version
|
|
17
|
+
|
|
18
|
+
Env
|
|
19
|
+
OPENAI_API_KEY Optional. When set, draft with an OpenAI-compatible API
|
|
20
|
+
OPENAI_MODEL Default: gpt-4o-mini
|
|
21
|
+
OPENAI_BASE_URL Default: https://api.openai.com/v1
|
|
22
|
+
VIBE / VIBE_COMMIT_VIBE Default vibe when --vibe is omitted
|
|
23
|
+
|
|
24
|
+
Examples
|
|
25
|
+
git add -p
|
|
26
|
+
vibe-commit
|
|
27
|
+
vibe-commit --vibe playful --commit
|
|
28
|
+
git commit -m "$(vibe-commit --dry-run)"
|
|
29
|
+
`;
|
|
30
|
+
export function defaultVibeFromEnv(env = process.env) {
|
|
31
|
+
const raw = (env.VIBE_COMMIT_VIBE ?? env.VIBE ?? "pro").trim().toLowerCase();
|
|
32
|
+
return VIBES.includes(raw) ? raw : "pro";
|
|
33
|
+
}
|
|
34
|
+
export function parseArgs(argv, env = process.env) {
|
|
35
|
+
const options = {
|
|
36
|
+
commit: false,
|
|
37
|
+
dryRun: false,
|
|
38
|
+
vibe: defaultVibeFromEnv(env),
|
|
39
|
+
noAi: false,
|
|
40
|
+
json: false,
|
|
41
|
+
help: false,
|
|
42
|
+
version: false,
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (arg === undefined)
|
|
47
|
+
break;
|
|
48
|
+
if (arg === "-h" || arg === "--help") {
|
|
49
|
+
options.help = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg === "-v" || arg === "--version") {
|
|
53
|
+
options.version = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--commit") {
|
|
57
|
+
options.commit = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === "--dry-run") {
|
|
61
|
+
options.dryRun = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--no-ai") {
|
|
65
|
+
options.noAi = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg === "--json") {
|
|
69
|
+
options.json = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--type" || arg.startsWith("--type=")) {
|
|
73
|
+
const value = arg.includes("=") ? arg.slice("--type=".length) : argv[++i];
|
|
74
|
+
options.type = parseType(value);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg === "--scope" || arg.startsWith("--scope=")) {
|
|
78
|
+
const value = arg.includes("=") ? arg.slice("--scope=".length) : argv[++i];
|
|
79
|
+
if (!value)
|
|
80
|
+
throw new UsageError("Missing value for --scope");
|
|
81
|
+
options.scope = sanitizeScope(value);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (arg === "--vibe" || arg.startsWith("--vibe=")) {
|
|
85
|
+
const value = arg.includes("=") ? arg.slice("--vibe=".length) : argv[++i];
|
|
86
|
+
options.vibe = parseVibe(value);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (arg.startsWith("-")) {
|
|
90
|
+
throw new UsageError(`Unknown option: ${arg}`);
|
|
91
|
+
}
|
|
92
|
+
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
93
|
+
}
|
|
94
|
+
return options;
|
|
95
|
+
}
|
|
96
|
+
export function parseType(value) {
|
|
97
|
+
const raw = value?.trim().toLowerCase();
|
|
98
|
+
if (!raw)
|
|
99
|
+
throw new UsageError("Missing value for --type");
|
|
100
|
+
if (!COMMIT_TYPES.includes(raw)) {
|
|
101
|
+
throw new UsageError(`Invalid --type "${value}". Use: ${COMMIT_TYPES.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
return raw;
|
|
104
|
+
}
|
|
105
|
+
export function parseVibe(value) {
|
|
106
|
+
const raw = value?.trim().toLowerCase();
|
|
107
|
+
if (!raw)
|
|
108
|
+
throw new UsageError("Missing value for --vibe");
|
|
109
|
+
if (!VIBES.includes(raw)) {
|
|
110
|
+
throw new UsageError(`Invalid --vibe "${value}". Use: ${VIBES.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
114
|
+
export function sanitizeScope(value) {
|
|
115
|
+
const scope = value.trim().replace(/^[\s(/]+|[\s)/]+$/g, "");
|
|
116
|
+
if (!scope)
|
|
117
|
+
throw new UsageError("Scope cannot be empty");
|
|
118
|
+
return scope;
|
|
119
|
+
}
|
|
120
|
+
export class UsageError extends Error {
|
|
121
|
+
constructor(message) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = "UsageError";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function helpText() {
|
|
127
|
+
return HELP;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=parse-args.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DiffHunk, StagedDiff, StagedFile } from "./types.ts";
|
|
2
|
+
export declare function parseNameStatus(output: string): Map<string, StagedFile>;
|
|
3
|
+
export declare function applyNumstat(files: Map<string, StagedFile>, output: string): void;
|
|
4
|
+
export declare function parseUnifiedDiff(raw: string): DiffHunk[];
|
|
5
|
+
export declare function assembleDiff(nameStatus: string, numstat: string, raw: string): StagedDiff;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const STATUS_MAP = {
|
|
2
|
+
A: "A",
|
|
3
|
+
M: "M",
|
|
4
|
+
D: "D",
|
|
5
|
+
R: "R",
|
|
6
|
+
C: "C",
|
|
7
|
+
T: "T",
|
|
8
|
+
U: "U",
|
|
9
|
+
};
|
|
10
|
+
export function parseNameStatus(output) {
|
|
11
|
+
const files = new Map();
|
|
12
|
+
for (const line of output.split("\n")) {
|
|
13
|
+
if (!line.trim())
|
|
14
|
+
continue;
|
|
15
|
+
const match = line.match(/^([A-Z])(\d*)\t([^\t]+)(?:\t([^\t]+))?$/);
|
|
16
|
+
if (!match)
|
|
17
|
+
continue;
|
|
18
|
+
const code = match[1] ?? "M";
|
|
19
|
+
const status = STATUS_MAP[code] ?? "M";
|
|
20
|
+
const first = (match[3] ?? "").trim();
|
|
21
|
+
const second = match[4]?.trim();
|
|
22
|
+
if (status === "R" || status === "C") {
|
|
23
|
+
const path = second || first;
|
|
24
|
+
files.set(path, {
|
|
25
|
+
path,
|
|
26
|
+
fromPath: first,
|
|
27
|
+
status,
|
|
28
|
+
additions: 0,
|
|
29
|
+
deletions: 0,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
files.set(first, {
|
|
34
|
+
path: first,
|
|
35
|
+
status,
|
|
36
|
+
additions: 0,
|
|
37
|
+
deletions: 0,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
export function applyNumstat(files, output) {
|
|
44
|
+
for (const line of output.split("\n")) {
|
|
45
|
+
if (!line.trim())
|
|
46
|
+
continue;
|
|
47
|
+
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
48
|
+
if (!match)
|
|
49
|
+
continue;
|
|
50
|
+
const pathToken = match[3] ?? "";
|
|
51
|
+
let path = pathToken;
|
|
52
|
+
let fromPath;
|
|
53
|
+
const rename = pathToken.match(/^(.+) => (.+)$/);
|
|
54
|
+
if (rename) {
|
|
55
|
+
fromPath = stripRenameBrace(rename[1] ?? "");
|
|
56
|
+
path = stripRenameBrace(rename[2] ?? "");
|
|
57
|
+
}
|
|
58
|
+
const existing = files.get(path) ?? {
|
|
59
|
+
path,
|
|
60
|
+
fromPath,
|
|
61
|
+
status: (fromPath ? "R" : "M"),
|
|
62
|
+
additions: 0,
|
|
63
|
+
deletions: 0,
|
|
64
|
+
};
|
|
65
|
+
existing.additions = match[1] === "-" ? 0 : Number(match[1]);
|
|
66
|
+
existing.deletions = match[2] === "-" ? 0 : Number(match[2]);
|
|
67
|
+
if (fromPath && !existing.fromPath)
|
|
68
|
+
existing.fromPath = fromPath;
|
|
69
|
+
files.set(path, existing);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function stripRenameBrace(value) {
|
|
73
|
+
return value.replace(/^\{/, "").replace(/\}$/, "");
|
|
74
|
+
}
|
|
75
|
+
export function parseUnifiedDiff(raw) {
|
|
76
|
+
const hunks = [];
|
|
77
|
+
let currentPath;
|
|
78
|
+
let current;
|
|
79
|
+
for (const line of raw.split("\n")) {
|
|
80
|
+
const gitHeader = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
81
|
+
if (gitHeader) {
|
|
82
|
+
currentPath = gitHeader[2];
|
|
83
|
+
current = undefined;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const plusPath = line.match(/^\+\+\+ b\/(.+)$/);
|
|
87
|
+
if (plusPath && plusPath[1] !== "/dev/null") {
|
|
88
|
+
currentPath = plusPath[1];
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (line.startsWith("@@")) {
|
|
92
|
+
if (!currentPath)
|
|
93
|
+
continue;
|
|
94
|
+
current = { path: currentPath, header: line, lines: [] };
|
|
95
|
+
hunks.push(current);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
|
|
99
|
+
current.lines.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hunks;
|
|
103
|
+
}
|
|
104
|
+
export function assembleDiff(nameStatus, numstat, raw) {
|
|
105
|
+
const files = parseNameStatus(nameStatus);
|
|
106
|
+
applyNumstat(files, numstat);
|
|
107
|
+
return {
|
|
108
|
+
files: [...files.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
109
|
+
hunks: parseUnifiedDiff(raw),
|
|
110
|
+
raw,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=parse-diff.js.map
|
package/dist/propose.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { proposeWithAi } from "./ai.js";
|
|
2
|
+
import { proposeHeuristic } from "./heuristic.js";
|
|
3
|
+
export async function proposeMessage(diff, options, env = process.env) {
|
|
4
|
+
const heuristic = proposeHeuristic({
|
|
5
|
+
diff,
|
|
6
|
+
type: options.type,
|
|
7
|
+
scope: options.scope,
|
|
8
|
+
vibe: options.vibe,
|
|
9
|
+
});
|
|
10
|
+
if (options.preferAi === false)
|
|
11
|
+
return heuristic;
|
|
12
|
+
const hasKey = Boolean(env.OPENAI_API_KEY?.trim());
|
|
13
|
+
if (!hasKey)
|
|
14
|
+
return heuristic;
|
|
15
|
+
const ai = await proposeWithAi({
|
|
16
|
+
diff,
|
|
17
|
+
vibe: options.vibe,
|
|
18
|
+
type: options.type,
|
|
19
|
+
scope: options.scope,
|
|
20
|
+
}, env);
|
|
21
|
+
return ai ?? heuristic;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=propose.js.map
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProposedMessage, StagedDiff } from "./types.ts";
|
|
2
|
+
export declare function renderProposal(diff: StagedDiff, message: ProposedMessage, hints?: {
|
|
3
|
+
committed?: boolean;
|
|
4
|
+
piped?: boolean;
|
|
5
|
+
}): string;
|
|
6
|
+
export declare function renderError(message: string): string;
|
|
7
|
+
export declare function renderVersion(version: string): string;
|