From a02fa9bcb22945048cb0d60a3193c847601fbe9e Mon Sep 17 00:00:00 2001 From: Evan Charlton Date: Fri, 24 Oct 2025 09:27:25 +0200 Subject: [PATCH 1/2] feat: Introduce --fixup to change subcommand When working on a change in a monorepo, I will often need to add subsequent change files as the change spreads into multiple packages. This creates multiple changefiles as well as multiple commits. To tackle this, this change introduces a `--fixup` flag, modeled after the `--fixup` flag in `git commit`. When used, this will enable fix-up mode, which will: 1. Find the most recent commit which adds a change file 2. Add the new changes into that file 3. Commit with a `--fixup ` flag If no commits are found in the current branch, then the standard flow is used to create a new commit / changefile. --- docs/cli/change.md | 7 + .../changefile/changeFixup.test.ts | 172 +++++++++++ .../options/getCliOptions.test.ts | 10 + .../changefile/writeChangeFilesFixup.test.ts | 290 ++++++++++++++++++ src/changefile/writeChangeFilesFixup.ts | 242 +++++++++++++++ src/commands/change.ts | 16 +- src/help.ts | 1 + src/options/getCliOptions.ts | 1 + src/types/BeachballOptions.ts | 5 + 9 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 src/__functional__/changefile/changeFixup.test.ts create mode 100644 src/__tests__/changefile/writeChangeFilesFixup.test.ts create mode 100644 src/changefile/writeChangeFilesFixup.ts diff --git a/docs/cli/change.md b/docs/cli/change.md index 8095c6e37..0895b3a6d 100644 --- a/docs/cli/change.md +++ b/docs/cli/change.md @@ -22,6 +22,7 @@ Some [general options](./options) including `--branch` and `--scope` also apply | ------------------------- | ----- | ------------------ | -------------------------------------------------------------------------- | | `--all` | | false | Generate change files for all packages | | `--dependent-change-type` | | `patch` | use this change type for dependent packages | +| `--fixup` | | false | Update the most recently added change file and create a fixup commit | | `--message` | `-m` | (prompt) | Description for all change files | | `--no-commit` | | false | Stage the change files rather than committing | | `--package` | | (changed packages) | Generate change files for these packages (can be specified multiple times) | @@ -53,6 +54,12 @@ Generate change files for all packages, regardless of changes. This would most o beachball change --all --type patch --message 'update build output settings' ``` +Update the most recently added change file with additional changes and create a fixup commit. This is useful when you want to amend the most recent change file instead of creating a new one. + +``` +beachball change --fixup --type minor --message 'additional change' +``` + ### Prompt walkthrough If you have changes that are not committed yet (i.e. `git status` reports changes), then `beachball change` will warn you about these: diff --git a/src/__functional__/changefile/changeFixup.test.ts b/src/__functional__/changefile/changeFixup.test.ts new file mode 100644 index 000000000..ce8833e46 --- /dev/null +++ b/src/__functional__/changefile/changeFixup.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { change } from '../../commands/change'; +import { RepositoryFactory } from '../../__fixtures__/repositoryFactory'; +import { generateChangeFiles } from '../../__fixtures__/changeFiles'; +import { getDefaultOptions } from '../../options/getDefaultOptions'; +import type { ChangeFileInfo } from '../../types/ChangeInfo'; +import type { Repository } from '../../__fixtures__/repository'; +import type { BeachballOptions } from '../../types/BeachballOptions'; +import fs from 'fs-extra'; +import path from 'path'; + +// Mock the promptForChange module to avoid interactive prompts +jest.mock('../../changefile/promptForChange', () => ({ + promptForChange: jest.fn(), +})); + +import { promptForChange } from '../../changefile/promptForChange'; +const mockPromptForChange = promptForChange as jest.MockedFunction; + +describe('change command with fixup mode', () => { + let repositoryFactory: RepositoryFactory; + let repository: Repository; + + function getOptions(options?: Partial): BeachballOptions { + return { + ...getDefaultOptions(), + path: repository.rootPath, + ...options, + }; + } + + beforeAll(() => { + repositoryFactory = new RepositoryFactory('single'); + }); + + beforeEach(() => { + repository = repositoryFactory.cloneRepository(); + mockPromptForChange.mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + repository?.cleanUp(); + }); + + it('creates a normal change file when fixup is false', async () => { + const changes: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Test change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + mockPromptForChange.mockResolvedValue(changes); + + const options = getOptions({ + fixup: false, + package: ['foo'], + commit: false, // Disable commit for easier testing + }); + + await change(options); + + // Verify a change file was created + const changeDir = path.join(repository.rootPath, 'change'); + const changeFiles = fs.readdirSync(changeDir).filter(f => f.endsWith('.json')); + expect(changeFiles).toHaveLength(1); + + // Verify the content + const changeFilePath = path.join(changeDir, changeFiles[0]); + const changeFileContent = fs.readJSONSync(changeFilePath); + expect(changeFileContent.comment).toBe('Test change'); + expect(changeFileContent.type).toBe('patch'); + }); + + it('updates existing change file and creates fixup commit when fixup is true', async () => { + // Create an initial change file + const initialChanges: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Initial change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + const options = getOptions({ commit: true }); // Enable commit to make sure change file is committed + generateChangeFiles(initialChanges, options); + + // Now simulate a fixup operation + const fixupChanges: ChangeFileInfo[] = [ + { + type: 'minor', + comment: 'Additional change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'minor', + }, + ]; + + mockPromptForChange.mockResolvedValue(fixupChanges); + + const fixupOptions = getOptions({ + fixup: true, + package: ['foo'], + commit: false, // We'll test the commit separately + }); + + await change(fixupOptions); + + // Verify only one change file exists (the updated one) + const changeDir = path.join(repository.rootPath, 'change'); + const changeFiles = fs.readdirSync(changeDir).filter(f => f.endsWith('.json')); + expect(changeFiles).toHaveLength(1); + + // Verify the content was merged + const changeFilePath = path.join(changeDir, changeFiles[0]); + const changeFileContent = fs.readJSONSync(changeFilePath); + expect(changeFileContent.comment).toBe('Initial change\n\nAdditional change'); + expect(changeFileContent.type).toBe('minor'); // Higher priority type + }); + + it('handles case when no existing change files exist in fixup mode', async () => { + const changes: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Test change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + mockPromptForChange.mockResolvedValue(changes); + + const options = getOptions({ + fixup: true, + package: ['foo'], + commit: false, + }); + + // Should not throw an error, but should fall back to creating a normal change file + await change(options); + + // Verify a change file was created (fallback to normal behavior) + const changeDir = path.join(repository.rootPath, 'change'); + const changeFiles = fs.existsSync(changeDir) ? fs.readdirSync(changeDir).filter(f => f.endsWith('.json')) : []; + expect(changeFiles).toHaveLength(1); + + // Verify the content is correct + const changeFilePath = path.join(changeDir, changeFiles[0]); + const changeFileContent = fs.readJSONSync(changeFilePath); + expect(changeFileContent.comment).toBe('Test change'); + expect(changeFileContent.type).toBe('patch'); + }); + + it('handles empty changes gracefully', async () => { + mockPromptForChange.mockResolvedValue(undefined); + + const options = getOptions({ + fixup: true, + package: ['foo'], + }); + + // Should not throw an error + await change(options); + }); +}); diff --git a/src/__functional__/options/getCliOptions.test.ts b/src/__functional__/options/getCliOptions.test.ts index fe63eb956..cf361f677 100644 --- a/src/__functional__/options/getCliOptions.test.ts +++ b/src/__functional__/options/getCliOptions.test.ts @@ -199,6 +199,16 @@ describe('getCliOptions', () => { expect(options).toEqual({ ...defaults, foo: ['bar', 'baz'] }); }); + it('parses fixup flag', () => { + const options = getCliOptionsTest(['change', '--fixup']); + expect(options).toEqual({ ...defaults, command: 'change', fixup: true }); + }); + + it('parses negated fixup flag', () => { + const options = getCliOptionsTest(['change', '--no-fixup']); + expect(options).toEqual({ ...defaults, command: 'change', fixup: false }); + }); + // documenting current behavior (doesn't have to stay this way) it('for additional options, does not handle multiple values as part of array', () => { // in this case the trailing value "baz" would be treated as the command since it's the first diff --git a/src/__tests__/changefile/writeChangeFilesFixup.test.ts b/src/__tests__/changefile/writeChangeFilesFixup.test.ts new file mode 100644 index 000000000..c2975e017 --- /dev/null +++ b/src/__tests__/changefile/writeChangeFilesFixup.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { writeChangeFilesFixup } from '../../changefile/writeChangeFilesFixup'; +import { RepositoryFactory } from '../../__fixtures__/repositoryFactory'; +import { generateChangeFiles } from '../../__fixtures__/changeFiles'; +import { getDefaultOptions } from '../../options/getDefaultOptions'; +import type { ChangeFileInfo } from '../../types/ChangeInfo'; +import type { Repository } from '../../__fixtures__/repository'; +import type { BeachballOptions } from '../../types/BeachballOptions'; +import fs from 'fs-extra'; +import path from 'path'; + +describe('writeChangeFilesFixup', () => { + let repositoryFactory: RepositoryFactory; + let repository: Repository; + + function getOptions(options?: Partial): BeachballOptions { + return { + ...getDefaultOptions(), + path: repository.rootPath, + ...options, + }; + } + + beforeAll(() => { + repositoryFactory = new RepositoryFactory('single'); + }); + + beforeEach(() => { + repository = repositoryFactory.cloneRepository(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + repository?.cleanUp(); + }); + + it('updates the most recent change file and creates a fixup commit', async () => { + const options = getOptions({ commit: true }); // Ensure change files are committed + + // Create an initial change file and commit it + const initialChanges: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Initial change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + generateChangeFiles(initialChanges, options); + + // Get the change file path + const changeFiles = fs.readdirSync(path.join(repository.rootPath, 'change')); + expect(changeFiles).toHaveLength(1); + const changeFilePath = path.join(repository.rootPath, 'change', changeFiles[0]); + + // Read the initial change file to verify its content + const initialChangeFileContent = fs.readJSONSync(changeFilePath); + expect(initialChangeFileContent.comment).toBe('Initial change'); + expect(initialChangeFileContent.type).toBe('patch'); + + // Now create new changes for fixup + const fixupChanges: ChangeFileInfo[] = [ + { + type: 'minor', + comment: 'Additional change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'minor', + }, + ]; + + // Use fixup mode + const result = await writeChangeFilesFixup(fixupChanges, options); + + // Verify the function returned the path to the updated file + expect(result).toBe(changeFilePath); + + // Verify the change file was updated with merged content + const updatedChangeFileContent = fs.readJSONSync(changeFilePath); + expect(updatedChangeFileContent.comment).toBe('Initial change\n\nAdditional change'); + expect(updatedChangeFileContent.type).toBe('minor'); // Higher severity should be kept + expect(updatedChangeFileContent.packageName).toBe('foo'); + + // Verify a fixup commit was created + const commitLog = repository.git(['log', '--oneline', '-n', '2']); + const commits = commitLog.stdout.trim().split('\n'); + expect(commits).toHaveLength(2); + expect(commits[0]).toMatch(/fixup!/); + }); + + it('merges grouped change files correctly', async () => { + const options = getOptions({ groupChanges: true, commit: true }); // Ensure change files are committed + + // Create an initial grouped change file and commit it + const initialChanges: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Initial change 1', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + { + type: 'patch', + comment: 'Initial change 2', + packageName: 'bar', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + generateChangeFiles(initialChanges, options); + + // Get the grouped change file + const changeFiles = fs.readdirSync(path.join(repository.rootPath, 'change')); + expect(changeFiles).toHaveLength(1); + const changeFilePath = path.join(repository.rootPath, 'change', changeFiles[0]); + + // Read initial content + const initialContent = fs.readJSONSync(changeFilePath); + expect(initialContent.changes).toHaveLength(2); + + // Create new changes for fixup + const fixupChanges: ChangeFileInfo[] = [ + { + type: 'minor', + comment: 'Additional change', + packageName: 'baz', + email: 'test@example.com', + dependentChangeType: 'minor', + }, + ]; + + // Use fixup mode + const result = await writeChangeFilesFixup(fixupChanges, options); + + // Verify the result + expect(result).toBe(changeFilePath); + + // Verify the grouped change file was updated + const updatedContent = fs.readJSONSync(changeFilePath); + expect(updatedContent.changes).toHaveLength(3); + expect(updatedContent.changes[2].comment).toBe('Additional change'); + expect(updatedContent.changes[2].packageName).toBe('baz'); + }); + + it('returns null when no change directory exists', async () => { + const options = getOptions({ path: repository.rootPath }); + + // Ensure no change directory exists + const changePath = path.join(repository.rootPath, 'change'); + if (fs.existsSync(changePath)) { + fs.removeSync(changePath); + } + + const changes: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Test change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + const result = await writeChangeFilesFixup(changes, options); + expect(result).toBeNull(); + }); + + it('returns null when no existing change files are found', async () => { + const options = getOptions({ path: repository.rootPath }); + + // Create change directory but no files + const changePath = path.join(repository.rootPath, 'change'); + fs.ensureDirSync(changePath); + + const changes: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Test change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + const result = await writeChangeFilesFixup(changes, options); + expect(result).toBeNull(); + }); + + it('handles higher change type priority correctly', async () => { + const options = getOptions({ commit: true }); // Ensure change files are committed + + // Create initial change file with major type and commit it + const initialChanges: ChangeFileInfo[] = [ + { + type: 'major', + comment: 'Breaking change', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'major', + }, + ]; + + generateChangeFiles(initialChanges, options); + + // Create fixup with lower priority type + const fixupChanges: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Small fix', + packageName: 'foo', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + const changeFiles = fs.readdirSync(path.join(repository.rootPath, 'change')); + const changeFilePath = path.join(repository.rootPath, 'change', changeFiles[0]); + + await writeChangeFilesFixup(fixupChanges, options); + + // Verify the major type is preserved + const updatedContent = fs.readJSONSync(changeFilePath); + expect(updatedContent.type).toBe('major'); + expect(updatedContent.comment).toBe('Breaking change\n\nSmall fix'); + }); + + it('selects first file alphabetically when multiple change files exist in same commit', async () => { + const options = getOptions({ commit: true }); + + // Create multiple change files in a single commit + const multipleChanges: ChangeFileInfo[] = [ + { + type: 'patch', + comment: 'Change for zebra package', + packageName: 'zebra', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + { + type: 'patch', + comment: 'Change for alpha package', + packageName: 'alpha', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + { + type: 'patch', + comment: 'Change for beta package', + packageName: 'beta', + email: 'test@example.com', + dependentChangeType: 'patch', + }, + ]; + + generateChangeFiles(multipleChanges, options); + + // Get all change files - should be 3 + const allChangeFiles = fs.readdirSync(path.join(repository.rootPath, 'change')).filter(f => f.endsWith('.json')); + expect(allChangeFiles).toHaveLength(3); + + // Sort them to find which should be selected (first alphabetically) + allChangeFiles.sort(); + const expectedSelectedFile = allChangeFiles[0]; + + // Now create a fixup change + const fixupChanges: ChangeFileInfo[] = [ + { + type: 'minor', + comment: 'Additional change', + packageName: 'any', + email: 'test@example.com', + dependentChangeType: 'minor', + }, + ]; + + const result = await writeChangeFilesFixup(fixupChanges, options); + + // Verify it selected the first file alphabetically + const expectedPath = path.join(repository.rootPath, 'change', expectedSelectedFile); + expect(result).toBe(expectedPath); + + // Verify the file was updated + const updatedContent = fs.readJSONSync(expectedPath); + expect(updatedContent.comment).toContain('Additional change'); + }); +}); diff --git a/src/changefile/writeChangeFilesFixup.ts b/src/changefile/writeChangeFilesFixup.ts new file mode 100644 index 000000000..f10b223df --- /dev/null +++ b/src/changefile/writeChangeFilesFixup.ts @@ -0,0 +1,242 @@ +import type { ChangeFileInfo } from '../types/ChangeInfo'; +import type { ChangeType } from '../types/ChangeInfo'; +import { getChangePath } from '../paths'; +import { getBranchName, stage } from 'workspace-tools'; +import { gitAsync } from '../git/gitAsync'; +import fs from 'fs-extra'; +import path from 'path'; +import type { BeachballOptions } from '../types/BeachballOptions'; + +/** + * Updates the most recently committed change file with new changes and creates a fixup commit. + * Searches through recent commits to find the most recent commit that introduced change files. + * @param changes - The new changes to add to the existing change file + * @param options - Beachball options + * @returns The path to the updated change file, or null if no suitable change file was found + */ +export async function writeChangeFilesFixup( + changes: ChangeFileInfo[], + options: Pick +): Promise { + const { path: cwd, groupChanges } = options; + const changePath = getChangePath(options); + const branchName = getBranchName(cwd); + + if (!(Object.keys(changes).length && branchName)) { + return null; + } + + if (!fs.existsSync(changePath)) { + console.warn('No change directory found. Cannot use fixup mode without existing change files.'); + return null; + } + + // Find the most recent commit that introduced change files + const commitAndFile = await findMostRecentChangeFileCommit(cwd, changePath); + + if (!commitAndFile) { + console.warn('No recent commits found that introduced change files. Cannot use fixup mode.'); + return null; + } + + const { commitHash, changeFileName } = commitAndFile; + const changeFilePath = path.join(changePath, changeFileName); + + console.log(`Updating change file from commit ${commitHash}: ${changeFileName}`); + + // Read existing change file + let existingChangeInfo: ChangeFileInfo | { changes: ChangeFileInfo[] }; + try { + existingChangeInfo = fs.readJSONSync(changeFilePath); + } catch (e) { + console.error(`Error reading existing change file ${changeFilePath}: ${e}`); + return null; + } + + // Update the change file with new changes + if (groupChanges) { + // For grouped changes, add to the existing changes array + const groupedChanges = existingChangeInfo as { changes: ChangeFileInfo[] }; + groupedChanges.changes.push(...changes); + fs.writeFileSync(changeFilePath, JSON.stringify(groupedChanges, null, 2)); + } else { + // For individual change files, merge the new change with the existing one + // We assume there's only one new change for fixup mode with individual files + if (changes.length > 1) { + console.warn( + 'Fixup mode with individual change files only supports updating one package at a time. Using the first change.' + ); + } + const newChange = changes[0]; + const existingChange = existingChangeInfo as ChangeFileInfo; + + // Merge comments and other properties + const mergedChange: ChangeFileInfo = { + ...existingChange, + ...newChange, + // Override with merged comment and higher change type + comment: existingChange.comment ? `${existingChange.comment}\n\n${newChange.comment}` : newChange.comment, + type: getHigherChangeType(existingChange.type, newChange.type), + // But preserve the original package name + packageName: existingChange.packageName, + }; + + fs.writeJSONSync(changeFilePath, mergedChange, { spaces: 2 }); + } + + // Stage the updated change file + stage([changeFilePath], cwd); + + // Create a fixup commit using the original commit hash + console.log(`Original change file was added in commit: ${commitHash}`); + + // Create a fixup commit + const commitResult = await gitAsync(['commit', '--fixup', commitHash], { cwd, verbose: true }); + + if (!commitResult.success) { + console.error(`Failed to create fixup commit: ${commitResult.errorMessage}`); + return null; + } + + console.log(`Created fixup commit for ${commitHash}`); + console.log(`Updated change file: ${changeFilePath}`); + + return changeFilePath; +} + +const MAX_COMMITS = 100; + +/** + * Finds the most recent commit that introduced change files by searching through + * the current branch's commits from newest to oldest. + * @param cwd - Working directory + * @param changePath - Path to the change directory + * @returns Object with commit hash and change file name, or null if none found + */ +async function findMostRecentChangeFileCommit( + cwd: string, + changePath: string +): Promise<{ commitHash: string; changeFileName: string } | null> { + const relativeChangePath = path.relative(cwd, changePath); + + // Get the fork point (merge base with main/master branch) + const forkPointResult = await findForkPoint(cwd); + if (!forkPointResult) { + console.warn('Unable to determine fork-point for current branch'); + return null; + } + + // Get commits on the current branch, limited to MAX_COMMITS + const logResult = await gitAsync( + ['log', '--oneline', '--format=%H', `-n`, MAX_COMMITS.toString(), `${forkPointResult}..HEAD`], + { cwd } + ); + if (!logResult.success) { + console.warn('Failed to get git log for finding recent change file commits'); + return null; + } + + const commits = logResult.stdout + .trim() + .split('\n') + .filter(line => line.trim()); + + // Search through commits from newest to oldest + for (const commitHash of commits) { + // Check what files were added in this commit (compare with parent) + const diffResult = await gitAsync( + [ + 'diff-tree', + '--no-commit-id', + '--name-status', + '--diff-filter=A', // Only look for added files + '-r', // Recursive to show files inside directories + commitHash, + ], + { cwd } + ); + + if (!diffResult.success) { + continue; + } + + // Look for added change files + const addedChangeFiles: string[] = []; + const lines = diffResult.stdout + .trim() + .split('\n') + .filter(line => line.trim()); + + for (const line of lines) { + // Handle both formats: "A\tfilepath" and "filepath" (when only added files are shown) + const parts = line.split('\t'); + const filePath = parts.length > 1 ? parts[1] : line; + + if (filePath) { + // Normalize paths for comparison + const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedChangePath = relativeChangePath.replace(/\\/g, '/'); + + if (normalizedPath.startsWith(normalizedChangePath) && normalizedPath.endsWith('.json')) { + const fileName = path.basename(normalizedPath); + addedChangeFiles.push(fileName); + } + } + } + + if (addedChangeFiles.length > 0) { + // Sort alphabetically and take the first one + addedChangeFiles.sort(); + const selectedFile = addedChangeFiles[0]; + + // Verify the file still exists + const fullPath = path.join(changePath, selectedFile); + if (fs.existsSync(fullPath)) { + console.log(`Found change file commit: ${commitHash} added ${selectedFile}`); + return { commitHash, changeFileName: selectedFile }; + } + } + } + + return null; +} + +/** + * Finds the fork point (merge base) with the main branch. + * @param cwd - Working directory + * @returns The commit hash of the fork point, or null if not found + */ +async function findForkPoint(cwd: string): Promise { + // Try common main branch names + const mainBranches = ['origin/main', 'origin/master', 'main', 'master']; + + for (const mainBranch of mainBranches) { + const result = await gitAsync(['merge-base', 'HEAD', mainBranch], { cwd }); + if (result.success) { + return result.stdout.trim(); + } + } + + return null; +} + +/** + * Determines which change type has higher severity for merging changes. + * Order: premajor/major > preminor/minor > prepatch/patch > prerelease > none + */ +function getHigherChangeType(type1: ChangeType, type2: ChangeType): ChangeType { + const changeTypeOrder: Record = { + premajor: 7, + major: 6, + preminor: 5, + minor: 4, + prepatch: 3, + patch: 2, + prerelease: 1, + none: 0, + }; + const order1 = changeTypeOrder[type1] ?? 0; + const order2 = changeTypeOrder[type2] ?? 0; + + return order1 >= order2 ? type1 : type2; +} diff --git a/src/commands/change.ts b/src/commands/change.ts index 524fa7c2e..2f1a14b8c 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -1,13 +1,14 @@ import type { BeachballOptions } from '../types/BeachballOptions'; import { promptForChange } from '../changefile/promptForChange'; import { writeChangeFiles } from '../changefile/writeChangeFiles'; +import { writeChangeFilesFixup } from '../changefile/writeChangeFilesFixup'; import { getPackageInfos } from '../monorepo/getPackageInfos'; import { getRecentCommitMessages, getUserEmail } from 'workspace-tools'; import { getChangedPackages } from '../changefile/getChangedPackages'; import { getPackageGroups } from '../monorepo/getPackageGroups'; export async function change(options: BeachballOptions): Promise { - const { branch, path: cwd, package: specificPackage } = options; + const { branch, path: cwd, package: specificPackage, fixup } = options; const packageInfos = getPackageInfos(cwd); const packageGroups = getPackageGroups(packageInfos, cwd, options.groups); @@ -35,6 +36,19 @@ export async function change(options: BeachballOptions): Promise { }); if (changes) { + if (fixup) { + // Use fixup mode: update most recent change file and create fixup commit + const updatedChangeFile = await writeChangeFilesFixup(changes, options); + if (updatedChangeFile) { + // Success! + return; + } + + console.log('No suitable commit found for fixup mode. Creating new change file instead.'); + // Fall back to normal mode + } + + // Normal mode: create new change files writeChangeFiles(changes, options); } } diff --git a/src/help.ts b/src/help.ts index 84fae8298..e089efd58 100644 --- a/src/help.ts +++ b/src/help.ts @@ -47,6 +47,7 @@ Options supported by all commands: (can be specified multiple times) --all - generate change files for all packages --dependent-change-type - use this change type for dependent packages (default patch) + --fixup - update the most recently added change file and create a fixup commit --no-commit - stage change files only 'check' options: diff --git a/src/options/getCliOptions.ts b/src/options/getCliOptions.ts index b3c3d47f1..2ffcee279 100644 --- a/src/options/getCliOptions.ts +++ b/src/options/getCliOptions.ts @@ -12,6 +12,7 @@ const booleanOptions = [ 'commit', 'disallowDeletedChangeFiles', 'fetch', + 'fixup', 'forceVersions', 'gitTags', 'help', diff --git a/src/types/BeachballOptions.ts b/src/types/BeachballOptions.ts index 0d0dd0baf..7baacda7b 100644 --- a/src/types/BeachballOptions.ts +++ b/src/types/BeachballOptions.ts @@ -45,6 +45,11 @@ export interface CliOptions command: string; configPath?: string; dependentChangeType?: ChangeType; + /** + * For the change command: update the most recently added change file instead of creating a new one, + * and commit with git commit --fixup using the hash of the original commit that added the change file. + */ + fixup?: boolean; /** * For sync: use the version from the registry even if it's older than local. */ From f63dc34698e394e0f6ea890fbea8fbd8c30adb59 Mon Sep 17 00:00:00 2001 From: Evan Charlton Date: Fri, 24 Oct 2025 11:54:53 +0200 Subject: [PATCH 2/2] Change files --- change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json diff --git a/change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json b/change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json new file mode 100644 index 000000000..765c3767f --- /dev/null +++ b/change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Introduce --fixup to change subcommand", + "packageName": "beachball", + "email": "evancharlton@microsoft.com", + "dependentChangeType": "patch" +}