@webywz/vibe-commit 0.0.0-stage → 0.1.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 +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 +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 webywz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vibe-commit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Stylish conventional commits. Offline-first. AI-optional.**
|
|
4
|
+
|
|
5
|
+
Stage your files, run one command, get a commit message that looks like you meant it.
|
|
6
|
+
|
|
7
|
+
一条命令,根据暂存区 diff 写出有品味的 [Conventional Commits](https://www.conventionalcommits.org/)。**无 API Key 也能用**,有 Key 时再可选 AI。
|
|
8
|
+
|
|
9
|
+
[](https://github.com/webywz/vibe-commit/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/vibe-commit)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Demo
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
$ git add src/heuristic.ts
|
|
19
|
+
$ vibe-commit
|
|
20
|
+
|
|
21
|
+
vibe-commit · heuristic · feat
|
|
22
|
+
|
|
23
|
+
1 file · +124 −8
|
|
24
|
+
|
|
25
|
+
M src/heuristic.ts +124 −8
|
|
26
|
+
|
|
27
|
+
proposed
|
|
28
|
+
|
|
29
|
+
feat(heuristic): infer conventional commit type from staged diffs
|
|
30
|
+
|
|
31
|
+
next vibe-commit --commit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
$ vibe-commit --vibe playful --commit
|
|
36
|
+
feat(cli): ship offline heuristic commit messages — quietly useful
|
|
37
|
+
committed
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Pipe-friendly when stdout is not a TTY:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git commit -m "$(vibe-commit --dry-run)"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Install · 安装
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install -g vibe-commit
|
|
52
|
+
# or, no install:
|
|
53
|
+
npx vibe-commit
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires **Node.js 18.18+** and `git`.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm i -g vibe-commit # 全局安装
|
|
60
|
+
npx vibe-commit # 一次性运行
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Usage · 用法
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git add -p # stage the work you actually want to commit
|
|
69
|
+
vibe-commit # inspect the staged diff, print a proposal
|
|
70
|
+
vibe-commit --commit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Flag | What it does · 作用 |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `--commit` | Run `git commit` with the proposed message · 用生成的信息执行提交 |
|
|
76
|
+
| `--dry-run` | Print only; never commit (default, and wins over `--commit`) |
|
|
77
|
+
| `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
|
|
78
|
+
| `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
|
|
79
|
+
| `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
|
|
80
|
+
| `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set |
|
|
81
|
+
| `--json` | Machine-readable proposal |
|
|
82
|
+
| `-h`, `--help` | Help |
|
|
83
|
+
| `-v`, `--version` | Version |
|
|
84
|
+
|
|
85
|
+
### Vibes
|
|
86
|
+
|
|
87
|
+
- **pro** — default. Imperative, review-ready, conventional.
|
|
88
|
+
- **terse** — headline only, filler words gone.
|
|
89
|
+
- **playful** — a little personality, still safe for a real repo.
|
|
90
|
+
|
|
91
|
+
默认 **pro**:专业、祈使句、可直接进 code review。`terse` 更短;`playful` 带一点俏皮,但不会变成段子。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## How it works · 原理
|
|
96
|
+
|
|
97
|
+
1. Read **staged** changes only (`git diff --cached`). Unstaged noise is ignored.
|
|
98
|
+
2. **Offline heuristic** (always on): file paths + hunk keywords + added identifiers → `type`, `scope`, `subject`.
|
|
99
|
+
- `README` / `docs/` → `docs`
|
|
100
|
+
- `*.test.ts` / `test/` → `test`
|
|
101
|
+
- `.github/workflows` → `ci`
|
|
102
|
+
- lockfiles / `package.json` → `chore`
|
|
103
|
+
- `fix` / `bug` / `crash` in the diff → `fix`
|
|
104
|
+
- new files / `export function` → `feat`
|
|
105
|
+
3. If `OPENAI_API_KEY` is set, ask an OpenAI-compatible model and **fall back to the heuristic** on any failure.
|
|
106
|
+
4. Print a proposal. With `--commit`, run `git commit` (headline + optional body as two `-m` flags — no shell interpolation).
|
|
107
|
+
|
|
108
|
+
只看 **已暂存** 的 diff。启发式始终可用;配置了 API Key 才走模型,失败自动回退。
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## AI (optional)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export OPENAI_API_KEY=sk-...
|
|
116
|
+
# optional:
|
|
117
|
+
export OPENAI_MODEL=gpt-4o-mini
|
|
118
|
+
export OPENAI_BASE_URL=https://api.openai.com/v1 # any compatible gateway
|
|
119
|
+
export VIBE_COMMIT_VIBE=pro
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Copy `.env.example` if you keep secrets in a file. vibe-commit never sends unstaged files, and it truncates huge diffs.
|
|
123
|
+
|
|
124
|
+
兼容 OpenAI Chat Completions。没 Key、超时、4xx/5xx → 静默回退到离线启发式,CLI 仍然能给出一条能用的信息。
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Library
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { proposeHeuristic, formatCommit } from "vibe-commit";
|
|
132
|
+
|
|
133
|
+
const message = proposeHeuristic({ diff, vibe: "pro" });
|
|
134
|
+
console.log(formatCommit(message));
|
|
135
|
+
// feat(auth): add login
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Develop · 开发
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/webywz/vibe-commit
|
|
144
|
+
cd vibe-commit
|
|
145
|
+
npm install
|
|
146
|
+
npm test
|
|
147
|
+
npm run build
|
|
148
|
+
node dist/cli.js --help
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Script | |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `npm test` | Unit + e2e tests (`node:test` + `tsx`) |
|
|
154
|
+
| `npm run build` | Emit `dist/` |
|
|
155
|
+
| `npm run typecheck` | `tsc --noEmit` |
|
|
156
|
+
|
|
157
|
+
MIT © webywz — see [LICENSE](./LICENSE).
|
package/dist/ai.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProposedMessage, StagedDiff, Vibe } from "./types.ts";
|
|
2
|
+
import type { CommitType } from "./types.ts";
|
|
3
|
+
export interface AiContext {
|
|
4
|
+
diff: StagedDiff;
|
|
5
|
+
vibe: Vibe;
|
|
6
|
+
type?: CommitType;
|
|
7
|
+
scope?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function proposeWithAi(context: AiContext, env?: NodeJS.ProcessEnv): Promise<ProposedMessage | null>;
|
package/dist/ai.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseCommit } from "./format.js";
|
|
2
|
+
const MAX_DIFF_CHARS = 12_000;
|
|
3
|
+
export async function proposeWithAi(context, env = process.env) {
|
|
4
|
+
const apiKey = env.OPENAI_API_KEY?.trim();
|
|
5
|
+
if (!apiKey)
|
|
6
|
+
return null;
|
|
7
|
+
const baseUrl = (env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
8
|
+
const model = env.OPENAI_MODEL?.trim() || "gpt-4o-mini";
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": "application/json",
|
|
14
|
+
authorization: `Bearer ${apiKey}`,
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
model,
|
|
18
|
+
temperature: context.vibe === "playful" ? 0.7 : 0.2,
|
|
19
|
+
messages: [
|
|
20
|
+
{ role: "system", content: systemPrompt(context.vibe) },
|
|
21
|
+
{ role: "user", content: userPrompt(context) },
|
|
22
|
+
],
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
return null;
|
|
27
|
+
const payload = (await response.json());
|
|
28
|
+
const content = payload.choices?.[0]?.message?.content;
|
|
29
|
+
if (!content)
|
|
30
|
+
return null;
|
|
31
|
+
const parsed = parseCommit(content, "ai");
|
|
32
|
+
if (!parsed)
|
|
33
|
+
return null;
|
|
34
|
+
if (context.type)
|
|
35
|
+
parsed.type = context.type;
|
|
36
|
+
if (context.scope)
|
|
37
|
+
parsed.scope = context.scope;
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function systemPrompt(vibe) {
|
|
45
|
+
const vibeLine = vibe === "playful"
|
|
46
|
+
? "Tone: witty, still professional; no memes, no emoji, no slang that would embarrass a code review."
|
|
47
|
+
: vibe === "terse"
|
|
48
|
+
? "Tone: as short as possible. Headline only, no body."
|
|
49
|
+
: "Tone: clear, professional, imperative mood.";
|
|
50
|
+
return `You write conventional commit messages.
|
|
51
|
+
Return only the commit message, no quotes, no markdown fences.
|
|
52
|
+
Format: type(scope): subject
|
|
53
|
+
Optional body after a blank line.
|
|
54
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
55
|
+
Subject: lowercase, imperative, no trailing period, <= 72 chars.
|
|
56
|
+
${vibeLine}`;
|
|
57
|
+
}
|
|
58
|
+
function userPrompt(context) {
|
|
59
|
+
const files = context.diff.files
|
|
60
|
+
.map((file) => {
|
|
61
|
+
const rename = file.fromPath ? ` (from ${file.fromPath})` : "";
|
|
62
|
+
return `${file.status} ${file.path}${rename} +${file.additions} -${file.deletions}`;
|
|
63
|
+
})
|
|
64
|
+
.join("\n");
|
|
65
|
+
const hints = [
|
|
66
|
+
context.type ? `Forced type: ${context.type}` : null,
|
|
67
|
+
context.scope ? `Forced scope: ${context.scope}` : null,
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join("\n");
|
|
71
|
+
const raw = context.diff.raw.slice(0, MAX_DIFF_CHARS);
|
|
72
|
+
return [
|
|
73
|
+
hints,
|
|
74
|
+
"Staged files:",
|
|
75
|
+
files,
|
|
76
|
+
"",
|
|
77
|
+
"Diff:",
|
|
78
|
+
raw || "(binary or empty hunks)",
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=ai.js.map
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import { formatCommit } from "./format.js";
|
|
7
|
+
import { commitWithMessage, GitError, inspectStaged } from "./git.js";
|
|
8
|
+
import { helpText, parseArgs, UsageError } from "./parse-args.js";
|
|
9
|
+
import { proposeMessage } from "./propose.js";
|
|
10
|
+
import { renderError, renderProposal, renderVersion } from "./render.js";
|
|
11
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
12
|
+
let options;
|
|
13
|
+
try {
|
|
14
|
+
options = parseArgs(argv);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof UsageError) {
|
|
18
|
+
process.stderr.write(`${renderError(error.message)}\n\n${helpText()}`);
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (options.help) {
|
|
24
|
+
process.stdout.write(helpText());
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if (options.version) {
|
|
28
|
+
process.stdout.write(`${renderVersion(readVersion())}\n`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
const piped = !process.stdout.isTTY;
|
|
32
|
+
try {
|
|
33
|
+
const diff = inspectStaged();
|
|
34
|
+
const message = await proposeMessage(diff, {
|
|
35
|
+
type: options.type,
|
|
36
|
+
scope: options.scope,
|
|
37
|
+
vibe: options.vibe,
|
|
38
|
+
preferAi: !options.noAi,
|
|
39
|
+
});
|
|
40
|
+
const formatted = formatCommit(message);
|
|
41
|
+
const shouldCommit = options.commit && !options.dryRun;
|
|
42
|
+
if (options.json) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ ...message, formatted }, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
else if (piped) {
|
|
46
|
+
process.stdout.write(`${formatted}\n`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
process.stdout.write(renderProposal(diff, message));
|
|
50
|
+
}
|
|
51
|
+
if (shouldCommit) {
|
|
52
|
+
commitWithMessage(formatted);
|
|
53
|
+
if (!piped && !options.json) {
|
|
54
|
+
process.stdout.write(` ${pc.green("committed")}\n\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const text = error instanceof GitError || error instanceof Error
|
|
61
|
+
? error.message
|
|
62
|
+
: "Unexpected error";
|
|
63
|
+
process.stderr.write(`${renderError(text)}\n`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function readVersion() {
|
|
68
|
+
try {
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const pkgPath = join(here, "..", "package.json");
|
|
71
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
72
|
+
return pkg.version ?? "0.0.0";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "0.0.0";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isDirectRun() {
|
|
79
|
+
const entry = process.argv[1];
|
|
80
|
+
if (!entry)
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (isDirectRun()) {
|
|
90
|
+
main().then((code) => process.exit(code), (error) => {
|
|
91
|
+
process.stderr.write(`${renderError(error instanceof Error ? error.message : "Unexpected error")}\n`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Engine, type ProposedMessage } from "./types.ts";
|
|
2
|
+
export declare function formatCommit(message: ProposedMessage): string;
|
|
3
|
+
export declare function normalizeSubject(subject: string): string;
|
|
4
|
+
export declare function clip(text: string, max: number): string;
|
|
5
|
+
export declare function parseCommit(text: string, engine?: Engine): ProposedMessage | null;
|
|
6
|
+
export declare function stripFences(text: string): string;
|
|
7
|
+
export declare function splitHeadline(formatted: string): {
|
|
8
|
+
headline: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
};
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { COMMIT_TYPES, } from "./types.js";
|
|
2
|
+
const TYPE_PATTERN = COMMIT_TYPES.join("|");
|
|
3
|
+
const HEADER_RE = new RegExp(`^(${TYPE_PATTERN})(?:\\(([a-zA-Z0-9][\\w./-]{0,62})\\))?(!)?:\\s*(.+)$`);
|
|
4
|
+
export function formatCommit(message) {
|
|
5
|
+
const bang = message.breaking ? "!" : "";
|
|
6
|
+
const head = message.scope
|
|
7
|
+
? `${message.type}(${message.scope})${bang}: ${normalizeSubject(message.subject)}`
|
|
8
|
+
: `${message.type}${bang}: ${normalizeSubject(message.subject)}`;
|
|
9
|
+
const body = message.body?.trim();
|
|
10
|
+
return body ? `${head}\n\n${body}` : head;
|
|
11
|
+
}
|
|
12
|
+
export function normalizeSubject(subject) {
|
|
13
|
+
let text = subject.replace(/\s+/g, " ").trim();
|
|
14
|
+
text = text.replace(/^[`'"]+|[`'"]+$/g, "");
|
|
15
|
+
text = text.replace(/\.+$/, "");
|
|
16
|
+
if (!text)
|
|
17
|
+
return "update project files";
|
|
18
|
+
if (/^[A-Z][a-z]/.test(text) && !/^[A-Z]{2,}/.test(text)) {
|
|
19
|
+
text = text.charAt(0).toLowerCase() + text.slice(1);
|
|
20
|
+
}
|
|
21
|
+
return clip(text, 72);
|
|
22
|
+
}
|
|
23
|
+
export function clip(text, max) {
|
|
24
|
+
if (text.length <= max)
|
|
25
|
+
return text;
|
|
26
|
+
const sliced = text.slice(0, max - 1);
|
|
27
|
+
const atWord = sliced.lastIndexOf(" ");
|
|
28
|
+
const base = atWord >= 40 ? sliced.slice(0, atWord) : sliced;
|
|
29
|
+
return `${base.replace(/[ ,;:.-]+$/, "")}…`;
|
|
30
|
+
}
|
|
31
|
+
export function parseCommit(text, engine = "heuristic") {
|
|
32
|
+
const cleaned = stripFences(text).trim();
|
|
33
|
+
if (!cleaned)
|
|
34
|
+
return null;
|
|
35
|
+
const lines = cleaned.split(/\r?\n/);
|
|
36
|
+
const headerLine = lines.find((line) => HEADER_RE.test(line.trim()));
|
|
37
|
+
if (!headerLine)
|
|
38
|
+
return null;
|
|
39
|
+
const match = headerLine.trim().match(HEADER_RE);
|
|
40
|
+
if (!match)
|
|
41
|
+
return null;
|
|
42
|
+
const type = match[1];
|
|
43
|
+
const scope = match[2];
|
|
44
|
+
const breaking = Boolean(match[3]);
|
|
45
|
+
const subject = normalizeSubject(match[4] ?? "");
|
|
46
|
+
const headerIndex = lines.findIndex((line) => line.trim() === headerLine.trim());
|
|
47
|
+
const rest = lines
|
|
48
|
+
.slice(headerIndex + 1)
|
|
49
|
+
.join("\n")
|
|
50
|
+
.trim();
|
|
51
|
+
return {
|
|
52
|
+
type,
|
|
53
|
+
scope,
|
|
54
|
+
subject,
|
|
55
|
+
body: rest || undefined,
|
|
56
|
+
breaking,
|
|
57
|
+
engine,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function stripFences(text) {
|
|
61
|
+
return text
|
|
62
|
+
.replace(/^\s*```(?:\w+)?\s*/m, "")
|
|
63
|
+
.replace(/\s*```\s*$/m, "")
|
|
64
|
+
.replace(/^\s*(?:commit message|message)\s*:\s*/i, "")
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
export function splitHeadline(formatted) {
|
|
68
|
+
const [headline = formatted, ...rest] = formatted.split("\n");
|
|
69
|
+
const body = rest.join("\n").trim();
|
|
70
|
+
return { headline, body: body || undefined };
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=format.js.map
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StagedDiff } from "./types.ts";
|
|
2
|
+
export declare class GitError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function inspectStaged(cwd?: string): StagedDiff;
|
|
6
|
+
export declare function commitWithMessage(message: string, cwd?: string): string;
|
|
7
|
+
export declare function assertRepo(cwd?: string): void;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { assembleDiff } from "./parse-diff.js";
|
|
3
|
+
export class GitError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "GitError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function inspectStaged(cwd = process.cwd()) {
|
|
10
|
+
assertRepo(cwd);
|
|
11
|
+
const nameStatus = git(["diff", "--cached", "--name-status", "-M"], cwd);
|
|
12
|
+
const numstat = git(["diff", "--cached", "--numstat", "-M"], cwd);
|
|
13
|
+
const raw = git(["diff", "--cached", "-M", "--unified=3"], cwd);
|
|
14
|
+
const diff = assembleDiff(nameStatus, numstat, raw);
|
|
15
|
+
if (diff.files.length === 0) {
|
|
16
|
+
throw new GitError("No staged changes. Stage files first: git add -p (or git add <path>)");
|
|
17
|
+
}
|
|
18
|
+
return diff;
|
|
19
|
+
}
|
|
20
|
+
export function commitWithMessage(message, cwd = process.cwd()) {
|
|
21
|
+
assertRepo(cwd);
|
|
22
|
+
const [headline, ...rest] = message.split("\n");
|
|
23
|
+
const body = rest.join("\n").trim();
|
|
24
|
+
const args = ["commit", "-m", headline ?? message];
|
|
25
|
+
if (body)
|
|
26
|
+
args.push("-m", body);
|
|
27
|
+
return git(args, cwd);
|
|
28
|
+
}
|
|
29
|
+
export function assertRepo(cwd = process.cwd()) {
|
|
30
|
+
try {
|
|
31
|
+
const inside = git(["rev-parse", "--is-inside-work-tree"], cwd).trim();
|
|
32
|
+
if (inside !== "true")
|
|
33
|
+
throw new Error("not a repo");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new GitError("Not a git repository. Run this inside a project with git.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function git(args, cwd) {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync("git", args, {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const stderr = error && typeof error === "object" && "stderr" in error
|
|
49
|
+
? String(error.stderr)
|
|
50
|
+
: "";
|
|
51
|
+
const detail = stderr.trim() || (error instanceof Error ? error.message : "git failed");
|
|
52
|
+
throw new GitError(detail);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=git.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CommitType, DiffHunk, ProposedMessage, StagedDiff, StagedFile, Vibe } from "./types.ts";
|
|
2
|
+
export interface HeuristicInput {
|
|
3
|
+
diff: StagedDiff;
|
|
4
|
+
type?: CommitType;
|
|
5
|
+
scope?: string;
|
|
6
|
+
vibe: Vibe;
|
|
7
|
+
}
|
|
8
|
+
export declare function proposeHeuristic(input: HeuristicInput): ProposedMessage;
|
|
9
|
+
export declare function inferType(diff: StagedDiff): CommitType;
|
|
10
|
+
export declare function primaryRole(path: string): CommitType;
|
|
11
|
+
export declare function classifyPath(path: string): CommitType[];
|
|
12
|
+
export declare function inferScope(files: StagedFile[]): string | undefined;
|
|
13
|
+
export declare function inferSubject(diff: StagedDiff, type: CommitType, scope?: string): string;
|
|
14
|
+
export declare function collectAddedIdentifiers(hunks: DiffHunk[]): string[];
|
|
15
|
+
export declare function stem(path: string): string;
|