relacher 0.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.
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { DependencyConfig } from '../types';
5
+
6
+ import { decorateList, type DependencyList } from './shared';
7
+
8
+ /**
9
+ * Loads a single project package configuration from package.json.
10
+ */
11
+ export function pnpmProject(cwd: string): DependencyList {
12
+ const configs: DependencyConfig[] = [];
13
+ const pJsonPath = path.join(cwd, 'package.json');
14
+
15
+ if (fs.existsSync(pJsonPath)) {
16
+ try {
17
+ const fileContent = fs.readFileSync(pJsonPath, 'utf8');
18
+ const parsed = JSON.parse(fileContent);
19
+ if (parsed.name) {
20
+ configs.push({
21
+ name: parsed.name,
22
+ watch: ['.'],
23
+ depends: [],
24
+ updates: [
25
+ {
26
+ kind: 'regex',
27
+ path: 'package.json',
28
+ search: '"version"\\s*:\\s*"[^"]+"',
29
+ replace: '"version": "{{version}}"',
30
+ },
31
+ ],
32
+ });
33
+ }
34
+ } catch { }
35
+ }
36
+
37
+ return decorateList(configs);
38
+ }
39
+
40
+ /**
41
+ * Parses pnpm-workspace.yaml to identify target members and versioning topologies.
42
+ */
43
+ export function pnpmWorkspace(cwd: string): DependencyList {
44
+ const configs: DependencyConfig[] = [];
45
+ const workspaceYamlPath = path.join(cwd, 'pnpm-workspace.yaml');
46
+ let globPatterns: string[] = [];
47
+
48
+ if (fs.existsSync(workspaceYamlPath)) {
49
+ const content = fs.readFileSync(workspaceYamlPath, 'utf8');
50
+ const lines = content.split('\n');
51
+ let inPackages = false;
52
+
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (trimmed.startsWith('packages:')) {
56
+ inPackages = true;
57
+ continue;
58
+ }
59
+ if (inPackages) {
60
+ if (trimmed && !line.startsWith(' ') && !trimmed.startsWith('-')) {
61
+ inPackages = false;
62
+ continue;
63
+ }
64
+ const match = trimmed.match(/^-\s*['"]?([^'"]+)['"]?/);
65
+ if (match && match[1]) {
66
+ globPatterns.push(match[1]);
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ const packageInfos: { name: string; memberPath: string; content: string }[] = [];
73
+
74
+ for (const pattern of globPatterns) {
75
+ const cleanPattern = pattern.replace(/\\/g, '/');
76
+ if (cleanPattern.endsWith('/*')) {
77
+ const parentDir = path.join(cwd, cleanPattern.slice(0, -2));
78
+ if (fs.existsSync(parentDir) && fs.statSync(parentDir).isDirectory()) {
79
+ const subdirs = fs.readdirSync(parentDir);
80
+ for (const subdir of subdirs) {
81
+ const pJsonPath = path.join(parentDir, subdir, 'package.json');
82
+ if (fs.existsSync(pJsonPath)) {
83
+ try {
84
+ const fileContent = fs.readFileSync(pJsonPath, 'utf8');
85
+ const parsed = JSON.parse(fileContent);
86
+ if (parsed.name) {
87
+ packageInfos.push({
88
+ name: parsed.name,
89
+ memberPath: path.dirname(pJsonPath),
90
+ content: fileContent,
91
+ });
92
+ }
93
+ } catch { }
94
+ }
95
+ }
96
+ }
97
+ } else {
98
+ const pJsonPath = path.join(cwd, cleanPattern, 'package.json');
99
+ if (fs.existsSync(pJsonPath)) {
100
+ try {
101
+ const fileContent = fs.readFileSync(pJsonPath, 'utf8');
102
+ const parsed = JSON.parse(fileContent);
103
+ if (parsed.name) {
104
+ packageInfos.push({
105
+ name: parsed.name,
106
+ memberPath: path.dirname(pJsonPath),
107
+ content: fileContent,
108
+ });
109
+ }
110
+ } catch { }
111
+ }
112
+ }
113
+ }
114
+
115
+ for (const info of packageInfos) {
116
+ const relativePath = path.relative(cwd, info.memberPath);
117
+ const relativePJson = path.join(relativePath, 'package.json');
118
+
119
+ const parsed = JSON.parse(info.content);
120
+ const depends: string[] = [];
121
+
122
+ const allDeps = {
123
+ ...parsed.dependencies,
124
+ ...parsed.devDependencies,
125
+ ...parsed.peerDependencies,
126
+ };
127
+
128
+ for (const other of packageInfos) {
129
+ if (other.name === info.name) continue;
130
+ if (allDeps[other.name] !== undefined) {
131
+ depends.push(other.name);
132
+ }
133
+ }
134
+
135
+ configs.push({
136
+ name: info.name,
137
+ watch: [relativePath],
138
+ depends,
139
+ updates: [
140
+ {
141
+ kind: 'regex',
142
+ path: relativePJson,
143
+ search: '"version"\\s*:\\s*"[^"]+"',
144
+ replace: '"version": "{{version}}"',
145
+ },
146
+ ],
147
+ });
148
+ }
149
+
150
+ return decorateList(configs);
151
+ }
152
+
153
+ /**
154
+ * Auto-detects whether the workspace configuration exists, reverting to single packages.
155
+ */
156
+ export function pnpmDeps(cwd: string): DependencyList {
157
+ const workspaceYamlPath = path.join(cwd, 'pnpm-workspace.yaml');
158
+ if (fs.existsSync(workspaceYamlPath)) {
159
+ return pnpmWorkspace(cwd);
160
+ }
161
+ return pnpmProject(cwd);
162
+ }
@@ -0,0 +1,37 @@
1
+ import type { ChangelogUpdate, RegexUpdate, DependencyConfig, UpdateAction } from '../types';
2
+
3
+ export function changelogUpdate(options: Omit<ChangelogUpdate, 'kind'>): ChangelogUpdate {
4
+ return { kind: 'changelog', ...options };
5
+ }
6
+
7
+ export function regexUpdate(options: Omit<RegexUpdate, 'kind'>): RegexUpdate {
8
+ return { kind: 'regex', ...options };
9
+ }
10
+
11
+ export class SingleDependencyBuilder {
12
+ constructor(private config: DependencyConfig) { }
13
+
14
+ update(action: UpdateAction): this {
15
+ if (!this.config.updates) {
16
+ this.config.updates = [];
17
+ }
18
+ this.config.updates.push(action);
19
+ return this;
20
+ }
21
+ }
22
+
23
+ export interface DependencyList extends Array<DependencyConfig> {
24
+ on(name: string, cb: (builder: SingleDependencyBuilder) => void): this;
25
+ }
26
+
27
+ export function decorateList(configs: DependencyConfig[]): DependencyList {
28
+ const list = configs as DependencyList;
29
+ list.on = function(name: string, cb: (builder: SingleDependencyBuilder) => void) {
30
+ const dep = this.find((d) => d.name === name);
31
+ if (dep) {
32
+ cb(new SingleDependencyBuilder(dep));
33
+ }
34
+ return this;
35
+ };
36
+ return list;
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './prepare';
2
+ export * from './run';
3
+ export * from './vcs';
4
+ export * from './builder';
5
+ export * from './print';
6
+ export * from './types';
package/src/prepare.ts ADDED
@@ -0,0 +1,262 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ defaultSizes,
6
+ defaultCascadeRules,
7
+ type BumpSize,
8
+ type Commit,
9
+ type VcsProvider,
10
+ type DependencyConfig,
11
+ type DependencyUpdateReport,
12
+ type IntermediateReport,
13
+ type PrepareOptions,
14
+ type SizePatterns,
15
+ type CascadeRules,
16
+ type UpdateAction,
17
+ type ChangelogContext,
18
+ } from './types';
19
+
20
+ export function getTomlValue(content: string): string | null {
21
+ const packageMatch = content.match(/\[package\][^]*?(?=^\[|z)/);
22
+ const targetText = packageMatch ? packageMatch[0] : content;
23
+ const versionMatch = targetText.match(/^version\s*=\s*"([^"]+)"/m);
24
+ return versionMatch && versionMatch[1] ? versionMatch[1] : null;
25
+ }
26
+
27
+ export function bumpVersion(version: string, size: BumpSize): string {
28
+ const parts = version.split('.').map(Number);
29
+ if (parts.length !== 3 || parts.some(isNaN)) return version;
30
+ const major = parts[0];
31
+ const minor = parts[1];
32
+ const patch = parts[2];
33
+ if (major === undefined || minor === undefined || patch === undefined) return version;
34
+ if (size === 'major') return `${major + 1}.0.0`;
35
+ if (size === 'minor') return `${major}.${minor + 1}.0`;
36
+ if (size === 'patch') return `${major}.${minor}.${patch + 1}`;
37
+ return version;
38
+ }
39
+
40
+ export function getCurrentVersion(dep: DependencyConfig, cwd: string): string {
41
+ const tomlUpdate = dep.updates?.find((u) => u.kind === 'toml');
42
+ if (!tomlUpdate) return '0.0.0';
43
+ const filePath = path.resolve(cwd, tomlUpdate.path);
44
+ if (!fs.existsSync(filePath)) return '0.0.0';
45
+ return getTomlValue(fs.readFileSync(filePath, 'utf8')) || '0.0.0';
46
+ }
47
+
48
+ export function matchBumpSize(message: string, sizes: SizePatterns): BumpSize {
49
+ if (new RegExp(sizes.major.pattern).test(message)) return 'major';
50
+ if (new RegExp(sizes.minor.pattern).test(message)) return 'minor';
51
+ if (new RegExp(sizes.patch.pattern).test(message)) return 'patch';
52
+ return 'skip';
53
+ }
54
+
55
+ export function evaluateCommitsBump(commits: Commit[], sizes: SizePatterns): BumpSize {
56
+ const priority: Record<BumpSize, number> = { skip: 0, patch: 1, minor: 2, major: 3 };
57
+ let maxBump: BumpSize = 'skip';
58
+ for (const commit of commits) {
59
+ const commitBump = matchBumpSize(commit.message, sizes);
60
+ if (priority[commitBump] > priority[maxBump]) {
61
+ maxBump = commitBump;
62
+ }
63
+ }
64
+ return maxBump;
65
+ }
66
+
67
+ export function topologicalSort(items: IntermediateReport[]): IntermediateReport[] {
68
+ const processed = new Set<string>();
69
+ const processing = new Set<string>();
70
+ const sorted: IntermediateReport[] = [];
71
+ function visit(item: IntermediateReport) {
72
+ if (processing.has(item.name) || processed.has(item.name)) return;
73
+ processing.add(item.name);
74
+ for (const depName of item.depends) {
75
+ const depItem = items.find((r) => r.name === depName);
76
+ if (depItem) visit(depItem);
77
+ }
78
+ processing.delete(item.name);
79
+ processed.add(item.name);
80
+ sorted.push(item);
81
+ }
82
+ items.forEach((item) => visit(item));
83
+ return sorted;
84
+ }
85
+
86
+ export function propagateBumps(sorted: IntermediateReport[], rules?: CascadeRules): void {
87
+ const activeRules: Record<BumpSize, Record<BumpSize, BumpSize>> = {
88
+ skip: { ...defaultCascadeRules.skip, ...rules?.skip },
89
+ patch: { ...defaultCascadeRules.patch, ...rules?.patch },
90
+ minor: { ...defaultCascadeRules.minor, ...rules?.minor },
91
+ major: { ...defaultCascadeRules.major, ...rules?.major },
92
+ };
93
+
94
+ for (const item of sorted) {
95
+ let maxDepBump: BumpSize = 'skip';
96
+ for (const depName of item.depends) {
97
+ const depItem = sorted.find((r) => r.name === depName);
98
+ if (depItem) {
99
+ if (depItem.bump === 'major') {
100
+ maxDepBump = 'major';
101
+ } else if (depItem.bump === 'minor' && maxDepBump !== 'major') {
102
+ maxDepBump = 'minor';
103
+ } else if (depItem.bump === 'patch' && maxDepBump === 'skip') {
104
+ maxDepBump = 'patch';
105
+ }
106
+ }
107
+ }
108
+
109
+ if (maxDepBump !== 'skip') {
110
+ const original = item.bump;
111
+ const targetBump = activeRules[original][maxDepBump] ?? original;
112
+ item.bump = targetBump;
113
+ }
114
+
115
+ item.newVersion =
116
+ item.bump !== 'skip' ? bumpVersion(item.currentVersion, item.bump) : item.currentVersion;
117
+ }
118
+ }
119
+
120
+ export async function initReportItems(
121
+ cargoDeps: DependencyConfig[],
122
+ vcs: VcsProvider,
123
+ sizes: SizePatterns,
124
+ cwd: string,
125
+ ): Promise<IntermediateReport[]> {
126
+ const reports: IntermediateReport[] = [];
127
+ for (const dep of cargoDeps) {
128
+ const currentVersion = getCurrentVersion(dep, cwd);
129
+ const commits = await vcs.getCommits(dep.name, dep.watch || []);
130
+ const selfBump = evaluateCommitsBump(commits, sizes);
131
+ reports.push({
132
+ name: dep.name,
133
+ currentVersion,
134
+ newVersion: currentVersion,
135
+ bump: selfBump,
136
+ originalBump: selfBump,
137
+ commits,
138
+ updates: dep.updates || [],
139
+ depends: dep.depends || [],
140
+ });
141
+ }
142
+ return reports;
143
+ }
144
+
145
+ export async function prepare(
146
+ cargoDeps: DependencyConfig[],
147
+ vcs: VcsProvider,
148
+ options: PrepareOptions = {},
149
+ ): Promise<DependencyUpdateReport[]> {
150
+ const cwd = options.cwd || process.cwd();
151
+ const sizes = options.sizes || defaultSizes;
152
+ const items = await initReportItems(cargoDeps, vcs, sizes, cwd);
153
+ const sorted = topologicalSort(items);
154
+ propagateBumps(sorted, options.cascade);
155
+ return finalizeReports(sorted);
156
+ }
157
+
158
+ export function buildChangelogBlock(version: string, commits: Commit[]): string {
159
+ const date = new Date().toISOString().split('T')[0] ?? '';
160
+ let block = `## [${version}] - ${date}\n\n`;
161
+
162
+ if (commits.length === 0) {
163
+ return block + `*No notable changes.*\n`;
164
+ }
165
+
166
+ const breaking: string[] = [];
167
+ const features: string[] = [];
168
+ const fixes: string[] = [];
169
+ const others: string[] = [];
170
+
171
+ for (const c of commits) {
172
+ if (c.message.includes('!:') || c.message.includes('BREAKING CHANGE')) {
173
+ breaking.push(`- ${c.hash} ${c.message}`);
174
+ } else if (c.message.startsWith('feat')) {
175
+ features.push(`- ${c.hash} ${c.message}`);
176
+ } else if (c.message.startsWith('fix')) {
177
+ fixes.push(`- ${c.hash} ${c.message}`);
178
+ } else {
179
+ others.push(`- ${c.hash} ${c.message}`);
180
+ }
181
+ }
182
+
183
+ if (breaking.length) block += `### ⚠️ BREAKING CHANGES\n${breaking.join('\n')}\n\n`;
184
+ if (features.length) block += `### ✨ Features\n${features.join('\n')}\n\n`;
185
+ if (fixes.length) block += `### 🐛 Bug Fixes\n${fixes.join('\n')}\n\n`;
186
+ if (others.length) block += `### 🛠 Other Changes\n${others.join('\n')}\n\n`;
187
+
188
+ return block.trim() + '\n';
189
+ }
190
+
191
+ export function finalizeReports(sorted: IntermediateReport[]): DependencyUpdateReport[] {
192
+ const allCommitsMap = new Map<string, Commit>();
193
+ for (const item of sorted) {
194
+ for (const c of item.commits) {
195
+ allCommitsMap.set(c.hash, c);
196
+ }
197
+ }
198
+ const globalCommits = Array.from(allCommitsMap.values());
199
+
200
+ return sorted.map((item) => ({
201
+ name: item.name,
202
+ currentVersion: item.currentVersion,
203
+ newVersion: item.newVersion,
204
+ bump: item.bump,
205
+ originalBump: item.originalBump,
206
+ commits: item.commits,
207
+ updates: mapResolvedUpdates(item.updates, item.newVersion, item.commits, globalCommits),
208
+ depends: item.depends,
209
+ }));
210
+ }
211
+
212
+ export function defaultChangelogTemplate({ version, date, commits }: ChangelogContext): string {
213
+ let block = `## [${version}] - ${date}\n\n`;
214
+ if (commits.length === 0) return block + `*No notable changes.*\n`;
215
+
216
+ const groups: { breaking: string[]; feat: string[]; fix: string[]; other: string[] } = {
217
+ breaking: [],
218
+ feat: [],
219
+ fix: [],
220
+ other: [],
221
+ };
222
+
223
+ for (const c of commits) {
224
+ const item = `- ${c.shortHash} ${c.message}`;
225
+ if (c.isBreaking) groups.breaking.push(item);
226
+ else if (c.type === 'feat') groups.feat.push(item);
227
+ else if (c.type === 'fix') groups.fix.push(item);
228
+ else groups.other.push(item);
229
+ }
230
+
231
+ if (groups.breaking.length) block += `### ⚠️ BREAKING CHANGES\n${groups.breaking.join('\n')}\n\n`;
232
+ if (groups.feat.length) block += `### ✨ Features\n${groups.feat.join('\n')}\n\n`;
233
+ if (groups.fix.length) block += `### 🐛 Bug Fixes\n${groups.fix.join('\n')}\n\n`;
234
+ if (groups.other.length) block += `### 🛠 Other Changes\n${groups.other.join('\n')}\n\n`;
235
+
236
+ return block.trim() + '\n';
237
+ }
238
+
239
+ export function mapResolvedUpdates(
240
+ updates: UpdateAction[],
241
+ newVersion: string,
242
+ crateCommits: Commit[],
243
+ globalCommits: Commit[],
244
+ ): UpdateAction[] {
245
+ return updates.map((u) => {
246
+ if (u.kind === 'regex') {
247
+ return { ...u, resolvedReplace: u.replace.replace('{{version}}', newVersion) };
248
+ }
249
+ if (u.kind === 'changelog') {
250
+ const targetCommits = u.global ? globalCommits : crateCommits;
251
+ const context: ChangelogContext = {
252
+ version: newVersion,
253
+ date: new Date().toISOString().split('T')[0] ?? '',
254
+ commits: targetCommits,
255
+ };
256
+
257
+ const content = u.template ? u.template(context) : defaultChangelogTemplate(context);
258
+ return { ...u, resolvedBlock: content };
259
+ }
260
+ return u;
261
+ });
262
+ }
package/src/print.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { matchBumpSize } from './prepare';
2
+ import { defaultSizes } from './types';
3
+ import type { DependencyUpdateReport } from './types';
4
+
5
+ // ANSI Escape Codes for Terminal Colors
6
+ const c = {
7
+ reset: '\x1b[0m',
8
+ bold: '\x1b[1m',
9
+ dim: '\x1b[2m',
10
+ red: '\x1b[31m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ cyan: '\x1b[36m',
16
+ gray: '\x1b[90m',
17
+ };
18
+
19
+ /**
20
+ * Highlights only the changed segments of a version string.
21
+ * Example: "1.2.3" -> "1.3.0" prints "1.2.3 → 1.[3.0]" where [3.0] is bright green.
22
+ */
23
+ function highlightVersion(oldV: string, newV: string): string {
24
+ if (oldV === newV) return `${c.gray}${oldV} (no change)${c.reset}`;
25
+
26
+ const oParts = oldV.split('.');
27
+ const nParts = newV.split('.');
28
+
29
+ let matchIdx = 0;
30
+ while (matchIdx < 3 && oParts[matchIdx] === nParts[matchIdx]) {
31
+ matchIdx++;
32
+ }
33
+
34
+ const common = nParts.slice(0, matchIdx).join('.') + (matchIdx > 0 ? '.' : '');
35
+ const changed = nParts.slice(matchIdx).join('.');
36
+
37
+ return `${c.gray}${oldV}${c.reset} ${c.magenta}→${c.reset} ${c.gray}${common}${c.reset}${c.green}${c.bold}${changed}${c.reset}`;
38
+ }
39
+
40
+ function getIconForFile(filename: string): string {
41
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
42
+
43
+ const icons: Record<string, string> = {
44
+ toml: '',
45
+ nix: '',
46
+ md: '',
47
+ mdx: '',
48
+ rs: '',
49
+ js: '',
50
+ ts: '',
51
+ json: '',
52
+ yaml: '',
53
+ yml: '',
54
+ lock: '',
55
+ log: '󰌱',
56
+ };
57
+
58
+ return icons[ext] || '󰈚'; // Default file icon
59
+ }
60
+ export function prettyPrint(reports: DependencyUpdateReport[]): void {
61
+ for (const report of reports) {
62
+ if (report.bump === 'skip') continue;
63
+
64
+ const bumpColor =
65
+ report.bump === 'major' ? c.red : report.bump === 'minor' ? c.yellow : c.green;
66
+ process.stdout.write(
67
+ `${c.bold}📦 ${report.name.padEnd(12)}${c.reset} ${highlightVersion(report.currentVersion, report.newVersion)} ${bumpColor}${c.bold}[${report.bump}]${c.reset}\n`,
68
+ );
69
+
70
+ const files = report.updates.map(
71
+ (u) =>
72
+ `${getIconForFile(u.path.split('/').pop() || '')} ${c.dim}${u.path.split('/').pop()}${c.reset}`,
73
+ );
74
+ console.log(` ${files.join(' ')}`);
75
+
76
+ // 1. Show all commits since the last release with a bump marker if applicable
77
+ for (const commit of report.commits) {
78
+ const commitBump = matchBumpSize(commit.message, defaultSizes);
79
+ const affectsBump = commitBump !== 'skip';
80
+
81
+ const marker = affectsBump ? `${c.green}✦${c.reset}` : `${c.gray}○${c.reset}`;
82
+
83
+ console.log(
84
+ ` ${marker} ${c.yellow}${commit.shortHash}${c.reset} ${commit.message} ${c.gray}${commit.author} ${commit.date}${c.reset}`,
85
+ );
86
+ }
87
+
88
+ // 2. Show Cascades with their update kind
89
+ const changedDeps = reports.filter(
90
+ (r) => report.depends?.includes(r.name) && r.currentVersion !== r.newVersion,
91
+ );
92
+
93
+ if (changedDeps.length > 0) {
94
+ const depsStr = changedDeps
95
+ .map((d) => {
96
+ const depBumpColor = d.bump === 'major' ? c.red : d.bump === 'minor' ? c.yellow : c.green;
97
+ return `${c.blue}${d.name}${c.reset} ${depBumpColor}[${d.bump}]${c.reset}`;
98
+ })
99
+ .join(', ');
100
+
101
+ console.log(
102
+ ` ${c.green}✦${c.reset} Cascaded as ${bumpColor}[${report.bump}]${c.reset} from deps: ${depsStr}`,
103
+ );
104
+ }
105
+
106
+ // Changelog Preview
107
+ console.log('');
108
+ }
109
+ }
package/src/run.ts ADDED
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { DependencyUpdateReport, VcsProvider } from './types';
5
+
6
+ /**
7
+ * Applies calculated updates to files on disk, stages, commits, and tags using the provided VcsProvider.
8
+ */
9
+ export async function run(
10
+ reports: DependencyUpdateReport[],
11
+ vcs: VcsProvider,
12
+ options: { cwd: string },
13
+ ): Promise<void> {
14
+ const cwd = options.cwd;
15
+
16
+ // 1. Apply file updates
17
+ for (const report of reports) {
18
+ for (const u of report.updates) {
19
+ const filePath = path.resolve(cwd, u.path);
20
+ const dirPath = path.dirname(filePath);
21
+ if (!fs.existsSync(dirPath)) {
22
+ fs.mkdirSync(dirPath, { recursive: true });
23
+ }
24
+
25
+ if (u.kind === 'toml') {
26
+ if (fs.existsSync(filePath)) {
27
+ let content = fs.readFileSync(filePath, 'utf8');
28
+ // Update package version inside the target Cargo.toml package block
29
+ content = content.replace(
30
+ /(\[package\][^]*?^version\s*=\s*")[^"]+(")/m,
31
+ `$1${report.newVersion}$2`,
32
+ );
33
+ // Sync workspace dependency entries if internal dependencies changed
34
+ for (const depReport of reports) {
35
+ if (depReport.name === report.name) continue;
36
+ const depRegex = new RegExp(
37
+ `(${depReport.name}\\s*=\\s*\\{[^}]*version\\s*=\\s*")[^"]+(")`,
38
+ 'g',
39
+ );
40
+ content = content.replace(depRegex, `$1${depReport.newVersion}$2`);
41
+ }
42
+ fs.writeFileSync(filePath, content);
43
+ }
44
+ } else if (u.kind === 'regex') {
45
+ if (fs.existsSync(filePath)) {
46
+ let content = fs.readFileSync(filePath, 'utf8');
47
+ const searchRegex = new RegExp(u.search, 'g');
48
+ content = content.replace(searchRegex, u.resolvedReplace ?? '');
49
+ fs.writeFileSync(filePath, content);
50
+ }
51
+ } else if (u.kind === 'changelog') {
52
+ const oldContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
53
+ const newContent = (u.resolvedBlock ?? '') + '\n' + oldContent;
54
+ fs.writeFileSync(filePath, newContent.trim() + '\n');
55
+ }
56
+ }
57
+ }
58
+
59
+ // 2. Build multi-line commit message
60
+ const activeReports = reports.filter((r) => r.bump !== 'skip');
61
+ if (activeReports.length === 0) {
62
+ return;
63
+ }
64
+
65
+ const tagList = activeReports.map((r) => `${r.name}-v${r.newVersion}`).join(', ');
66
+ let commitMessage = `chore: release ${tagList}\n\n`;
67
+
68
+ for (const report of activeReports) {
69
+ commitMessage += `${report.name} (${report.currentVersion} -> ${report.newVersion}) [${report.bump}]\n`;
70
+
71
+ // Add commits
72
+ for (const commit of report.commits) {
73
+ commitMessage += ` - ${commit.shortHash} ${commit.message} (${commit.author} ${commit.date})\n`;
74
+ }
75
+
76
+ // Add cascades
77
+ const changedDeps = reports.filter(
78
+ (r) => report.depends?.includes(r.name) && r.currentVersion !== r.newVersion,
79
+ );
80
+
81
+ if (changedDeps.length > 0) {
82
+ const depsStr = changedDeps.map((d) => `${d.name} [${d.bump}]`).join(', ');
83
+ commitMessage += ` - Cascaded from deps: ${depsStr}\n`;
84
+ }
85
+
86
+ commitMessage += '\n';
87
+ }
88
+
89
+ // 3. Stage and execute commit
90
+ await vcs.commit(commitMessage.trim());
91
+
92
+ // 4. Create tags
93
+ for (const report of activeReports) {
94
+ const tagName = `${report.name}-v${report.newVersion}`;
95
+ await vcs.tag(tagName);
96
+ }
97
+ }