bunset 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -33,11 +26,12 @@ bunset [options]
33
26
  | `--all` | Update all workspace packages |
34
27
  | `--changed` | Update only changed packages (default for workspaces) |
35
28
  | `--no-commit` | Do not commit changes to git (commits by default) |
36
- | `--tag` | Tag the commit with new version (default) |
29
+ | `--no-tag` | Tag the commit with new version (default) |
37
30
  | `--per-package-tags` | Use `pkg@1.2.3` tags instead of prefixed tags |
38
- | `--tag-prefix` | Tag prefix (default: `v`, e.g. `v1.2.3`) |
31
+ | `--tag-prefix` | Tag prefix (auto-detected from last tag, or `v` if no tags) |
39
32
  | `--sections` | Comma-separated changelog sections (default: `feat,fix,perf`) |
40
33
  | `--dry-run` | Preview changes without writing files, committing, or tagging |
34
+ | `--debug` | Show detailed inclusion/exclusion reasoning (implies `--dry-run`) |
41
35
  | `--no-filter-by-package` | Include all commits in every package changelog (monorepo) |
42
36
 
43
37
  When bump type or scope flags are omitted, interactive prompts will ask.
@@ -71,7 +65,22 @@ All these styles work:
71
65
 
72
66
  An optional scope groups commits under a `#### scope` sub-heading within their type section in the changelog.
73
67
 
74
- 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
+
75
84
  - `feat`, `feature` — listed under **Features**
76
85
  - `fix`, `bug`, `bugfix` — listed under **Bug Fixes**
77
86
  - `refactor` — listed under **Refactors**
@@ -82,26 +91,39 @@ Recognized type keywords:
82
91
  - `build` — listed under **Build**
83
92
  - `ops` — listed under **Ops**
84
93
  - `chore` — listed under **Chores**
94
+ - `ci` — listed under **CI**
85
95
 
86
96
  Only sections listed in `--sections` (or the `sections` config option) appear in the changelog. The default is `feat,fix,perf`.
87
97
 
88
98
  ### Config File
89
99
 
90
- Place a `.bunset.toml` in your project root to set persistent defaults so you don't have to pass the same flags every time:
100
+ Place a `.bunset.toml` in your project root to set persistent defaults so you don't have to pass the same flags every time. All fields are optional. CLI flags always take priority over config values.
91
101
 
92
102
  ```toml
93
- bump = "patch" # "patch" | "minor" | "major"
94
- scope = "changed" # "all" | "changed"
95
- commit = true
96
- tag = true
97
- per-package-tags = false
98
- sections = ["feat", "fix", "perf"] # changelog sections and order
99
- dry-run = false # preview without writing
100
- filter-by-package = true # per-package commit filtering (monorepo)
101
- tag-prefix = "v" # prefix for version tags
103
+ bump = "patch" # "patch" | "minor" | "major"
104
+ scope = "changed" # "all" | "changed"
105
+ commit = true # auto-commit (default: true)
106
+ tag = true # create git tags (default: true)
107
+ per-package-tags = false # pkg@version tags (monorepo)
108
+ tag-prefix = "v" # tag prefix (default: auto-detect)
109
+ sections = ["feat", "fix", "perf"] # changelog sections and order
110
+ dry-run = false # preview without writing
111
+ debug = false # detailed reasoning (implies dry-run)
112
+ filter-by-package = true # per-package filtering (monorepo)
102
113
  ```
103
114
 
104
- All fields are optional. CLI flags always take priority over config values. If `bump` or `scope` is set in config, the interactive prompt for that option is skipped.
115
+ | Key | Type | Default | Description |
116
+ |-----|------|---------|-------------|
117
+ | `bump` | `string` | _(prompt)_ | Version bump type: `"patch"`, `"minor"`, or `"major"`. Skips the interactive prompt when set. |
118
+ | `scope` | `string` | _(prompt)_ | Package scope: `"all"` or `"changed"`. Skips the interactive prompt when set (monorepo only). |
119
+ | `commit` | `boolean` | `true` | Whether to auto-commit the version bump and changelog changes. |
120
+ | `tag` | `boolean` | `true` | Whether to create git tags for released versions. |
121
+ | `per-package-tags` | `boolean` | `false` | Use `pkg@1.2.3` tags instead of prefixed tags. In a monorepo, packages with no matching commits are skipped entirely. |
122
+ | `tag-prefix` | `string` | _(auto)_ | Prefix for version tags. Auto-detected from the last git tag when not set (falls back to `"v"` if no tags exist). Set to `""` for bare version numbers, or e.g. `"project-v"` for `project-v1.2.3`. |
123
+ | `sections` | `string[]` | `["feat", "fix", "perf"]` | Which commit types to include in the changelog and in what order. Accepts any recognized type keyword. |
124
+ | `dry-run` | `boolean` | `false` | Preview all changes without writing files, committing, or tagging. |
125
+ | `debug` | `boolean` | `false` | Show detailed inclusion/exclusion reasoning. Implies `dry-run`. |
126
+ | `filter-by-package` | `boolean` | `true` | In a monorepo, only include commits that touched files within each package. Disable with `false` to include all commits in every changelog. |
105
127
 
106
128
  ## Testing
107
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunset",
3
- "version": "0.0.2",
3
+ "version": "1.0.0",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -16,9 +16,10 @@ Options:
16
16
  --no-commit Do not commit the version bump and changelog
17
17
  --no-tag Do not create git tags for released versions
18
18
  --per-package-tags Use package-scoped tags (pkg@version) instead of prefixed
19
- --tag-prefix <str> Tag prefix (default: "v", e.g. v1.2.3)
19
+ --tag-prefix <str> Tag prefix (auto-detected from last tag, or "v" if no tags)
20
20
  --sections <list> Comma-separated changelog sections (default: feat,fix,perf)
21
21
  --dry-run Preview changes without writing files, committing, or tagging
22
+ --debug Show detailed inclusion/exclusion reasoning (implies --dry-run)
22
23
  --no-filter-by-package
23
24
  Include all commits in every package changelog (monorepo)
24
25
  --help, -h Show this help message
@@ -36,6 +37,15 @@ Commit format:
36
37
 
37
38
  An optional scope groups commits under a sub-heading in the changelog.
38
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
+
39
49
  Recognized type keywords:
40
50
  feat, feature → Features
41
51
  fix, bug, bugfix → Bug Fixes
@@ -47,12 +57,25 @@ Commit format:
47
57
  build → Build
48
58
  ops → Ops
49
59
  chore → Chores
60
+ ci → CI
50
61
  Only sections listed in --sections (or config) are included in the changelog.
51
62
  Default sections: feat, fix, perf.
52
63
 
53
- Config:
64
+ Config file (.bunset.toml):
54
65
  Place a .bunset.toml in your project root to set persistent defaults.
55
- CLI flags always override config values. See README for format.`);
66
+ All fields are optional. CLI flags always override config values.
67
+
68
+ Example:
69
+ bump = "patch" # "patch" | "minor" | "major"
70
+ scope = "changed" # "all" | "changed"
71
+ commit = true # auto-commit (default: true)
72
+ tag = true # create git tags (default: true)
73
+ per-package-tags = false # pkg@version tags (monorepo)
74
+ tag-prefix = "v" # tag prefix (default: auto-detect)
75
+ sections = ["feat", "fix", "perf"] # changelog sections and order
76
+ dry-run = false # preview without writing
77
+ debug = false # detailed reasoning (implies dry-run)
78
+ filter-by-package = true # per-package filtering (monorepo)`);
56
79
  }
57
80
 
