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 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 (`[feat]`, `[fix]`, `[test]`), bumps semantic versions, updates `CHANGELOG.md` per package, and optionally commits and tags the result.
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 install
10
+ bun add bunset
11
11
  ```
12
12
 
13
13
  ## Usage
14
14
 
15
15
  ```bash
16
- bun src/index.ts [options]
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
- Recognized type keywords:
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": "0.0.3",
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": {
@@ -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
 
@@ -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): desc, type: desc
39
- const COMMIT_PATTERN = /^\[([^\]]+)\]:?\s*(.*)$|^(\w+(?:\([^)]*\))?):\s+(.*)$/;
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
- export function parseCommit(hash: string, message: string): ParsedCommit {
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
- const raw = (match[1] ?? match[3])!.trim();
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
- const keyword = scopeMatch ? scopeMatch[1]! : raw;
55
- const commitScope = scopeMatch ? scopeMatch[2]!.trim() : null;
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
- return { hash, message: trimmed, type, commitScope, description, files: [] };
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:%H%n%s --no-merges`.quiet();
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:%H%n%s --no-merges`.quiet();
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 lines = text.split("\n");
29
- const commits: { hash: string; message: string }[] = [];
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
- for (let i = 0; i < lines.length - 1; i += 2) {
32
- commits.push({ hash: lines[i]!, message: lines[i + 1]! });
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