bunset 0.0.3 → 1.0.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/CHANGELOG.md +39 -0
- package/README.md +20 -11
- package/package.json +4 -1
- package/src/changelog.test.ts +63 -11
- package/src/changelog.ts +15 -0
- package/src/cli.ts +10 -0
- package/src/commits.test.ts +89 -12
- package/src/commits.ts +32 -8
- package/src/git.ts +14 -7
- package/src/index.ts +13 -1
- package/src/types.ts +3 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.1
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- missing README
|
|
8
|
+
|
|
9
|
+
## 1.0.0
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- align with Conventional Commits 1.0.0 breaking change detection
|
|
14
|
+
|
|
15
|
+
## 0.0.3
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- automatically determine tag-prefix based on tag history
|
|
20
|
+
- add --debug option for detailed logging during execution
|
|
21
|
+
|
|
22
|
+
## 0.0.2
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
- add --tag-prefix option and default --commit/--tag to true
|
|
27
|
+
- enhance changelog generation for monorepos with per-package filtering
|
|
28
|
+
- add --dry-run option
|
|
29
|
+
- git tag is default
|
|
30
|
+
- add scopes, e.g. feaure(api): description
|
|
31
|
+
- user configurable sections and section ordering
|
|
32
|
+
- add new sections of changelog: refactor, style, docs, chore, ops, build, perf
|
|
33
|
+
- add local toml file for settings
|
|
34
|
+
|
|
35
|
+
## 0.0.1
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- Initial code for changeset
|
package/README.md
CHANGED
|
@@ -2,25 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
A zero-dependency CLI tool that automates version bumping and changelog generation for Bun workspace monorepos and single packages.
|
|
4
4
|
|
|
5
|
-
It reads git commit messages since the last tag, categorizes them by type prefix (`
|
|
5
|
+
It reads git commit messages since the last tag, categorizes them by type prefix (`feat:`, `fix:`, `test:`), bumps semantic versions, updates `CHANGELOG.md` per package, and optionally commits and tags the result.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
bun
|
|
10
|
+
bun add bunset
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Or link it globally:
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
bun link
|
|
23
|
-
bunset [options]
|
|
16
|
+
bunx bunset [options]
|
|
24
17
|
```
|
|
25
18
|
|
|
26
19
|
### Options
|
|
@@ -72,7 +65,22 @@ All these styles work:
|
|
|
72
65
|
|
|
73
66
|
An optional scope groups commits under a `#### scope` sub-heading within their type section in the changelog.
|
|
74
67
|
|
|
75
|
-
|
|
68
|
+
#### Breaking Changes
|
|
69
|
+
|
|
70
|
+
Append `!` before the colon (or closing bracket) to mark a commit as a breaking change, per [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/):
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
feat!: Remove old API
|
|
74
|
+
feat(auth)!: Change token format
|
|
75
|
+
[feat!] Remove old API
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer in the commit body is also detected.
|
|
79
|
+
|
|
80
|
+
Breaking commits are collected into a **Breaking Changes** section at the top of each changelog entry, regardless of which `--sections` are configured. If breaking changes are detected and the bump type is not `major`, a warning is printed.
|
|
81
|
+
|
|
82
|
+
#### Recognized Type Keywords
|
|
83
|
+
|
|
76
84
|
- `feat`, `feature` — listed under **Features**
|
|
77
85
|
- `fix`, `bug`, `bugfix` — listed under **Bug Fixes**
|
|
78
86
|
- `refactor` — listed under **Refactors**
|
|
@@ -83,6 +91,7 @@ Recognized type keywords:
|
|
|
83
91
|
- `build` — listed under **Build**
|
|
84
92
|
- `ops` — listed under **Ops**
|
|
85
93
|
- `chore` — listed under **Chores**
|
|
94
|
+
- `ci` — listed under **CI**
|
|
86
95
|
|
|
87
96
|
Only sections listed in `--sections` (or the `sections` config option) appear in the changelog. The default is `feat,fix,perf`.
|
|
88
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunset",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"bun": "^1.3"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"CHANGELOG.md",
|
|
19
22
|
"src"
|
|
20
23
|
],
|
|
21
24
|
"publishConfig": {
|
package/src/changelog.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ function emptyGroups(): GroupedCommits {
|
|
|
18
18
|
build: [],
|
|
19
19
|
ops: [],
|
|
20
20
|
chore: [],
|
|
21
|
+
ci: [],
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -26,13 +27,13 @@ describe("buildChangelogEntry", () => {
|
|
|
26
27
|
const groups: GroupedCommits = {
|
|
27
28
|
...emptyGroups(),
|
|
28
29
|
feature: [
|
|
29
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, files: [], description: "Add login" },
|
|
30
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "Add login" },
|
|
30
31
|
],
|
|
31
32
|
bugfix: [
|
|
32
|
-
{ hash: "b", message: "", type: "bugfix", commitScope: null, files: [], description: "Fix crash" },
|
|
33
|
+
{ hash: "b", message: "", type: "bugfix", commitScope: null, breaking: false, files: [], description: "Fix crash" },
|
|
33
34
|
],
|
|
34
35
|
test: [
|
|
35
|
-
{ hash: "c", message: "", type: "test", commitScope: null, files: [], description: "Add tests" },
|
|
36
|
+
{ hash: "c", message: "", type: "test", commitScope: null, breaking: false, files: [], description: "Add tests" },
|
|
36
37
|
],
|
|
37
38
|
};
|
|
38
39
|
const entry = buildChangelogEntry("1.2.0", groups, [], COMMIT_TYPES);
|
|
@@ -49,10 +50,10 @@ describe("buildChangelogEntry", () => {
|
|
|
49
50
|
const groups: GroupedCommits = {
|
|
50
51
|
...emptyGroups(),
|
|
51
52
|
feature: [
|
|
52
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, files: [], description: "New thing" },
|
|
53
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "New thing" },
|
|
53
54
|
],
|
|
54
55
|
test: [
|
|
55
|
-
{ hash: "b", message: "", type: "test", commitScope: null, files: [], description: "Add tests" },
|
|
56
|
+
{ hash: "b", message: "", type: "test", commitScope: null, breaking: false, files: [], description: "Add tests" },
|
|
56
57
|
],
|
|
57
58
|
};
|
|
58
59
|
const entry = buildChangelogEntry("1.0.0", groups);
|
|
@@ -64,7 +65,7 @@ describe("buildChangelogEntry", () => {
|
|
|
64
65
|
const groups: GroupedCommits = {
|
|
65
66
|
...emptyGroups(),
|
|
66
67
|
feature: [
|
|
67
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, files: [], description: "New thing" },
|
|
68
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "New thing" },
|
|
68
69
|
],
|
|
69
70
|
};
|
|
70
71
|
const entry = buildChangelogEntry("1.0.0", groups);
|
|
@@ -77,10 +78,10 @@ describe("buildChangelogEntry", () => {
|
|
|
77
78
|
const groups: GroupedCommits = {
|
|
78
79
|
...emptyGroups(),
|
|
79
80
|
feature: [
|
|
80
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, files: [], description: "Global feature" },
|
|
81
|
-
{ hash: "b", message: "", type: "feature", commitScope: "auth", files: [], description: "Add login" },
|
|
82
|
-
{ hash: "c", message: "", type: "feature", commitScope: "auth", files: [], description: "Add logout" },
|
|
83
|
-
{ hash: "d", message: "", type: "feature", commitScope: "ui", files: [], description: "New dashboard" },
|
|
81
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "Global feature" },
|
|
82
|
+
{ hash: "b", message: "", type: "feature", commitScope: "auth", breaking: false, files: [], description: "Add login" },
|
|
83
|
+
{ hash: "c", message: "", type: "feature", commitScope: "auth", breaking: false, files: [], description: "Add logout" },
|
|
84
|
+
{ hash: "d", message: "", type: "feature", commitScope: "ui", breaking: false, files: [], description: "New dashboard" },
|
|
84
85
|
],
|
|
85
86
|
};
|
|
86
87
|
const entry = buildChangelogEntry("1.0.0", groups);
|
|
@@ -102,7 +103,7 @@ describe("buildChangelogEntry", () => {
|
|
|
102
103
|
const groups: GroupedCommits = {
|
|
103
104
|
...emptyGroups(),
|
|
104
105
|
bugfix: [
|
|
105
|
-
{ hash: "a", message: "", type: "bugfix", commitScope: "parser", files: [], description: "Fix edge case" },
|
|
106
|
+
{ hash: "a", message: "", type: "bugfix", commitScope: "parser", breaking: false, files: [], description: "Fix edge case" },
|
|
106
107
|
],
|
|
107
108
|
};
|
|
108
109
|
const entry = buildChangelogEntry("1.0.0", groups);
|
|
@@ -118,6 +119,57 @@ describe("buildChangelogEntry", () => {
|
|
|
118
119
|
expect(entry).toContain("### Updated Dependencies");
|
|
119
120
|
expect(entry).toContain("- `lodash`: 4.18.0");
|
|
120
121
|
});
|
|
122
|
+
|
|
123
|
+
test("renders Breaking Changes section first when breaking commits exist", () => {
|
|
124
|
+
const groups: GroupedCommits = {
|
|
125
|
+
...emptyGroups(),
|
|
126
|
+
feature: [
|
|
127
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: true, files: [], description: "Remove old API" },
|
|
128
|
+
{ hash: "b", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "Add new API" },
|
|
129
|
+
],
|
|
130
|
+
bugfix: [
|
|
131
|
+
{ hash: "c", message: "", type: "bugfix", commitScope: "parser", breaking: true, files: [], description: "Change return type" },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const entry = buildChangelogEntry("2.0.0", groups, [], ["feature", "bugfix"]);
|
|
135
|
+
expect(entry).toContain("### Breaking Changes");
|
|
136
|
+
expect(entry).toContain("- **features**: Remove old API");
|
|
137
|
+
expect(entry).toContain("- **bug fixes(parser)**: Change return type");
|
|
138
|
+
|
|
139
|
+
// Breaking Changes section appears before Features
|
|
140
|
+
const breakingIdx = entry.indexOf("### Breaking Changes");
|
|
141
|
+
const featuresIdx = entry.indexOf("### Features");
|
|
142
|
+
expect(breakingIdx).toBeLessThan(featuresIdx);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("breaking section appears even when commit type is not in sections", () => {
|
|
146
|
+
const groups: GroupedCommits = {
|
|
147
|
+
...emptyGroups(),
|
|
148
|
+
chore: [
|
|
149
|
+
{ hash: "a", message: "", type: "chore", commitScope: null, breaking: true, files: [], description: "Drop Node 14 support" },
|
|
150
|
+
],
|
|
151
|
+
feature: [
|
|
152
|
+
{ hash: "b", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "Add feature" },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
// sections only includes "feature", not "chore"
|
|
156
|
+
const entry = buildChangelogEntry("3.0.0", groups, [], ["feature"]);
|
|
157
|
+
expect(entry).toContain("### Breaking Changes");
|
|
158
|
+
expect(entry).toContain("- **chores**: Drop Node 14 support");
|
|
159
|
+
// Chore section itself should not appear
|
|
160
|
+
expect(entry).not.toContain("### Chores");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("no Breaking Changes section when no breaking commits", () => {
|
|
164
|
+
const groups: GroupedCommits = {
|
|
165
|
+
...emptyGroups(),
|
|
166
|
+
feature: [
|
|
167
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, breaking: false, files: [], description: "Add feature" },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
const entry = buildChangelogEntry("1.1.0", groups);
|
|
171
|
+
expect(entry).not.toContain("### Breaking Changes");
|
|
172
|
+
});
|
|
121
173
|
});
|
|
122
174
|
|
|
123
175
|
describe("writeChangelog", () => {
|
package/src/changelog.ts
CHANGED
|
@@ -12,6 +12,7 @@ const SECTION_HEADINGS: Record<CommitType, string> = {
|
|
|
12
12
|
build: "Build",
|
|
13
13
|
ops: "Ops",
|
|
14
14
|
chore: "Chores",
|
|
15
|
+
ci: "CI",
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export function buildChangelogEntry(
|
|
@@ -22,6 +23,20 @@ export function buildChangelogEntry(
|
|
|
22
23
|
): string {
|
|
23
24
|
const lines: string[] = [`## ${version}`, ""];
|
|
24
25
|
|
|
26
|
+
// Collect breaking changes across all types
|
|
27
|
+
const breakingCommits = Object.values(groups).flat().filter((c) => c.breaking);
|
|
28
|
+
if (breakingCommits.length > 0) {
|
|
29
|
+
lines.push("### Breaking Changes", "");
|
|
30
|
+
for (const c of breakingCommits) {
|
|
31
|
+
const typeLabel = c.type ? SECTION_HEADINGS[c.type].toLowerCase() : "unknown";
|
|
32
|
+
const prefix = c.commitScope
|
|
33
|
+
? `**${typeLabel}(${c.commitScope})**`
|
|
34
|
+
: `**${typeLabel}**`;
|
|
35
|
+
lines.push(`- ${prefix}: ${c.description}`);
|
|
36
|
+
}
|
|
37
|
+
lines.push("");
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
for (const type of sections) {
|
|
26
41
|
const commits = groups[type];
|
|
27
42
|
if (commits.length === 0) continue;
|
package/src/cli.ts
CHANGED
|
@@ -37,6 +37,15 @@ Commit format:
|
|
|
37
37
|
|
|
38
38
|
An optional scope groups commits under a sub-heading in the changelog.
|
|
39
39
|
|
|
40
|
+
Breaking changes (Conventional Commits 1.0.0):
|
|
41
|
+
Append ! before the colon to mark a breaking change:
|
|
42
|
+
feat!: Remove old API
|
|
43
|
+
feat(auth)!: Change token format
|
|
44
|
+
[feat!] Remove old API
|
|
45
|
+
Or include a "BREAKING CHANGE:" footer in the commit body.
|
|
46
|
+
Breaking commits are collected into a "Breaking Changes" section at the
|
|
47
|
+
top of the changelog entry. A warning is printed if the bump is not major.
|
|
48
|
+
|
|
40
49
|
Recognized type keywords:
|
|
41
50
|
feat, feature → Features
|
|
42
51
|
fix, bug, bugfix → Bug Fixes
|
|
@@ -48,6 +57,7 @@ Commit format:
|
|
|
48
57
|
build → Build
|
|
49
58
|
ops → Ops
|
|
50
59
|
chore → Chores
|
|
60
|
+
ci → CI
|
|
51
61
|
Only sections listed in --sections (or config) are included in the changelog.
|
|
52
62
|
Default sections: feat, fix, perf.
|
|
53
63
|
|
package/src/commits.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ describe("parseCommit", () => {
|
|
|
8
8
|
const result = parseCommit("abc123", "[feat] Add user auth");
|
|
9
9
|
expect(result.type).toBe("feature");
|
|
10
10
|
expect(result.commitScope).toBeNull();
|
|
11
|
+
expect(result.breaking).toBe(false);
|
|
11
12
|
expect(result.files).toEqual([]);
|
|
12
13
|
expect(result.description).toBe("Add user auth");
|
|
13
14
|
});
|
|
@@ -204,18 +205,94 @@ describe("parseCommit", () => {
|
|
|
204
205
|
const result = parseCommit("vwx234", "[feat No closing bracket");
|
|
205
206
|
expect(result.type).toBeNull();
|
|
206
207
|
});
|
|
208
|
+
|
|
209
|
+
// Breaking change `!` marker
|
|
210
|
+
test("parses feat!: as breaking", () => {
|
|
211
|
+
const result = parseCommit("brk1", "feat!: breaking change");
|
|
212
|
+
expect(result.type).toBe("feature");
|
|
213
|
+
expect(result.breaking).toBe(true);
|
|
214
|
+
expect(result.description).toBe("breaking change");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("parses feat(auth)!: as breaking with scope", () => {
|
|
218
|
+
const result = parseCommit("brk2", "feat(auth)!: breaking");
|
|
219
|
+
expect(result.type).toBe("feature");
|
|
220
|
+
expect(result.commitScope).toBe("auth");
|
|
221
|
+
expect(result.breaking).toBe(true);
|
|
222
|
+
expect(result.description).toBe("breaking");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("parses [feat!] as breaking", () => {
|
|
226
|
+
const result = parseCommit("brk3", "[feat!] breaking");
|
|
227
|
+
expect(result.type).toBe("feature");
|
|
228
|
+
expect(result.breaking).toBe(true);
|
|
229
|
+
expect(result.description).toBe("breaking");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("parses [feat(auth)!]: as breaking with scope", () => {
|
|
233
|
+
const result = parseCommit("brk4", "[feat(auth)!]: breaking");
|
|
234
|
+
expect(result.type).toBe("feature");
|
|
235
|
+
expect(result.commitScope).toBe("auth");
|
|
236
|
+
expect(result.breaking).toBe(true);
|
|
237
|
+
expect(result.description).toBe("breaking");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("parses [feat(auth)!] as breaking with scope and no colon", () => {
|
|
241
|
+
const result = parseCommit("brk4b", "[feat(auth)!] breaking");
|
|
242
|
+
expect(result.type).toBe("feature");
|
|
243
|
+
expect(result.commitScope).toBe("auth");
|
|
244
|
+
expect(result.breaking).toBe(true);
|
|
245
|
+
expect(result.description).toBe("breaking");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("parses fix!: as breaking", () => {
|
|
249
|
+
const result = parseCommit("brk5", "fix!: desc");
|
|
250
|
+
expect(result.type).toBe("bugfix");
|
|
251
|
+
expect(result.breaking).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// BREAKING CHANGE footer in body
|
|
255
|
+
test("detects BREAKING CHANGE: in body", () => {
|
|
256
|
+
const result = parseCommit("brk6", "feat: add feature", "BREAKING CHANGE: old API removed");
|
|
257
|
+
expect(result.type).toBe("feature");
|
|
258
|
+
expect(result.breaking).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("detects BREAKING-CHANGE: in body", () => {
|
|
262
|
+
const result = parseCommit("brk7", "feat: add feature", "BREAKING-CHANGE: old API removed");
|
|
263
|
+
expect(result.type).toBe("feature");
|
|
264
|
+
expect(result.breaking).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("body without breaking footer is not breaking", () => {
|
|
268
|
+
const result = parseCommit("brk8", "feat: add feature", "Just some extra details");
|
|
269
|
+
expect(result.breaking).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ci type
|
|
273
|
+
test("parses ci: commit", () => {
|
|
274
|
+
const result = parseCommit("ci1", "ci: setup pipeline");
|
|
275
|
+
expect(result.type).toBe("ci");
|
|
276
|
+
expect(result.description).toBe("setup pipeline");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("parses [ci] commit", () => {
|
|
280
|
+
const result = parseCommit("ci2", "[ci] Add GitHub Actions");
|
|
281
|
+
expect(result.type).toBe("ci");
|
|
282
|
+
expect(result.description).toBe("Add GitHub Actions");
|
|
283
|
+
});
|
|
207
284
|
});
|
|
208
285
|
|
|
209
286
|
describe("groupCommits", () => {
|
|
210
287
|
test("groups commits by type", () => {
|
|
211
288
|
const commits: ParsedCommit[] = [
|
|
212
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, description: "F1", files: [] },
|
|
213
|
-
{ hash: "b", message: "", type: "bugfix", commitScope: null, description: "B1", files: [] },
|
|
214
|
-
{ hash: "c", message: "", type: "test", commitScope: null, description: "T1", files: [] },
|
|
215
|
-
{ hash: "d", message: "", type: "feature", commitScope: "auth", description: "F2", files: [] },
|
|
216
|
-
{ hash: "e", message: "", type: "refactor", commitScope: null, description: "R1", files: [] },
|
|
217
|
-
{ hash: "f", message: "", type: "chore", commitScope: null, description: "C1", files: [] },
|
|
218
|
-
{ hash: "g", message: "", type: null, commitScope: null, description: "Ignored", files: [] },
|
|
289
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, description: "F1", breaking: false, files: [] },
|
|
290
|
+
{ hash: "b", message: "", type: "bugfix", commitScope: null, description: "B1", breaking: false, files: [] },
|
|
291
|
+
{ hash: "c", message: "", type: "test", commitScope: null, description: "T1", breaking: false, files: [] },
|
|
292
|
+
{ hash: "d", message: "", type: "feature", commitScope: "auth", description: "F2", breaking: false, files: [] },
|
|
293
|
+
{ hash: "e", message: "", type: "refactor", commitScope: null, description: "R1", breaking: false, files: [] },
|
|
294
|
+
{ hash: "f", message: "", type: "chore", commitScope: null, description: "C1", breaking: false, files: [] },
|
|
295
|
+
{ hash: "g", message: "", type: null, commitScope: null, description: "Ignored", breaking: false, files: [] },
|
|
219
296
|
];
|
|
220
297
|
const groups = groupCommits(commits);
|
|
221
298
|
expect(groups.feature).toHaveLength(2);
|
|
@@ -237,10 +314,10 @@ describe("groupCommits", () => {
|
|
|
237
314
|
|
|
238
315
|
describe("filterCommitsForPackage", () => {
|
|
239
316
|
const commits: ParsedCommit[] = [
|
|
240
|
-
{ hash: "a", message: "", type: "feature", commitScope: null, description: "A feat", files: ["packages/a/src/index.ts"] },
|
|
241
|
-
{ hash: "b", message: "", type: "bugfix", commitScope: null, description: "B fix", files: ["packages/b/src/main.ts"] },
|
|
242
|
-
{ hash: "c", message: "", type: "feature", commitScope: null, description: "Both", files: ["packages/a/lib/x.ts", "packages/b/lib/y.ts"] },
|
|
243
|
-
{ hash: "d", message: "", type: "feature", commitScope: null, description: "No files", files: [] },
|
|
317
|
+
{ hash: "a", message: "", type: "feature", commitScope: null, description: "A feat", breaking: false, files: ["packages/a/src/index.ts"] },
|
|
318
|
+
{ hash: "b", message: "", type: "bugfix", commitScope: null, description: "B fix", breaking: false, files: ["packages/b/src/main.ts"] },
|
|
319
|
+
{ hash: "c", message: "", type: "feature", commitScope: null, description: "Both", breaking: false, files: ["packages/a/lib/x.ts", "packages/b/lib/y.ts"] },
|
|
320
|
+
{ hash: "d", message: "", type: "feature", commitScope: null, description: "No files", breaking: false, files: [] },
|
|
244
321
|
];
|
|
245
322
|
|
|
246
323
|
test("filters commits to only those touching the package", () => {
|
|
@@ -257,7 +334,7 @@ describe("filterCommitsForPackage", () => {
|
|
|
257
334
|
|
|
258
335
|
test("includes commits with no files (fallback)", () => {
|
|
259
336
|
const noFileCommits: ParsedCommit[] = [
|
|
260
|
-
{ hash: "x", message: "", type: "feature", commitScope: null, description: "X", files: [] },
|
|
337
|
+
{ hash: "x", message: "", type: "feature", commitScope: null, description: "X", breaking: false, files: [] },
|
|
261
338
|
];
|
|
262
339
|
const filtered = filterCommitsForPackage(noFileCommits, "/root/packages/a", "/root");
|
|
263
340
|
expect(filtered).toHaveLength(1);
|
package/src/commits.ts
CHANGED
|
@@ -16,6 +16,7 @@ const TYPE_MAP: Record<string, CommitType> = {
|
|
|
16
16
|
build: "build",
|
|
17
17
|
ops: "ops",
|
|
18
18
|
chore: "chore",
|
|
19
|
+
ci: "ci",
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export const COMMIT_TYPES: CommitType[] = [
|
|
@@ -29,34 +30,57 @@ export const COMMIT_TYPES: CommitType[] = [
|
|
|
29
30
|
"build",
|
|
30
31
|
"ops",
|
|
31
32
|
"chore",
|
|
33
|
+
"ci",
|
|
32
34
|
];
|
|
33
35
|
|
|
34
36
|
export function normalizeType(keyword: string): CommitType | null {
|
|
35
37
|
return TYPE_MAP[keyword.toLowerCase()] ?? null;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
// Matches: [type(scope)] desc, [type]: desc, type(scope)
|
|
39
|
-
const COMMIT_PATTERN = /^\[([^\]]+)\]:?\s*(.*)$|^(\w+(?:\([^)]*\))
|
|
40
|
+
// Matches: [type(scope)!] desc, [type]: desc, type(scope)!: desc, type: desc
|
|
41
|
+
const COMMIT_PATTERN = /^\[([^\]]+)\]:?\s*(.*)$|^(\w+(?:\([^)]*\))?!?):\s+(.*)$/;
|
|
40
42
|
const SCOPE_PATTERN = /^(\w+)\(([^)]*)\)$/;
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
const BREAKING_FOOTER_PATTERN = /^BREAKING[ -]CHANGE:\s/m;
|
|
45
|
+
|
|
46
|
+
export function parseCommit(hash: string, message: string, body?: string): ParsedCommit {
|
|
43
47
|
const trimmed = message.trim();
|
|
44
48
|
const match = COMMIT_PATTERN.exec(trimmed);
|
|
45
49
|
|
|
46
50
|
if (!match) {
|
|
47
|
-
return { hash, message: trimmed, type: null, commitScope: null, description: trimmed, files: [] };
|
|
51
|
+
return { hash, message: trimmed, type: null, commitScope: null, description: trimmed, breaking: false, files: [] };
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
let raw = (match[1] ?? match[3])!.trim();
|
|
51
55
|
const description = (match[2] ?? match[4])!.trim();
|
|
52
56
|
|
|
57
|
+
// Detect trailing `!` breaking marker
|
|
58
|
+
let breaking = false;
|
|
59
|
+
if (raw.endsWith("!")) {
|
|
60
|
+
breaking = true;
|
|
61
|
+
raw = raw.slice(0, -1);
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
const scopeMatch = SCOPE_PATTERN.exec(raw);
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
let keyword: string;
|
|
66
|
+
let commitScope: string | null;
|
|
67
|
+
|
|
68
|
+
if (scopeMatch) {
|
|
69
|
+
keyword = scopeMatch[1]!;
|
|
70
|
+
commitScope = scopeMatch[2]!.trim();
|
|
71
|
+
} else {
|
|
72
|
+
keyword = raw;
|
|
73
|
+
commitScope = null;
|
|
74
|
+
}
|
|
56
75
|
|
|
57
76
|
const type = TYPE_MAP[keyword.toLowerCase()] ?? null;
|
|
58
77
|
|
|
59
|
-
|
|
78
|
+
// Scan body for BREAKING CHANGE footer
|
|
79
|
+
if (!breaking && body && BREAKING_FOOTER_PATTERN.test(body)) {
|
|
80
|
+
breaking = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { hash, message: trimmed, type, commitScope, description, breaking, files: [] };
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
export function filterCommitsForPackage(
|
package/src/git.ts
CHANGED
|
@@ -12,24 +12,31 @@ export async function getLastTag(cwd: string): Promise<string | null> {
|
|
|
12
12
|
export async function getCommitsSince(
|
|
13
13
|
cwd: string,
|
|
14
14
|
sinceRef: string | null,
|
|
15
|
-
): Promise<{ hash: string; message: string }[]> {
|
|
15
|
+
): Promise<{ hash: string; message: string; body: string }[]> {
|
|
16
|
+
const fmt = "%x00%H%x00%s%x00%b";
|
|
16
17
|
let result;
|
|
17
18
|
if (sinceRef) {
|
|
18
19
|
result =
|
|
19
|
-
await $`git -C ${cwd} log ${sinceRef}..HEAD --pretty=format
|
|
20
|
+
await $`git -C ${cwd} log ${sinceRef}..HEAD --pretty=format:${fmt} --no-merges`.quiet();
|
|
20
21
|
} else {
|
|
21
22
|
result =
|
|
22
|
-
await $`git -C ${cwd} log --pretty=format
|
|
23
|
+
await $`git -C ${cwd} log --pretty=format:${fmt} --no-merges`.quiet();
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const text = result.text().trim();
|
|
26
27
|
if (!text) return [];
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
-
|
|
29
|
+
const commits: { hash: string; message: string; body: string }[] = [];
|
|
30
|
+
// Split on the leading \x00 that starts each record
|
|
31
|
+
const records = text.split("\x00");
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
// records[0] is empty (before the first \x00), then groups of 3: hash, subject, body
|
|
34
|
+
for (let i = 1; i + 2 < records.length; i += 3) {
|
|
35
|
+
commits.push({
|
|
36
|
+
hash: records[i]!.trim(),
|
|
37
|
+
message: records[i + 1]!.trim(),
|
|
38
|
+
body: records[i + 2]!.trim(),
|
|
39
|
+
});
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
return commits;
|
package/src/index.ts
CHANGED
|
@@ -69,7 +69,7 @@ if (rawCommits.length === 0) {
|
|
|
69
69
|
process.exit(0);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
const parsed = rawCommits.map((c) => parseCommit(c.hash, c.message));
|
|
72
|
+
const parsed = rawCommits.map((c) => parseCommit(c.hash, c.message, c.body));
|
|
73
73
|
|
|
74
74
|
if (dbg) {
|
|
75
75
|
console.log("");
|
|
@@ -83,6 +83,18 @@ if (dbg) {
|
|
|
83
83
|
console.log("");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Warn if breaking changes detected with non-major bump
|
|
87
|
+
const hasBreaking = parsed.some((c) => c.breaking);
|
|
88
|
+
if (hasBreaking && options.bump !== "major") {
|
|
89
|
+
console.log(`\u26a0 Breaking changes detected but bump is "${options.bump}" (not "major").`);
|
|
90
|
+
if (dbg) {
|
|
91
|
+
for (const c of parsed.filter((c) => c.breaking)) {
|
|
92
|
+
debug(` breaking: ${c.hash.slice(0, 7)} ${c.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.log("");
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
// In a monorepo with filtering, fetch the file list for each commit
|
|
87
99
|
const shouldFilter = isWs && options.filterByPackage;
|
|
88
100
|
debug(`per-package filtering: ${shouldFilter ? "enabled" : "disabled"}`);
|
package/src/types.ts
CHANGED
|
@@ -10,7 +10,8 @@ export type CommitType =
|
|
|
10
10
|
| "docs"
|
|
11
11
|
| "build"
|
|
12
12
|
| "ops"
|
|
13
|
-
| "chore"
|
|
13
|
+
| "chore"
|
|
14
|
+
| "ci";
|
|
14
15
|
|
|
15
16
|
export interface CliOptions {
|
|
16
17
|
scope: PackageScope;
|
|
@@ -31,6 +32,7 @@ export interface ParsedCommit {
|
|
|
31
32
|
type: CommitType | null;
|
|
32
33
|
commitScope: string | null;
|
|
33
34
|
description: string;
|
|
35
|
+
breaking: boolean;
|
|
34
36
|
files: string[];
|
|
35
37
|
}
|
|
36
38
|
|