58
81
  export function resolveOptions(
@@ -74,6 +97,7 @@ export function resolveOptions(
74
97
  "dry-run": { type: "boolean", default: false },
75
98
  "filter-by-package": { type: "boolean", default: true },
76
99
  "tag-prefix": { type: "string" },
100
+ debug: { type: "boolean", default: false },
77
101
  help: { type: "boolean", short: "h", default: false },
78
102
  },
79
103
  strict: true,
@@ -100,7 +124,8 @@ export function resolveOptions(
100
124
  ?? config.sections
101
125
  ?? DEFAULT_SECTIONS;
102
126
 
103
- const dryRun = values["dry-run"] ? true : (config.dryRun ?? false);
127
+ const debug = values.debug ? true : (config.debug ?? false);
128
+ const dryRun = debug || values["dry-run"] ? true : (config.dryRun ?? false);
104
129
 
105
130
  const filterByPackage = values["filter-by-package"] === false
106
131
  ? false
@@ -108,14 +133,14 @@ export function resolveOptions(
108
133
 
109
134
  const tagPrefix = values["tag-prefix"] as string | undefined
110
135
  ?? config.tagPrefix
111
- ?? "v";
136
+ ?? null;
112
137
 
113
138
  if (bump && scope) {
114
- return { scope, bump, commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix };
139
+ return { scope, bump, commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, debug };
115
140
  }
116
141
 
117
142
  return promptForMissing(
118
- { commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix },
143
+ { commit, tag, perPackageTags, sections, dryRun, filterByPackage, tagPrefix, debug },
119
144
  bump,
120
145
  scope,
121
146
  isWs,
@@ -156,7 +181,8 @@ interface MergedDefaults {
156
181
  sections: CommitType[];
157
182
  dryRun: boolean;
158
183
  filterByPackage: boolean;
159
- tagPrefix: string;
184
+ tagPrefix: string | null;
185
+ debug: boolean;
160
186
  }
161
187
 
162
188
  async function promptForMissing(
@@ -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/config.ts CHANGED
@@ -49,6 +49,10 @@ export async function loadConfig(
49
49
  config.tagPrefix = raw["tag-prefix"];
50
50
  }
51
51
 
52
+ if (typeof raw.debug === "boolean") {
53
+ config.debug = raw.debug;
54
+ }
55
+
52
56
  if (Array.isArray(raw.sections)) {
53
57
  const sections: CommitType[] = [];
54
58
  for (const s of raw.sections) {
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
@@ -29,25 +29,87 @@ const isWs = await isWorkspace(cwd);
29
29
  const config = await loadConfig(cwd);
30
30
  const options = await resolveOptions(isWs, config);
31
31
 
32
+ const dbg = options.debug;
33
+ function debug(msg: string): void {
34
+ if (dbg) console.log(`[debug] ${msg}`);
35
+ }
36
+
37
+ if (dbg) {
38
+ console.log("--- Debug Mode (dry-run implied) ---\n");
39
+ debug(`cwd: ${cwd}`);
40
+ debug(`workspace: ${isWs}`);
41
+ debug(`config loaded: ${JSON.stringify(config)}`);
42
+ debug(`resolved options: ${JSON.stringify(options)}`);
43
+ console.log("");
44
+ }
45
+
32
46
  const allPackages = await getAllPackages(cwd);
33
47
  const lastTag = await getLastTag(cwd);
34
48
  const rawCommits = await getCommitsSince(cwd, lastTag);
35
49
 
50
+ // Resolve tagPrefix: explicit value wins, otherwise infer from last tag
51
+ let tagPrefix: string;
52
+ if (options.tagPrefix !== null) {
53
+ tagPrefix = options.tagPrefix;
54
+ debug(`tag prefix explicit: "${tagPrefix}"`);
55
+ } else if (lastTag) {
56
+ const semverMatch = lastTag.match(/\d+\.\d+\.\d+/);
57
+ tagPrefix = semverMatch ? lastTag.slice(0, semverMatch.index) : "v";
58
+ debug(`tag prefix auto-detected: "${tagPrefix}" (from tag: ${lastTag})`);
59
+ } else {
60
+ tagPrefix = "v";
61
+ debug(`tag prefix default: "${tagPrefix}" (no previous tags found)`);
62
+ }
63
+
64
+ debug(`last tag: ${lastTag ?? "(none)"}`);
65
+ debug(`raw commits since tag: ${rawCommits.length}`);
66
+
36
67
  if (rawCommits.length === 0) {
37
68
  console.log("No commits found since last tag. Nothing to do.");
38
69
  process.exit(0);
39
70
  }
40
71
 
41
- const parsed = rawCommits.map((c) => parseCommit(c.hash, c.message));
72
+ const parsed = rawCommits.map((c) => parseCommit(c.hash, c.message, c.body));
73
+
74
+ if (dbg) {
75
+ console.log("");
76
+ debug("--- Parsed commits ---");
77
+ for (const c of parsed) {
78
+ const typeStr = c.type ?? "UNRECOGNIZED";
79
+ const scopeStr = c.commitScope ? `(${c.commitScope})` : "";
80
+ const included = c.type && options.sections.includes(c.type) ? "INCLUDED" : "EXCLUDED";
81
+ debug(` ${c.hash.slice(0, 7)} ${typeStr}${scopeStr}: ${c.description} → ${included} (section: ${c.type ?? "none"})`);
82
+ }
83
+ console.log("");
84
+ }
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
+ }
42
97
 
43
98
  // In a monorepo with filtering, fetch the file list for each commit
44
99
  const shouldFilter = isWs && options.filterByPackage;
100
+ debug(`per-package filtering: ${shouldFilter ? "enabled" : "disabled"}`);
45
101
  if (shouldFilter) {
46
102
  await Promise.all(
47
103
  parsed.map(async (commit) => {
48
104
  commit.files = await getCommitFiles(cwd, commit.hash);
49
105
  }),
50
106
  );
107
+ if (dbg) {
108
+ for (const c of parsed) {
109
+ debug(` ${c.hash.slice(0, 7)} files: ${c.files.length > 0 ? c.files.join(", ") : "(none)"}`);
110
+ }
111
+ console.log("");
112
+ }
51
113
  }
52
114
 
53
115
  const globalGroups = groupCommits(parsed);
@@ -62,6 +124,8 @@ let packages =
62
124
  ? await getChangedPackages(cwd, allPackages, lastTag)
63
125
  : allPackages;
64
126
 
127
+ debug(`scope: ${options.scope}, packages to process: ${packages.map((p) => p.name).join(", ") || "(none)"}`);
128
+
65
129
  if (packages.length === 0) {
66
130
  console.log("No changed packages found. Nothing to do.");
67
131
  process.exit(0);
@@ -81,7 +145,7 @@ function packageHasChanges(groups: GroupedCommits): boolean {
81
145
  }
82
146
 
83
147
  if (options.dryRun) {
84
- console.log("--- Dry Run ---\n");
148
+ if (!dbg) console.log("--- Dry Run ---\n");
85
149
 
86
150
  const tags: string[] = [];
87
151
 
@@ -89,6 +153,24 @@ if (options.dryRun) {
89
153
  const groups = getPackageGroups(pkg, parsed);
90
154
  const hasChanges = packageHasChanges(groups);
91
155
 
156
+ if (dbg) {
157
+ debug(`--- Package: ${pkg.name} ---`);
158
+ debug(` path: ${pkg.path}`);
159
+ debug(` current version: ${pkg.version ?? "0.0.0"}`);
160
+ for (const section of options.sections) {
161
+ const commits = groups[section];
162
+ if (commits.length > 0) {
163
+ debug(` ${section}: ${commits.length} commit(s)`);
164
+ for (const c of commits) {
165
+ debug(` - ${c.hash.slice(0, 7)} ${c.description}${c.commitScope ? ` (scope: ${c.commitScope})` : ""}`);
166
+ }
167
+ } else {
168
+ debug(` ${section}: 0 commits`);
169
+ }
170
+ }
171
+ debug(` has matching commits: ${hasChanges}`);
172
+ }
173
+
92
174
  if (!hasChanges && options.perPackageTags) {
93
175
  console.log(`${pkg.name}: no matching commits, skipping.`);
94
176
  continue;
@@ -117,7 +199,7 @@ if (options.dryRun) {
117
199
  if (options.perPackageTags) {
118
200
  tags.push(`${pkg.name}@${newVersion}`);
119
201
  } else {
120
- tags.push(`${options.tagPrefix}${newVersion}`);
202
+ tags.push(`${tagPrefix}${newVersion}`);
121
203
  }
122
204
  }
123
205
  }
@@ -177,7 +259,7 @@ for (const pkg of packages) {
177
259
  if (options.perPackageTags) {
178
260
  tags.push(`${pkg.name}@${newVersion}`);
179
261
  } else {
180
- tags.push(`${options.tagPrefix}${newVersion}`);
262
+ tags.push(`${tagPrefix}${newVersion}`);
181
263
  }
182
264
  }
183
265
  }
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;
@@ -21,7 +22,8 @@ export interface CliOptions {
21
22
  sections: CommitType[];
22
23
  dryRun: boolean;
23
24
  filterByPackage: boolean;
24
- tagPrefix: string;
25
+ tagPrefix: string | null;
26
+ debug: boolean;
25
27
  }
26
28
 
27
29
  export interface ParsedCommit {
@@ -30,6 +32,7 @@ export interface ParsedCommit {
30
32
  type: CommitType | null;
31
33
  commitScope: string | null;
32
34
  description: string;
35
+ breaking: boolean;
33
36
  files: string[];
34
37
  }
35
38