Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions change/beachball-d725cdf6-20dd-41df-9402-cf9cf4f9bc08.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Introduce --fixup to change subcommand",
"packageName": "beachball",
"email": "evancharlton@microsoft.com",
"dependentChangeType": "patch"
}
7 changes: 7 additions & 0 deletions docs/cli/change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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:
Expand Down
172 changes: 172 additions & 0 deletions src/__functional__/changefile/changeFixup.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof promptForChange>;

describe('change command with fixup mode', () => {
let repositoryFactory: RepositoryFactory;
let repository: Repository;

function getOptions(options?: Partial<BeachballOptions>): 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);
});
});
10 changes: 10 additions & 0 deletions src/__functional__/options/getCliOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading