diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..c89661d85 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "enabledPlugins": { + "gopls-lsp@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "rust-analyzer-lsp@claude-plugins-official": true + } +} diff --git a/.claude/shared/rust-and-go.md b/.claude/shared/rust-and-go.md new file mode 100644 index 000000000..6b5fb22f9 --- /dev/null +++ b/.claude/shared/rust-and-go.md @@ -0,0 +1,51 @@ + + +## Rust and Go Implementation Guidelines + +The `rust/` and `go/` directories contain parallel re-implementations of beachball's `check` and `change` commands and the corresponding tests. + +### Scope + +Implemented: + +- CLI args (as relevant for supported commands) +- JSON config loading (`.beachballrc.json` and `package.json` `"beachball"` field) +- workspaces detection (npm, yarn, pnpm, rush, lerna) +- `getChangedPackages` (git diff + file-to-package mapping + change file dedup) +- `validate()` (minus `bumpInMemory`/dependency validation) +- non-interactive `change` command (`--type` + `--message`) +- `check` command. + +Not implemented: + +- JS config files +- interactive prompts +- all bumping and publishing operations +- sync + +### Implementation requirements + +The behavior, log messages, and tests as specified in the TypeScript code MUST BE MATCHED in the Go/Rust code. + +- Do not change behavior or logs or remove tests, unless it's exclusively related to features which you've been asked not to implement yet. +- If a different pattern would be more idiomatic in the target language, or it's not possible to implement the exact same behavior in the target language, ask the user before changing anything. +- Don't make assumptions about the implementation of functions from `workspace-tools`. Check the JS implementation in `node_modules` and exactly follow that. + +When porting tests, add a comment by each Rust/Go test with the name of the corresponding TS test. If any TS tests have been omitted or combined, add a comment indicating which tests and why. + +Use syntax and helpers from the newest version of the language where it makes sense. If a particular scenario is most commonly handled in this language by some external library, and the library would meaningfully simplify the code, ask the user about adding the library as a dependency. + +After making changes, run the commands to build, test, lint, and format. + +### Key Implementation Details + +**Git commands**: Both shell out to `git` (matching the TS approach via workspace-tools). The git flags used should exactly match the workspace-tools code. Three-dot range (`branch...`) is used for diffs. + +**Config loading**: Searches `.beachballrc.json` then `package.json` `"beachball"` field, walking up from cwd but stopping at git root. + +**Glob matching**: Two modes matching the TS behavior — `matchBase` (patterns without `/` match basename) for `ignorePatterns`, full path matching for `scope`/`groups`. + +### Known Gotchas + +- **macOS `/tmp` symlink**: `/tmp` is a symlink to `/private/tmp`. `git rev-parse --show-toplevel` resolves symlinks but `tempfile`/`os.MkdirTemp` does not. Both implementations canonicalize paths (`std::fs::canonicalize` in Rust, `filepath.EvalSymlinks` in Go) when comparing git-returned paths with filesystem paths. +- **Default branch name**: Modern git defaults to `main`. Test fixtures use `--initial-branch=master` for bare repo init to match the `origin/master` refs used in tests. diff --git a/.claude/skills/go-impl.md b/.claude/skills/go-impl.md new file mode 100644 index 000000000..0e915debd --- /dev/null +++ b/.claude/skills/go-impl.md @@ -0,0 +1,11 @@ +--- +name: go-impl +description: | + Go implementation guide for beachball's Go port. + TRIGGER when: user asks to edit, write, test, or debug Go code under the go/ directory, or opens/references go/*.go files. + DO NOT TRIGGER when: working only with TypeScript or Rust code. +--- + +!`cat go/README.md` + +!`cat .claude/skills/rust-and-go.md` diff --git a/.claude/skills/rust-impl.md b/.claude/skills/rust-impl.md new file mode 100644 index 000000000..78c413b94 --- /dev/null +++ b/.claude/skills/rust-impl.md @@ -0,0 +1,11 @@ +--- +name: rust-impl +description: | + Rust implementation guide for beachball's Rust port. + TRIGGER when: user asks to edit, write, test, or debug Rust code under the rust/ directory, or opens/references rust/*.rs files. + DO NOT TRIGGER when: working only with TypeScript or Go code. +--- + +!`cat rust/README.md` + +!`cat .claude/skills/rust-and-go.md` diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index df4616fda..6f757d119 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,9 @@ jobs: - run: yarn --frozen-lockfile + - run: yarn format:check + if: ${{ matrix.os == 'ubuntu-latest' }} + - run: yarn build - run: yarn checkchange --verbose @@ -84,3 +87,89 @@ jobs: - run: yarn docs:build working-directory: ./docs + + rust: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: build rust (${{ matrix.os }}) + + runs-on: ${{ matrix.os }} + + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + + - name: Format + run: cargo fmt --check + working-directory: ./rust + if: ${{ matrix.os == 'ubuntu-latest' }} + + - name: Build + run: cargo build + working-directory: ./rust + + - name: Lint + run: cargo clippy --all-targets -- -D warnings + working-directory: ./rust + + - name: Test + run: cargo test + working-directory: ./rust + + go: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: build go (${{ matrix.os }}) + + runs-on: ${{ matrix.os }} + + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + + - name: Format + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "The following files need formatting (run 'gofmt -w .'):" + echo "$unformatted" + exit 1 + fi + working-directory: ./go + if: ${{ matrix.os == 'ubuntu-latest' }} + + - name: Build + run: go build ./... + working-directory: ./go + + - name: Lint + run: go vet ./... + working-directory: ./go + + - name: Test + run: go test ./... + working-directory: ./go diff --git a/.gitignore b/.gitignore index 49679aa9a..e86411a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,13 @@ lib/ package-lock.json # ignore when switching between yarn 1/4 branches /.yarn +rust/target +go/beachball +.claude/settings.local.json docs/.vuepress/.cache docs/.vuepress/.temp docs/.yarn/* !docs/.yarn/patches/ !docs/.yarn/releases/ + diff --git a/.prettierignore b/.prettierignore index 729a8d215..da2a7eb2d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,10 +8,15 @@ .nojekyll .nvmrc docs/.vuepress/dist/ +**/.yarn /change/ /CHANGELOG.* /lib/ LICENSE node_modules/ SECURITY.md -yarn.lock \ No newline at end of file +yarn.lock +rust/** +!rust/README.md +go/** +!go/README.md diff --git a/.vscode/settings.json b/.vscode/settings.json index ccfd36363..bbac540d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,16 @@ "**/node_modules": true, "**/lib": true, "**/*.svg": true, - "**/.yarn": true + "**/.yarn": true, + "**/target": true + }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true + }, + "[go]": { + "editor.defaultFormatter": "golang.go", + "editor.formatOnSave": true }, "files.associations": { // VS Code doesn't have json5 support, so handle .json5 files as jsonc. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..6820d70cb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Beachball is a semantic version bumping tool for JS repos and monorepos. It manages change files, calculates version bumps, generates changelogs, and publishes packages to npm registries. + +## Common Commands + +### Building + +```bash +yarn build # Compile TypeScript to lib/ +``` + +### Testing + +```bash +yarn test:all # Run all tests in order: unit, functional, then e2e +yarn test:unit # Unit tests only (__tests__ directories) +yarn test:func # Functional tests only (__functional__ directories) +yarn test:e2e # E2E tests only (__e2e__ directories) +yarn update-snapshots # Update all test snapshots +``` + +To run a single test file: + +```bash +yarn jest path/to/test.test.ts +``` + +To run a single test within a file: + +```bash +yarn jest path/to/test.test.ts -t "test name pattern" +``` + +### Linting + +```bash +yarn lint # Run all linting (deps + code) +yarn lint:code # ESLint only +yarn lint:deps # Depcheck only +yarn format # Format with Prettier +``` + +### Final steps before PR + +```bash +yarn change --type minor|patch --message "message" # Create a change file +``` + +## Architecture Overview + +### Core Processing Flow + +Beachball's bump process follows a **two-phase architecture** (calculate in-memory, then apply to disk): + +1. **Configuration Layer** (`src/options/`) - Merges CLI args, repo config (beachball.config.js), and defaults into `BeachballOptions` +2. **Discovery Layer** (`src/monorepo/`) - Discovers packages, builds dependency graphs, applies scoping +3. **Validation Layer** (`src/validation/`) - Validates setup, reads change files, pre-calculates bump info +4. **Calculation Phase** (`bumpInMemory()`) - Computes all version changes without side effects +5. **Application Phase** (`performBump()`) - Writes package.json, changelogs, deletes change files +6. **Publishing Layer** (`src/publish/`) - Registry operations and git push with retries + +### Key Components + +**Entry Point:** `src/cli.ts` + +- Single async IIFE that validates git repo, parses options, routes to commands + +**Commands** (`src/commands/`): + +- `change` - Interactive prompts to create change files +- `check` - Validates change files exist when needed +- `bump` - Calculates and applies version bumps (no publish/push) +- `publish` - Full workflow: bump → publish to registry → git push with tags +- `canary` - Canary/prerelease publishing +- `sync` - Synchronizes versions from registry +- `init` - Repository initialization + +**Bump Logic** (`src/bump/bumpInMemory.ts`): +Multi-pass algorithm that calculates version bumps. See comments in file. + +**Change Files** (`src/changefile/`): + +- Change files stored in `change/` directory track intended version changes +- See `src/types/ChangeInfo.ts` `ChangeFileInfo` for the info stored in each change file. For grouped change files (config has `groupChanges: true`), the type will be `ChangeInfoMultiple`. +- Folder contains helper to read, write, and unlink change files + +**Publishing** (`src/publish/`): + +- `publishToRegistry()` - Validates, applies publishConfig, runs hooks, publishes (respects dependency order) +- `bumpAndPush()` - Git operations: creates temp `publish_*` branch, fetches, merges, bumps, commits, tags, pushes +- Pre/post hooks available (see `HooksOptions` in `src/types/BeachballOptions.ts`) + +**Context Passing:** +`CommandContext` aggregates reusable data (packages, version groups, change files, and more) to avoid repeated calculations. See source in `src/types/CommandContext.ts`. + +### Important Patterns + +**Immutable-First Design:** + +- `bumpInMemory` makes a copy of its input objects before making changes in memory +- Separate calculation (`bumpInMemory`) from on-disk application (`performBump`) + +**Validation-First:** + +- `validate()` runs before most commands to validate config and repo state +- Pre-calculates expensive operations when needed + +**Package Groups:** + +- Multiple packages can be versioned together (synchronized versions) +- All packages in a group receive the maximum change type +- Configured via `groups` option in beachball.config.js + +**Dependent Versioning:** + +- When package A changes, dependents of A can be auto-bumped +- Controlled by `dependentChangeType` in change files and `bumpDeps` option +- Propagation respects package groups + +**Change Type Hierarchy:** + +- Defined in `src/changefile/changeTypes.ts` `SortedChangeTypes` +- Packages, groups, and the repo config can specify `disallowedChangeTypes` + +**Scoping:** + +- Filters which packages participate in operations +- Based on git changes, explicit inclusion/exclusion, or package patterns +- Affects change file validation, bumping, and publishing + +**Git Operations:** + +- All git commands use `gitAsync()` wrapper with logging +- Push retries 5 times with fetch/merge between attempts +- Temporary branches ensure safety during publish + +**Testing Structure:** + +- Fixtures and helpers: `__fixtures__/` directory +- Unit tests: `__tests__/` directory +- Functional tests: `__functional__/` directory +- E2E tests: `__e2e__/` directory +- Uses Jest projects to separate test types +- Verdaccio (local npm registry) used for e2e testing +- Many of the tests cover log output since the logs are Beachball's UI, so we need to verify its correctness and readability + +## TypeScript Configuration + +- Current target: ES2020 (Node 14+ compatible) +- Strict mode enabled with `noUnusedLocals` + +## Important Notes + +- Change files (not git commits) are the source of truth for version bumps +- Beachball is not intended to have a public API (only the CLI and configuration options are supported). However, some of the command functions and `gatherBumpInfo` have been used directly by other teams, so we try to maintain compatibility with old signatures. +- Package/workspace manager is auto-detected (supports npm, yarn, pnpm, rush, lerna) + +## Experimental: Rust and Go Implementations + +The `rust/` and `go/` directories contain experimental parallel re-implementations. See the `go-impl` and `rust-impl` custom skills as relevant. diff --git a/beachball.config.js b/beachball.config.js index 46b742484..6b5c5e5e8 100644 --- a/beachball.config.js +++ b/beachball.config.js @@ -5,10 +5,12 @@ const config = { ignorePatterns: [ '.*ignore', '*.yml', + '.claude/**/*', '.eslintrc.js', '.github/**', '.prettierrc.json5', '.vscode/**', + 'CLAUDE.md', 'docs/**', 'docs/.vuepress/**', 'jest.*.js', @@ -17,6 +19,8 @@ const config = { 'src/__*/**', // This one is especially important (otherwise dependabot would be blocked by change file requirements) 'yarn.lock', + 'rust/**/*', + 'go/**/*', ], }; diff --git a/change/beachball-8bc0af86-788d-4dee-84db-ff3f4c3f90f3.json b/change/beachball-8bc0af86-788d-4dee-84db-ff3f4c3f90f3.json new file mode 100644 index 000000000..98e9f5bbb --- /dev/null +++ b/change/beachball-8bc0af86-788d-4dee-84db-ff3f4c3f90f3.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "testing", + "packageName": "beachball", + "email": "elcraig@microsoft.com", + "dependentChangeType": "none" +} diff --git a/go/README.md b/go/README.md new file mode 100644 index 000000000..08ae39986 --- /dev/null +++ b/go/README.md @@ -0,0 +1,92 @@ +# Beachball (Go) + +A Go re-implementation of beachball's `check` and `change` commands. + + + +## Prerequisites + +- Go 1.26+ +- `git` on PATH + +## Building + +```bash +go build ./... +go build -o beachball ./cmd/beachball +``` + +## Formatting + +```bash +gofmt -w . +``` + +To check without modifying (as CI does): + +```bash +gofmt -l . +``` + +If any files are listed, they need formatting. + +## Linting + +```bash +go vet ./... +``` + +## Testing + +```bash +go test ./... +``` + +Run a specific test: + +```bash +go test ./internal/changefile/ -run TestExcludesPackagesWithExistingChangeFiles +``` + +Verbose output: + +```bash +go test -v ./... +``` + +## Running + +```bash +go run ./cmd/beachball check +go run ./cmd/beachball change --type patch --message "my change" +``` + +Or after building: + +```bash +./beachball check +./beachball change -t minor -m "add feature" +``` + +## CLI Options + +Currently the `check` command and non-interactive `change` command have been implemented. Options should be the same as JS Beachball. + +## Project Structure + +Tests (`*_test.go`) live alongside the corresponding files. + +``` +cmd/beachball/ + main.go # CLI entry point (cobra) +internal/ + types/ # Basic types + options/ # Option merging, config file loading + git/ # Git operations (shell out to git) + monorepo/ # Package discovery, groups, filtering + changefile/ # Change file handling, changed package detection + validation/ # Validation + commands/ # Main commands + logging/ # Output helpers + testutil/ # Test helpers +``` diff --git a/go/cmd/beachball/main.go b/go/cmd/beachball/main.go new file mode 100644 index 000000000..e5dc80908 --- /dev/null +++ b/go/cmd/beachball/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "os" + + "github.com/microsoft/beachball/internal/commands" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/types" + "github.com/spf13/cobra" +) + +func main() { + var cli types.CliOptions + + rootCmd := &cobra.Command{ + Use: "beachball", + Short: "Beachball - automated semantic versioning and change management", + } + + // Persistent flags + rootCmd.PersistentFlags().StringVar(&cli.Branch, "branch", "", "target branch") + rootCmd.PersistentFlags().StringVar(&cli.Path, "path", "", "path to the repository") + rootCmd.PersistentFlags().StringVar(&cli.ConfigPath, "config-path", "", "path to beachball config") + + boolPtr := func(b bool) *bool { return &b } + + checkCmd := &cobra.Command{ + Use: "check", + Short: "Check if change files are needed", + RunE: func(cmd *cobra.Command, args []string) error { + cli.Command = "check" + cwd, _ := os.Getwd() + parsed, err := options.GetParsedOptions(cwd, cli) + if err != nil { + return err + } + return commands.Check(parsed) + }, + } + + changeCmd := &cobra.Command{ + Use: "change", + Short: "Create change files", + RunE: func(cmd *cobra.Command, args []string) error { + cli.Command = "change" + cwd, _ := os.Getwd() + parsed, err := options.GetParsedOptions(cwd, cli) + if err != nil { + return err + } + return commands.Change(parsed) + }, + } + changeCmd.Flags().StringVarP(&cli.ChangeType, "type", "t", "", "change type (patch, minor, major, etc.)") + changeCmd.Flags().StringVarP(&cli.Message, "message", "m", "", "change description") + changeCmd.Flags().StringVar(&cli.DependentChangeType, "dependent-change-type", "", "change type for dependent packages (patch, minor, major, none)") + changeCmd.Flags().StringSliceVar(&cli.Package, "package", nil, "specific package(s) to create change files for") + + var noCommitFlag bool + changeCmd.Flags().BoolVar(&noCommitFlag, "no-commit", false, "don't commit change files") + + var noFetchFlag bool + rootCmd.PersistentFlags().BoolVar(&noFetchFlag, "no-fetch", false, "don't fetch remote branch") + + var allFlag, verboseFlag bool + rootCmd.PersistentFlags().BoolVar(&allFlag, "all", false, "include all packages") + rootCmd.PersistentFlags().BoolVar(&verboseFlag, "verbose", false, "verbose output") + + cobra.OnInitialize(func() { + if rootCmd.PersistentFlags().Changed("all") { + cli.All = boolPtr(allFlag) + } + if rootCmd.PersistentFlags().Changed("verbose") { + cli.Verbose = boolPtr(verboseFlag) + } + if rootCmd.PersistentFlags().Changed("no-fetch") { + cli.Fetch = boolPtr(!noFetchFlag) + } + if changeCmd.Flags().Changed("no-commit") { + cli.Commit = boolPtr(!noCommitFlag) + } + }) + + rootCmd.AddCommand(checkCmd, changeCmd) + + if err := rootCmd.Execute(); err != nil { + logging.Error.Println(err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 000000000..70d0c72ec --- /dev/null +++ b/go/go.mod @@ -0,0 +1,18 @@ +module github.com/microsoft/beachball + +go 1.26 + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 000000000..cbffcf44c --- /dev/null +++ b/go/go.sum @@ -0,0 +1,23 @@ +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/internal/changefile/changed_packages.go b/go/internal/changefile/changed_packages.go new file mode 100644 index 000000000..ad2e5506d --- /dev/null +++ b/go/internal/changefile/changed_packages.go @@ -0,0 +1,213 @@ +package changefile + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/types" +) + +// isPackageIncluded checks whether a package should be included in changed packages. +func isPackageIncluded(info *types.PackageInfo, scopedPackages types.ScopedPackages) (bool, string) { + if info == nil { + return false, "no corresponding package found" + } + if info.Private { + return false, fmt.Sprintf("%s is private", info.Name) + } + if info.PackageOptions != nil && info.PackageOptions.ShouldPublish != nil && !*info.PackageOptions.ShouldPublish { + return false, fmt.Sprintf("%s has beachball.shouldPublish=false", info.Name) + } + if !scopedPackages[info.Name] { + return false, fmt.Sprintf("%s is out of scope", info.Name) + } + return true, "" +} + +// getMatchingPackage finds which package a changed file belongs to. +func getMatchingPackage(file, cwd string, packagesByPath map[string]*types.PackageInfo) *types.PackageInfo { + absFile := filepath.Join(cwd, file) + dir := filepath.Dir(absFile) + + for { + if info, ok := packagesByPath[dir]; ok { + return info + } + if dir == cwd { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return nil +} + +// getAllChangedPackages returns all changed packages regardless of existing change files. +func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.PackageInfos, scopedPackages types.ScopedPackages) ([]string, error) { + cwd := options.Path + + if options.All { + logging.Verbose.Println("--all option was provided, so including all packages that are in scope (regardless of changes)") + var result []string + for _, pkg := range packageInfos { + included, reason := isPackageIncluded(pkg, scopedPackages) + if included { + logging.Verbose.Printf(" - %s", pkg.Name) + result = append(result, pkg.Name) + } else { + logging.Verbose.Printf(" - ~~%s~~ (%s)", pkg.Name, strings.TrimPrefix(reason, pkg.Name+" ")) + } + } + return result, nil + } + + logging.Info.Printf("Checking for changes against %q", options.Branch) + + if err := git.EnsureSharedHistory(options); err != nil { + return nil, err + } + + // Canonicalize cwd for consistent path matching + canonicalCwd, err := filepath.EvalSymlinks(cwd) + if err != nil { + canonicalCwd = cwd + } + + changes, err := git.GetBranchChanges(options.Branch, cwd) + if err != nil { + return nil, err + } + staged, err := git.GetStagedChanges(cwd) + if err != nil { + return nil, err + } + changes = append(changes, staged...) + + logging.Verbose.Printf("Found %s in current branch (before filtering)", logging.Count(len(changes), "changed file")) + + if len(changes) == 0 { + return nil, nil + } + + // Build ignore patterns + ignorePatterns := append([]string{}, options.IgnorePatterns...) + ignorePatterns = append(ignorePatterns, fmt.Sprintf("%s/*.json", options.ChangeDir)) + ignorePatterns = append(ignorePatterns, "CHANGELOG.md", "CHANGELOG.json") + + nonIgnored := monorepo.FilterIgnoredFiles(changes, ignorePatterns) + + if len(nonIgnored) == 0 { + logging.Verbose.Println("All files were ignored") + return nil, nil + } + + // Build map from package directory path to PackageInfo (canonicalized) + packagesByPath := make(map[string]*types.PackageInfo) + for _, info := range packageInfos { + dir := filepath.Dir(info.PackageJSONPath) + canonical, err := filepath.EvalSymlinks(dir) + if err != nil { + canonical = dir + } + packagesByPath[canonical] = info + } + + includedPackages := make(map[string]bool) + fileCount := 0 + + for _, file := range nonIgnored { + pkgInfo := getMatchingPackage(file, canonicalCwd, packagesByPath) + included, reason := isPackageIncluded(pkgInfo, scopedPackages) + + if !included { + logging.Verbose.Printf(" - ~~%s~~ (%s)", file, reason) + } else { + includedPackages[pkgInfo.Name] = true + fileCount++ + logging.Verbose.Printf(" - %s", file) + } + } + + logging.Verbose.Printf("Found %s in %s that should be published", + logging.Count(fileCount, "file"), logging.Count(len(includedPackages), "package")) + + var result []string + for name := range includedPackages { + result = append(result, name) + } + return result, nil +} + +// GetChangedPackages returns changed packages that don't already have change files. +func GetChangedPackages(options *types.BeachballOptions, packageInfos types.PackageInfos, scopedPackages types.ScopedPackages) ([]string, error) { + // If --package is specified, return those names directly + if len(options.Package) > 0 { + return options.Package, nil + } + + changedPackages, err := getAllChangedPackages(options, packageInfos, scopedPackages) + if err != nil { + return nil, err + } + + changePath := GetChangePath(options) + if _, err := os.Stat(changePath); os.IsNotExist(err) { + return changedPackages, nil + } + + // Check which packages already have change files + changeFiles, err := git.GetChangesBetweenRefs(options.Branch, "A", "*.json", changePath) + if err != nil { + changeFiles = nil + } + + existingPackages := make(map[string]bool) + + for _, file := range changeFiles { + filePath := filepath.Join(changePath, file) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + var multi types.ChangeInfoMultiple + if err := json.Unmarshal(data, &multi); err == nil && len(multi.Changes) > 0 { + for _, change := range multi.Changes { + existingPackages[change.PackageName] = true + } + continue + } + + var single types.ChangeFileInfo + if err := json.Unmarshal(data, &single); err == nil && single.PackageName != "" { + existingPackages[single.PackageName] = true + } else if err != nil { + logging.Warn.Printf("Error reading or parsing change file %s: %v", filePath, err) + } + } + + if len(existingPackages) > 0 { + sorted := slices.Sorted(maps.Keys(existingPackages)) + logging.Info.Printf("Your local repository already has change files for these packages:\n%s", + logging.BulletedList(sorted)) + } + + var result []string + for _, pkg := range changedPackages { + if !existingPackages[pkg] { + result = append(result, pkg) + } + } + return result, nil +} diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go new file mode 100644 index 000000000..c006c1bf0 --- /dev/null +++ b/go/internal/changefile/changed_packages_test.go @@ -0,0 +1,320 @@ +package changefile_test + +import ( + "slices" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// get default options for this file (fetch disabled) +func getDefaultOptions() types.BeachballOptions { + defaultOptions := types.DefaultOptions() + defaultOptions.Branch = testutil.DefaultRemoteBranch + defaultOptions.Fetch = false + + return defaultOptions +} + +func getOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *types.BeachballOptions, extraCli *types.CliOptions) (types.BeachballOptions, types.PackageInfos, types.ScopedPackages) { + t.Helper() + + cli := types.CliOptions{} + if extraCli != nil { + cli = *extraCli + } + + repoOpts := getDefaultOptions() + if overrides != nil { + repoOpts = *overrides + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + packageInfos, err := monorepo.GetPackageInfos(&parsed.Options) + require.NoError(t, err, "failed to get package infos") + scopedPackages := monorepo.GetScopedPackages(&parsed.Options, packageInfos) + return parsed.Options, packageInfos, scopedPackages +} + +func checkOutTestBranch(repo *testutil.Repository, name string) { + repo.Checkout("-b", name, testutil.DefaultBranch) +} + +// ===== Basic tests (TS: getChangedPackages (basic)) ===== + +// TS: "returns empty list when no changes have been made" +func TestReturnsEmptyListWhenNoChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) +} + +// TS: "returns package name when changes exist in a new branch" +func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + checkOutTestBranch(repo, "changes_in_branch") + repo.CommitChange("packages/foo/myFilename") + + buf := testutil.CaptureLogging(t) + overrides := getDefaultOptions() + overrides.Verbose = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result) + assert.Contains(t, buf.String(), "Checking for changes against") +} + +// TS: "returns empty list when changes are CHANGELOG files" +func TestReturnsEmptyListForChangelogChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + checkOutTestBranch(repo, "changelog_changes") + repo.CommitChange("packages/foo/CHANGELOG.md") + + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) +} + +// TS: "returns the given package name(s) as-is" +func TestReturnsGivenPackageNamesAsIs(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + cli := types.CliOptions{Package: []string{"foo"}} + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, &cli) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result) + + cli2 := types.CliOptions{Package: []string{"foo", "bar", "nope"}} + opts2, infos2, scoped2 := getOptionsAndPackages(t, repo, nil, &cli2) + result2, err := changefile.GetChangedPackages(&opts2, infos2, scoped2) + require.NoError(t, err) + assert.Equal(t, []string{"foo", "bar", "nope"}, result2) +} + +// TS: "returns all packages with all: true" +func TestReturnsAllPackagesWithAllTrue(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + overrides := getDefaultOptions() + overrides.All = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + slices.Sort(result) + assert.Equal(t, []string{"a", "b", "bar", "baz", "foo"}, result) +} + +// ===== Single package tests (TS: getChangedPackages) ===== + +// TS: "detects changed files in single-package repo" +func TestDetectsChangedFilesInSinglePackageRepo(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) + + repo.StageChange("foo.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result2) +} + +// TS: "respects ignorePatterns option" +func TestRespectsIgnorePatterns(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + + overrides := getDefaultOptions() + overrides.IgnorePatterns = []string{"*.test.js", "tests/**", "yarn.lock"} + overrides.Verbose = true + + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + + repo.WriteFile("src/foo.test.js") + repo.WriteFile("tests/stuff.js") + repo.WriteFileContent("yarn.lock", "changed") + repo.Git([]string{"add", "-A"}) + + buf := testutil.CaptureVerboseLogging(t) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) + output := buf.String() + assert.Contains(t, output, "ignored by pattern") + assert.Contains(t, output, "All files were ignored") +} + +// ===== Monorepo tests ===== + +// TS: "detects changed files in monorepo" +func TestDetectsChangedFilesInMonorepo(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) + + repo.StageChange("packages/foo/test.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result2) +} + +// TS: "excludes packages that already have change files" +func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test") + repo.CommitChange("packages/foo/test.js") + + overrides := getDefaultOptions() + overrides.Verbose = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + + buf := testutil.CaptureVerboseLogging(t) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Empty(t, result) + output := buf.String() + assert.Contains(t, output, "already has change files for these packages") + assert.Contains(t, output, "Found 1 file in 1 package that should be published") + + // Change bar => bar is the only changed package returned + repo.StageChange("packages/bar/test.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"bar"}, result2) +} + +// TS: "ignores change files that exist in target remote branch" +func TestIgnoresChangeFilesThatExistInTargetRemoteBranch(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + + overrides := getDefaultOptions() + overrides.Verbose = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + + // create and push a change file in master + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + // create a new branch and stage a new file + repo.Checkout("-b", "test") + repo.StageChange("test.js") + + buf := testutil.CaptureVerboseLogging(t) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result) + // The change file from master should not appear in the exclusion list + assert.NotContains(t, buf.String(), "already has change files") +} + +// TS: "ignores package changes as appropriate" +func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { + rootPkg := map[string]any{ + "name": "test-monorepo", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + } + + packages := map[string]map[string]any{ + "private-pkg": {"name": "private-pkg", "version": "1.0.0", "private": true}, + "no-publish": { + "name": "no-publish", "version": "1.0.0", + "beachball": map[string]any{"shouldPublish": false}, + }, + "out-of-scope": {"name": "out-of-scope", "version": "1.0.0"}, + "ignore-pkg": {"name": "ignore-pkg", "version": "1.0.0"}, + "publish-me": {"name": "publish-me", "version": "1.0.0"}, + } + + groups := map[string]map[string]map[string]any{ + "packages": packages, + } + + factory := testutil.NewCustomRepositoryFactory(t, rootPkg, groups) + repo := factory.CloneRepository() + + repo.StageChange("packages/private-pkg/test.js") + repo.StageChange("packages/no-publish/test.js") + repo.StageChange("packages/out-of-scope/test.js") + repo.StageChange("packages/ignore-pkg/jest.config.js") + repo.StageChange("packages/ignore-pkg/CHANGELOG.md") + repo.StageChange("packages/publish-me/test.js") + + overrides := getDefaultOptions() + overrides.Scope = []string{"!packages/out-of-scope"} + overrides.IgnorePatterns = []string{"**/jest.config.js"} + overrides.Verbose = true + + buf := testutil.CaptureVerboseLogging(t) + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + require.NoError(t, err) + assert.Equal(t, []string{"publish-me"}, result) + output := buf.String() + assert.Contains(t, output, "private-pkg is private") + assert.Contains(t, output, "no-publish has beachball.shouldPublish=false") + assert.Contains(t, output, "out-of-scope is out of scope") + assert.Contains(t, output, "ignored by pattern") +} + +// TS: "detects changed files in multi-root monorepo repo" +func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "multi-project") + repo := factory.CloneRepository() + + repo.StageChange("project-a/packages/foo/test.js") + + // Test from project-a root + pathA := repo.PathTo("project-a") + optsA := getDefaultOptions() + optsA.Path = pathA + + parsedA := options.GetParsedOptionsForTest(pathA, types.CliOptions{}, optsA) + infosA, err := monorepo.GetPackageInfos(&parsedA.Options) + require.NoError(t, err, "failed to get package infos") + scopedA := monorepo.GetScopedPackages(&parsedA.Options, infosA) + resultA, err := changefile.GetChangedPackages(&parsedA.Options, infosA, scopedA) + require.NoError(t, err) + assert.Equal(t, []string{"@project-a/foo"}, resultA) + + // Test from project-b root + pathB := repo.PathTo("project-b") + optsB := getDefaultOptions() + optsB.Path = pathB + + parsedB := options.GetParsedOptionsForTest(pathB, types.CliOptions{}, optsB) + infosB, err := monorepo.GetPackageInfos(&parsedB.Options) + require.NoError(t, err, "failed to get package infos") + scopedB := monorepo.GetScopedPackages(&parsedB.Options, infosB) + resultB, err := changefile.GetChangedPackages(&parsedB.Options, infosB, scopedB) + require.NoError(t, err) + assert.Empty(t, resultB) +} diff --git a/go/internal/changefile/disallowed_change_types.go b/go/internal/changefile/disallowed_change_types.go new file mode 100644 index 000000000..7a293f0ce --- /dev/null +++ b/go/internal/changefile/disallowed_change_types.go @@ -0,0 +1,32 @@ +package changefile + +import ( + "slices" + + "github.com/microsoft/beachball/internal/types" +) + +// GetDisallowedChangeTypes returns the disallowed change types for a package. +// Priority: group > package-level > main options (matching TS behavior). +func GetDisallowedChangeTypes( + pkgName string, + packageInfos types.PackageInfos, + packageGroups types.PackageGroups, + opts *types.BeachballOptions, +) []types.ChangeType { + // Check group-level disallowed types first (group takes priority) + for _, group := range packageGroups { + if group.DisallowedChangeTypes != nil && slices.Contains(group.Packages, pkgName) { + return group.DisallowedChangeTypes + } + } + + // Package is not in a group, so get its own option or the main option + // TODO: slightly different behavior than JS getPackageOption--it's almost impossible to emulate exactly + info := packageInfos[pkgName] + if info != nil && info.PackageOptions != nil && info.PackageOptions.DisallowedChangeTypes != nil { + return info.PackageOptions.DisallowedChangeTypes + } + + return opts.DisallowedChangeTypes +} diff --git a/go/internal/changefile/disallowed_change_types_test.go b/go/internal/changefile/disallowed_change_types_test.go new file mode 100644 index 000000000..cf6fdca20 --- /dev/null +++ b/go/internal/changefile/disallowed_change_types_test.go @@ -0,0 +1,135 @@ +package changefile_test + +import ( + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" +) + +var testRoot = testutil.FakeRoot() + +// TS: "returns null for unknown package" +func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { + infos := types.PackageInfos{} + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("unknown-pkg", infos, groups, opts) + assert.Nil(t, result) +} + +// TS: "falls back to main option for package without disallowedChangeTypes" +func TestGetDisallowedChangeTypes_FallsBackToMainOption(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + opts.DisallowedChangeTypes = []types.ChangeType{types.ChangeTypeMajor} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor}, result) +} + +// TS: "returns disallowedChangeTypes for package" +func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) +} + +// Not possible (Go doesn't distinguish between null and unset): +// returns null if package disallowedChangeTypes is set to null + +// TS: "returns empty array if package disallowedChangeTypes is set to empty array" +func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyPackageDisallowedTypes(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []types.ChangeType{}, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{}, result) +} + +// TS: "returns disallowedChangeTypes for package group" +func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) +} + +// Not possible (Go doesn't distinguish between null and unset): +// returns null if package group disallowedChangeTypes is set to null + +// TS: "returns empty array if package group disallowedChangeTypes is set to empty array" +func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyGroupDisallowedTypes(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []types.ChangeType{}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{}, result) +} + +// TS: "returns disallowedChangeTypes for package if not in a group" +func TestGetDisallowedChangeTypes_ReturnsPackageDisallowedTypesIfNotInGroup(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypePatch}, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"bar"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{types.ChangeTypePatch}, result) +} + +// TS: "prefers disallowedChangeTypes for group over package" +func TestGetDisallowedChangeTypes_PrefersGroupOverPackage(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypePatch}, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) +} diff --git a/go/internal/changefile/read_change_files.go b/go/internal/changefile/read_change_files.go new file mode 100644 index 000000000..d887de5eb --- /dev/null +++ b/go/internal/changefile/read_change_files.go @@ -0,0 +1,92 @@ +package changefile + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// GetChangePath returns the path to the change directory. +func GetChangePath(options *types.BeachballOptions) string { + return filepath.Join(options.Path, options.ChangeDir) +} + +// ReadChangeFiles reads all change files and returns a ChangeSet. +func ReadChangeFiles(options *types.BeachballOptions, packageInfos types.PackageInfos, scopedPackages types.ScopedPackages) types.ChangeSet { + changePath := GetChangePath(options) + if _, err := os.Stat(changePath); os.IsNotExist(err) { + return nil + } + + // Get change files from git diff + changeFiles, err := git.GetChangesBetweenRefs(options.Branch, "A", "*.json", changePath) + if err != nil { + return nil + } + + var changeSet types.ChangeSet + + for _, file := range changeFiles { + if !strings.HasSuffix(file, ".json") { + continue + } + + filePath := filepath.Join(changePath, file) + data, err := os.ReadFile(filePath) + if err != nil { + logging.Warn.Printf("Error reading change file %s: %v", filePath, err) + continue + } + + // Try to parse as multi or single change file + var changes []types.ChangeFileInfo + + var multi types.ChangeInfoMultiple + if err := json.Unmarshal(data, &multi); err == nil && len(multi.Changes) > 0 { + changes = multi.Changes + } else { + var single types.ChangeFileInfo + if err := json.Unmarshal(data, &single); err == nil && single.PackageName != "" { + changes = []types.ChangeFileInfo{single} + } else { + logging.Warn.Printf("%s does not appear to be a change file", filePath) + continue + } + } + + // Filter changes: warn about nonexistent/private packages, include only valid in-scope ones + for _, change := range changes { + info, exists := packageInfos[change.PackageName] + var warningType string + if !exists { + warningType = "nonexistent" + } else if info.Private { + warningType = "private" + } + + if warningType != "" { + resolution := "delete this file" + if options.GroupChanges { + resolution = "remove the entry from this file" + } + logging.Warn.Printf("Change detected for %s package %s; %s: %s", + warningType, change.PackageName, resolution, filePath) + continue + } + + if scopedPackages[change.PackageName] { + changeSet = append(changeSet, types.ChangeSetEntry{ + Change: change, + ChangeFile: file, + }) + } + } + } + + return changeSet +} diff --git a/go/internal/changefile/read_change_files_test.go b/go/internal/changefile/read_change_files_test.go new file mode 100644 index 000000000..1cc988eea --- /dev/null +++ b/go/internal/changefile/read_change_files_test.go @@ -0,0 +1,173 @@ +package changefile_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getReadTestOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *types.BeachballOptions) (types.BeachballOptions, types.PackageInfos, types.ScopedPackages) { + t.Helper() + repoOpts := getDefaultOptions() + if overrides != nil { + repoOpts = *overrides + } + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + packageInfos, err := monorepo.GetPackageInfos(&parsed.Options) + require.NoError(t, err) + scopedPackages := monorepo.GetScopedPackages(&parsed.Options, packageInfos) + return parsed.Options, packageInfos, scopedPackages +} + +func getPackageNames(changeSet types.ChangeSet) []string { + var names []string + for _, entry := range changeSet { + names = append(names, entry.Change.PackageName) + } + slices.Sort(names) + return names +} + +// TS: "reads change files and returns [them]" +func TestReadChangeFiles_ReadsChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, nil) + testutil.GenerateChangeFiles(t, []string{"foo", "bar"}, &opts, repo) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Len(t, changeSet, 2) + assert.Equal(t, []string{"bar", "foo"}, getPackageNames(changeSet)) +} + +// TS: "reads from a custom changeDir" +func TestReadChangeFiles_ReadsFromCustomChangeDir(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + + overrides := getDefaultOptions() + overrides.ChangeDir = "customChanges" + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) +} + +// TS: "reads a grouped change file" +func TestReadChangeFiles_ReadsGroupedChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + + overrides := getDefaultOptions() + overrides.GroupChanges = true + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + + testutil.GenerateChangeFiles(t, []string{"foo", "bar"}, &opts, repo) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"bar", "foo"}, getPackageNames(changeSet)) +} + +// TS: "excludes invalid change files" +func TestReadChangeFiles_ExcludesInvalidChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.UpdatePackageJson("packages/bar/package.json", map[string]any{"private": true}) + + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, nil) + + // Generate change files: "fake" doesn't exist, "bar" is private, "foo" is valid + testutil.GenerateChangeFiles(t, []string{"fake", "bar", "foo"}, &opts, repo) + + // Also write a non-change JSON file + changePath := changefile.GetChangePath(&opts) + os.WriteFile(filepath.Join(changePath, "not-change.json"), []byte("{}"), 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "add invalid file"}) + + buf := testutil.CaptureLogging(t) + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) + + output := buf.String() + assert.Contains(t, output, "does not appear to be a change file") + assert.Contains(t, output, "Change detected for nonexistent package fake; delete this file") + assert.Contains(t, output, "Change detected for private package bar; delete this file") +} + +// TS: "excludes invalid changes from grouped change file in monorepo" +func TestReadChangeFiles_ExcludesInvalidChangesFromGroupedFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.UpdatePackageJson("packages/bar/package.json", map[string]any{"private": true}) + + overrides := getDefaultOptions() + overrides.GroupChanges = true + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + + testutil.GenerateChangeFiles(t, []string{"fake", "bar", "foo"}, &opts, repo) + + buf := testutil.CaptureLogging(t) + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) + + output := buf.String() + assert.Contains(t, output, "Change detected for nonexistent package fake; remove the entry from this file") + assert.Contains(t, output, "Change detected for private package bar; remove the entry from this file") +} + +// TS: "excludes out of scope change files in monorepo" +func TestReadChangeFiles_ExcludesOutOfScopeChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + + overrides := getDefaultOptions() + overrides.Scope = []string{"packages/foo"} + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + + testutil.GenerateChangeFiles(t, []string{"bar", "foo"}, &opts, repo) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) +} + +// TS: "excludes out of scope changes from grouped change file in monorepo" +func TestReadChangeFiles_ExcludesOutOfScopeChangesFromGroupedFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + + overrides := getDefaultOptions() + overrides.Scope = []string{"packages/foo"} + overrides.GroupChanges = true + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + + testutil.GenerateChangeFiles(t, []string{"bar", "foo"}, &opts, repo) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) +} diff --git a/go/internal/changefile/testmain_test.go b/go/internal/changefile/testmain_test.go new file mode 100644 index 000000000..fbff92d8b --- /dev/null +++ b/go/internal/changefile/testmain_test.go @@ -0,0 +1,18 @@ +package changefile_test + +// TestMain wires up testutil.WriteChangeFilesFn with the real changefile.WriteChangeFiles +// implementation, breaking the import cycle between testutil and changefile. +// This runs before any tests in this package. + +import ( + "os" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/testutil" +) + +func TestMain(m *testing.M) { + testutil.WriteChangeFilesFn = changefile.WriteChangeFiles + os.Exit(m.Run()) +} diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go new file mode 100644 index 000000000..977557725 --- /dev/null +++ b/go/internal/changefile/write_change_files.go @@ -0,0 +1,89 @@ +package changefile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/google/uuid" + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +var nonAlphanumRe = regexp.MustCompile(`[^a-zA-Z0-9@]`) + +// WriteChangeFiles writes change files for the given changes. +func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFileInfo) error { + changePath := GetChangePath(options) + if err := os.MkdirAll(changePath, 0o755); err != nil { + return fmt.Errorf("failed to create change directory: %w", err) + } + + var filePaths []string + + if options.GroupChanges { + // Write all changes to a single grouped file + id := uuid.New().String() + filename := fmt.Sprintf("change-%s.json", id) + filePath := filepath.Join(changePath, filename) + + grouped := types.ChangeInfoMultiple{Changes: changes} + data, err := json.MarshalIndent(grouped, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal grouped changes: %w", err) + } + + if err := os.WriteFile(filePath, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("failed to write change file: %w", err) + } + + filePaths = append(filePaths, filePath) + } else { + for _, change := range changes { + id := uuid.New().String() + sanitized := nonAlphanumRe.ReplaceAllString(change.PackageName, "-") + filename := fmt.Sprintf("%s-%s.json", sanitized, id) + filePath := filepath.Join(changePath, filename) + + data, err := json.MarshalIndent(change, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal change: %w", err) + } + + if err := os.WriteFile(filePath, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("failed to write change file: %w", err) + } + + filePaths = append(filePaths, filePath) + } + } + + if len(filePaths) > 0 { + if err := git.Stage(filePaths, options.Path); err != nil { + return fmt.Errorf("failed to stage change files: %w", err) + } + + if options.Commit { + msg := "Change files" + if err := git.Commit(msg, options.Path); err != nil { + return fmt.Errorf("failed to commit change files: %w", err) + } + } + + action := "staged" + if options.Commit { + action = "committed" + } + var fileList strings.Builder + for _, f := range filePaths { + fmt.Fprintf(&fileList, "\n - %s", f) + } + logging.Info.Printf("git %s these change files:%s", action, fileList.String()) + } + + return nil +} diff --git a/go/internal/changefile/write_change_files_test.go b/go/internal/changefile/write_change_files_test.go new file mode 100644 index 000000000..38c388554 --- /dev/null +++ b/go/internal/changefile/write_change_files_test.go @@ -0,0 +1,172 @@ +package changefile_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/jsonutil" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TS: "writes individual change files" +func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "write-test", testutil.DefaultBranch) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = testutil.DefaultRemoteBranch + opts.Fetch = false + + changes := []types.ChangeFileInfo{ + testutil.GetChange("foo", "fix foo", types.ChangeTypePatch), + testutil.GetChange("bar", "add bar feature", types.ChangeTypeMinor), + } + + err := changefile.WriteChangeFiles(&opts, changes) + require.NoError(t, err) + + files := testutil.GetChangeFiles(&opts) + require.Len(t, files, 2) + + // Verify file contents + foundFoo := false + foundBar := false + for _, f := range files { + change, err := jsonutil.ReadJSON[types.ChangeFileInfo](f) + require.NoError(t, err, "failed to read/parse %s", f) + switch change.PackageName { + case "foo": + foundFoo = true + assert.Equal(t, types.ChangeTypePatch, change.Type) + assert.Equal(t, "fix foo", change.Comment) + case "bar": + foundBar = true + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "add bar feature", change.Comment) + default: + t.Fatalf("unexpected package: %s", change.PackageName) + } + } + assert.True(t, foundFoo, "expected foo change file") + assert.True(t, foundBar, "expected bar change file") + + // Verify files are committed (default Commit=true) + status := repo.Status() + assert.Empty(t, status, "expected clean working tree after commit") +} + +// TS: "respects changeDir option" +func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "custom-dir-test", testutil.DefaultBranch) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = testutil.DefaultRemoteBranch + opts.Fetch = false + opts.ChangeDir = "my-changes" + + changes := []types.ChangeFileInfo{ + testutil.GetChange("foo", "test change", types.ChangeTypePatch), + } + + err := changefile.WriteChangeFiles(&opts, changes) + require.NoError(t, err) + + // Verify the custom directory was used + customPath := filepath.Join(repo.RootPath(), "my-changes") + entries, err := os.ReadDir(customPath) + require.NoError(t, err, "failed to read custom change dir") + jsonCount := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + jsonCount++ + } + } + assert.Equal(t, 1, jsonCount) + + // Default change dir should not exist + defaultPath := filepath.Join(repo.RootPath(), "change") + _, err = os.Stat(defaultPath) + assert.True(t, os.IsNotExist(err), "expected default change dir to not exist") +} + +// TS: "respects commit=false" +func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "no-commit-test", testutil.DefaultBranch) + + // Get the current HEAD hash before writing + headBefore := repo.Git([]string{"rev-parse", "HEAD"}) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = testutil.DefaultRemoteBranch + opts.Fetch = false + opts.Commit = false + + changes := []types.ChangeFileInfo{ + testutil.GetChange("foo", "uncommitted change", types.ChangeTypePatch), + } + + err := changefile.WriteChangeFiles(&opts, changes) + require.NoError(t, err) + + // Verify files exist + files := testutil.GetChangeFiles(&opts) + assert.Len(t, files, 1) + + // Verify HEAD hash is unchanged (no commit was made) + headAfter := repo.Git([]string{"rev-parse", "HEAD"}) + assert.Equal(t, headBefore, headAfter, "expected HEAD to be unchanged") +} + +// TS: "writes grouped change files" +func TestWriteChangeFiles_WritesGroupedChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "grouped-write-test", testutil.DefaultBranch) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = testutil.DefaultRemoteBranch + opts.Fetch = false + opts.GroupChanges = true + + changes := []types.ChangeFileInfo{ + testutil.GetChange("foo", "fix foo", types.ChangeTypePatch), + testutil.GetChange("bar", "add bar feature", types.ChangeTypeMinor), + } + + err := changefile.WriteChangeFiles(&opts, changes) + require.NoError(t, err) + + // Should be a single grouped file + files := testutil.GetChangeFiles(&opts) + require.Len(t, files, 1) + + // Verify it's a grouped format + grouped, err := jsonutil.ReadJSON[types.ChangeInfoMultiple](files[0]) + require.NoError(t, err) + + assert.Len(t, grouped.Changes, 2) + packageNames := map[string]bool{} + for _, change := range grouped.Changes { + packageNames[change.PackageName] = true + } + assert.True(t, packageNames["foo"], "expected foo") + assert.True(t, packageNames["bar"], "expected bar") + + // Verify committed + status := repo.Status() + assert.Empty(t, status, "expected clean working tree after commit") +} diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go new file mode 100644 index 000000000..f46694976 --- /dev/null +++ b/go/internal/commands/change.go @@ -0,0 +1,68 @@ +package commands + +import ( + "fmt" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" + "github.com/microsoft/beachball/internal/validation" +) + +// Change runs the change command (non-interactive). +func Change(parsed types.ParsedOptions) error { + result, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + AllowMissingChangeFiles: true, + }) + if err != nil { + return err + } + + if !result.IsChangeNeeded && len(parsed.Options.Package) == 0 { + logging.Info.Println("No change files are needed") + return nil + } + + options := &parsed.Options + + changeType := options.Type + if changeType == "" { + return fmt.Errorf("--type is required for non-interactive change") + } + + message := options.Message + if message == "" { + return fmt.Errorf("--message is required for non-interactive change") + } + + email := git.GetUserEmail(options.Path) + + depChangeType := changeType + if options.DependentChangeType != "" { + depChangeType = options.DependentChangeType + } + + changedPackages := result.ChangedPackages + if len(changedPackages) == 0 && len(options.Package) > 0 { + changedPackages = options.Package + } + + if len(changedPackages) == 0 { + return nil + } + + var changes []types.ChangeFileInfo + for _, pkg := range changedPackages { + changes = append(changes, types.ChangeFileInfo{ + Type: changeType, + Comment: message, + PackageName: pkg, + Email: email, + DependentChangeType: depChangeType, + }) + } + + return changefile.WriteChangeFiles(options, changes) +} diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go new file mode 100644 index 000000000..05e5a2b3c --- /dev/null +++ b/go/internal/commands/change_test.go @@ -0,0 +1,300 @@ +package commands_test + +import ( + "strings" + "testing" + + "github.com/microsoft/beachball/internal/commands" + "github.com/microsoft/beachball/internal/jsonutil" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// get default options for this file (fetch disabled) +func getDefaultOptions() types.BeachballOptions { + defaultOptions := types.DefaultOptions() + defaultOptions.Branch = testutil.DefaultRemoteBranch + defaultOptions.Fetch = false + + return defaultOptions +} + +// TS: "does not create change files when there are no changes" +func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "no-changes-test", testutil.DefaultBranch) + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + cli := types.CliOptions{ + Command: "change", + Message: "test change", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + files := testutil.GetChangeFiles(&parsed.Options) + assert.Empty(t, files) + assert.Contains(t, buf.String(), "No change files are needed") +} + +// TS: "creates and commits a change file" (non-interactive equivalent) +func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "creates-change-test", testutil.DefaultBranch) + repo.CommitChange("file.js") + + repoOpts := getDefaultOptions() + repoOpts.Commit = false + + commitFalse := false + cli := types.CliOptions{ + Command: "change", + Message: "test description", + ChangeType: "patch", + Commit: &commitFalse, + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + change, err := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) + require.NoError(t, err) + + assert.Equal(t, types.ChangeTypePatch, change.Type) + assert.Equal(t, "test description", change.Comment) + assert.Equal(t, "foo", change.PackageName) + assert.Equal(t, types.ChangeTypePatch, change.DependentChangeType) +} + +// TS: "creates and stages a change file" +func TestCreatesAndStagesChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "stages-change-test", testutil.DefaultBranch) + repo.CommitChange("file.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + repoOpts.Commit = false + + commitFalse := false + cli := types.CliOptions{ + Command: "change", + Message: "stage me please", + ChangeType: "patch", + Commit: &commitFalse, + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + // Verify file is staged (git status shows "A ") + status := repo.Status() + assert.Contains(t, status, "A ") + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) + assert.Equal(t, "stage me please", change.Comment) + assert.Equal(t, "foo", change.PackageName) + assert.Contains(t, buf.String(), "git staged these change files:") +} + +// TS: "creates and commits a change file" +func TestCreatesAndCommitsChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "commits-change-test", testutil.DefaultBranch) + repo.CommitChange("file.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + cli := types.CliOptions{ + Command: "change", + Message: "commit me please", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + // Verify clean git status (committed) + status := repo.Status() + assert.Empty(t, status) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) + assert.Equal(t, "commit me please", change.Comment) + assert.Contains(t, buf.String(), "git committed these change files:") +} + +// TS: "creates and commits a change file with changeDir set" +func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "changedir-test", testutil.DefaultBranch) + repo.CommitChange("file.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + repoOpts.ChangeDir = "changeDir" + + cli := types.CliOptions{ + Command: "change", + Message: "commit me please", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + status := repo.Status() + assert.Empty(t, status) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + // Verify file is in custom directory + assert.True(t, strings.Contains(files[0], "changeDir"), "expected file in changeDir, got: %s", files[0]) + + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) + assert.Equal(t, "commit me please", change.Comment) + assert.Contains(t, buf.String(), "git committed these change files:") +} + +// TS: "creates a change file when there are no changes but package name is provided" +func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "package-flag-test", testutil.DefaultBranch) + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + repoOpts.Commit = false + + commitFalse := false + cli := types.CliOptions{ + Command: "change", + Message: "forced change", + ChangeType: "patch", + Package: []string{"foo"}, + Commit: &commitFalse, + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) + assert.Equal(t, "foo", change.PackageName) + assert.Contains(t, buf.String(), "git staged these change files:") +} + +// TS: "creates and commits change files for multiple packages" +func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "multi-pkg-test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.CommitChange("packages/bar/file.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + cli := types.CliOptions{ + Command: "change", + Message: "multi-package change", + ChangeType: "minor", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + status := repo.Status() + assert.Empty(t, status) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 2) + + packageNames := map[string]bool{} + for _, f := range files { + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](f) + packageNames[change.PackageName] = true + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "multi-package change", change.Comment) + } + + assert.True(t, packageNames["foo"], "expected foo") + assert.True(t, packageNames["bar"], "expected bar") + assert.Contains(t, buf.String(), "git committed these change files:") +} + +// TS: "creates and commits grouped change file for multiple packages" +func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "grouped-test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.CommitChange("packages/bar/file.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + repoOpts.GroupChanges = true + + cli := types.CliOptions{ + Command: "change", + Message: "grouped change", + ChangeType: "minor", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + require.NoError(t, err) + + status := repo.Status() + assert.Empty(t, status) + + files := testutil.GetChangeFiles(&parsed.Options) + require.Len(t, files, 1) + + grouped, err := jsonutil.ReadJSON[types.ChangeInfoMultiple](files[0]) + require.NoError(t, err) + + assert.Len(t, grouped.Changes, 2) + + packageNames := map[string]bool{} + for _, change := range grouped.Changes { + packageNames[change.PackageName] = true + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "grouped change", change.Comment) + } + + assert.True(t, packageNames["foo"], "expected foo") + assert.True(t, packageNames["bar"], "expected bar") + assert.Contains(t, buf.String(), "git committed these change files:") +} diff --git a/go/internal/commands/check.go b/go/internal/commands/check.go new file mode 100644 index 000000000..6d5be0634 --- /dev/null +++ b/go/internal/commands/check.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" + "github.com/microsoft/beachball/internal/validation" +) + +// Check runs the check command. +func Check(parsed types.ParsedOptions) error { + _, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + }) + if err != nil { + return err + } + + logging.Info.Println("No change files are needed") + return nil +} diff --git a/go/internal/git/commands.go b/go/internal/git/commands.go new file mode 100644 index 000000000..89cee2943 --- /dev/null +++ b/go/internal/git/commands.go @@ -0,0 +1,444 @@ +package git + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/microsoft/beachball/internal/logging" +) + +// GitResult holds the result of a git command. +type GitResult struct { + Success bool + Stdout string + Stderr string + ExitCode int +} + +// Git runs a git command in the given directory. +func Git(args []string, cwd string) (GitResult, error) { + cmd := exec.Command("git", args...) + cmd.Dir = cwd + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return GitResult{}, fmt.Errorf("failed to run git %s: %w", strings.Join(args, " "), err) + } + } + + result := GitResult{ + Success: exitCode == 0, + Stdout: strings.TrimSpace(stdout.String()), + Stderr: strings.TrimSpace(stderr.String()), + ExitCode: exitCode, + } + return result, nil +} + +// gitStdout runs a git command and returns stdout, or error if it fails. +func gitStdout(args []string, cwd string) (string, error) { + result, err := Git(args, cwd) + if err != nil { + return "", err + } + if !result.Success { + return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), result.Stderr) + } + return result.Stdout, nil +} + +// FindGitRoot returns the root directory of the git repository. +func FindGitRoot(cwd string) (string, error) { + return gitStdout([]string{"rev-parse", "--show-toplevel"}, cwd) +} + +// managerFiles are workspace/monorepo manager config files, in precedence order. +// Matches the workspace-tools detection order. +var ManagerFiles = []string{ + "lerna.json", + "rush.json", + "yarn.lock", + "pnpm-workspace.yaml", + "package-lock.json", +} + +// searchUp walks up the directory tree from cwd looking for any of the given files. +// Returns the full path of the first match, or "" if none found. +func searchUp(files []string, cwd string) string { + absPath, err := filepath.Abs(cwd) + if err != nil { + return "" + } + root := filepath.VolumeName(absPath) + string(filepath.Separator) + + dir := absPath + for dir != root { + for _, f := range files { + candidate := filepath.Join(dir, f) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// FindProjectRoot searches up from cwd for a workspace manager root, +// falling back to the git root. Matches workspace-tools findProjectRoot. +func FindProjectRoot(cwd string) (string, error) { + if found := searchUp(ManagerFiles, cwd); found != "" { + return filepath.Dir(found), nil + } + return FindGitRoot(cwd) +} + +// GetBranchName returns the current branch name. +func GetBranchName(cwd string) (string, error) { + return gitStdout([]string{"rev-parse", "--abbrev-ref", "HEAD"}, cwd) +} + +// GetUserEmail returns the user's git email. +func GetUserEmail(cwd string) string { + email, err := gitStdout([]string{"config", "user.email"}, cwd) + if err != nil { + return "" + } + return email +} + +// GetBranchChanges returns files changed between the current branch and the target branch. +func GetBranchChanges(branch, cwd string) ([]string, error) { + result, err := Git([]string{ + "--no-pager", "diff", "--name-only", "--relative", + fmt.Sprintf("%s...", branch), + }, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return processGitOutput(result.Stdout), nil +} + +// GetStagedChanges returns staged file changes. +func GetStagedChanges(cwd string) ([]string, error) { + result, err := Git([]string{ + "--no-pager", "diff", "--staged", "--name-only", "--relative", + }, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return processGitOutput(result.Stdout), nil +} + +// GetChangesBetweenRefs returns changes between refs with optional filter and pattern. +func GetChangesBetweenRefs(fromRef string, diffFilter, pattern, cwd string) ([]string, error) { + args := []string{"--no-pager", "diff", "--name-only", "--relative"} + if diffFilter != "" { + args = append(args, fmt.Sprintf("--diff-filter=%s", diffFilter)) + } + args = append(args, fmt.Sprintf("%s...", fromRef)) + if pattern != "" { + args = append(args, "--", pattern) + } + + result, err := Git(args, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return processGitOutput(result.Stdout), nil +} + +// GetUntrackedChanges returns untracked files (not in index, respecting .gitignore). +func GetUntrackedChanges(cwd string) ([]string, error) { + result, err := Git([]string{"ls-files", "--others", "--exclude-standard"}, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return processGitOutput(result.Stdout), nil +} + +// Stage adds files to the staging area. +func Stage(files []string, cwd string) error { + args := append([]string{"add"}, files...) + _, err := gitStdout(args, cwd) + return err +} + +// Commit creates a commit with the given message. +func Commit(message, cwd string) error { + _, err := gitStdout([]string{"commit", "-m", message}, cwd) + return err +} + +// IsShallowClone checks if the repo is a shallow clone. +func IsShallowClone(cwd string) bool { + result, err := gitStdout([]string{"rev-parse", "--is-shallow-repository"}, cwd) + if err != nil { + return false + } + return result == "true" +} + +// Fetch fetches from the remote branch. +func Fetch(remoteBranch, cwd string) error { + parts := strings.SplitN(remoteBranch, "/", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid remote branch format: %s", remoteBranch) + } + _, err := gitStdout([]string{"fetch", "--", parts[0], parts[1]}, cwd) + return err +} + +// Deepen deepens a shallow clone. +func Deepen(depth int, cwd string) error { + _, err := gitStdout([]string{"fetch", "--deepen", fmt.Sprintf("%d", depth)}, cwd) + return err +} + +// HasRef checks if a ref exists. +func HasRef(ref, cwd string) bool { + result, err := Git([]string{"rev-parse", "--verify", ref}, cwd) + if err != nil { + return false + } + return result.Success +} + +// GetDefaultRemote returns the default remote name, matching workspace-tools getDefaultRemote. +// +// The order of preference is: +// 1. If `repository` is defined in package.json at the git root, the remote with a matching URL +// 2. "upstream" if defined +// 3. "origin" if defined +// 4. The first defined remote +// 5. "origin" as final fallback +// +// Note: ADO/VSO URL formats are not currently handled by getRepositoryName. +// This is probably fine since usage of forks with ADO is uncommon. +func GetDefaultRemote(cwd string) string { + gitRoot, err := FindGitRoot(cwd) + if err != nil { + return "origin" + } + + // Read package.json at git root for repository field + repositoryName := "" + packageJsonPath := filepath.Join(gitRoot, "package.json") + pkgData, err := os.ReadFile(packageJsonPath) + if err != nil { + logging.Info.Printf(`Valid "repository" key not found in "%s". Consider adding this info for more accurate git remote detection.`, packageJsonPath) + } else { + var pkg struct { + Repository json.RawMessage `json:"repository"` + } + if err := json.Unmarshal(pkgData, &pkg); err == nil && pkg.Repository != nil { + repositoryURL := extractRepositoryURL(pkg.Repository) + if repositoryURL == "" { + logging.Info.Printf(`Valid "repository" key not found in "%s". Consider adding this info for more accurate git remote detection.`, packageJsonPath) + } else { + repositoryName = getRepositoryName(repositoryURL) + } + } else { + logging.Info.Printf(`Valid "repository" key not found in "%s". Consider adding this info for more accurate git remote detection.`, packageJsonPath) + } + } + + // Get remotes with URLs + remotesResult, err := Git([]string{"remote", "-v"}, cwd) + if err != nil || !remotesResult.Success { + return "origin" + } + + // Build mapping from repository name → remote name + remotesByRepoName := map[string]string{} + var allRemoteNames []string + seen := map[string]bool{} + for _, line := range splitLines(remotesResult.Stdout) { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + remoteName, remoteURL := fields[0], fields[1] + repoName := getRepositoryName(remoteURL) + if repoName != "" { + remotesByRepoName[repoName] = remoteName + } + if !seen[remoteName] { + allRemoteNames = append(allRemoteNames, remoteName) + seen[remoteName] = true + } + } + + // 1. Match by repository name from package.json + if repositoryName != "" { + if matched, ok := remotesByRepoName[repositoryName]; ok { + return matched + } + } + + // 2-4. Fall back to upstream > origin > first + if slices.Contains(allRemoteNames, "upstream") { + return "upstream" + } + if slices.Contains(allRemoteNames, "origin") { + return "origin" + } + if len(allRemoteNames) > 0 { + return allRemoteNames[0] + } + + return "origin" +} + +// extractRepositoryURL gets the URL from a package.json "repository" field, +// which can be a string or an object with a "url" property. +func extractRepositoryURL(raw json.RawMessage) string { + // Try as string first + var s string + if json.Unmarshal(raw, &s) == nil && s != "" { + return s + } + // Try as object with url field + var obj struct { + URL string `json:"url"` + } + if json.Unmarshal(raw, &obj) == nil { + return obj.URL + } + return "" +} + +// sshPattern matches SSH git URLs like git@github.com:owner/repo.git +var sshPattern = regexp.MustCompile(`^[^@]+@([^:]+):(.+?)(?:\.git)?$`) + +// shorthandPattern matches shorthand URLs like github:owner/repo +var shorthandPattern = regexp.MustCompile(`^[a-z]+:([^/].+)$`) + +// getRepositoryName extracts the "owner/repo" full name from a git URL. +// Handles HTTPS, SSH, git://, and shorthand (github:owner/repo) formats for +// common hosts (GitHub, GitLab, Bitbucket, etc.). +// +// Note: Azure DevOps and Visual Studio Online URL formats are not currently handled. +// Those would require more complex parsing similar to workspace-tools' git-url-parse usage. +func getRepositoryName(rawURL string) string { + if rawURL == "" { + return "" + } + + // SSH format: git@github.com:owner/repo.git + if m := sshPattern.FindStringSubmatch(rawURL); m != nil { + return strings.TrimSuffix(m[2], ".git") + } + + // Shorthand format: github:owner/repo + if m := shorthandPattern.FindStringSubmatch(rawURL); m != nil { + // e.g. "github:microsoft/workspace-tools" → "microsoft/workspace-tools" + return strings.TrimSuffix(m[1], ".git") + } + + // HTTPS or git:// format + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + path := strings.TrimPrefix(parsed.Path, "/") + path = strings.TrimSuffix(path, ".git") + if path == "" { + return "" + } + return path +} + +// GetDefaultRemoteBranch returns the default remote branch reference (e.g. "origin/main"). +// If branch is non-empty, it's combined with the default remote. +// If branch is empty, detects the remote's HEAD branch, falling back to +// git config init.defaultBranch or "master". +func GetDefaultRemoteBranch(cwd string, branch string) string { + remote := GetDefaultRemote(cwd) + + if branch != "" { + return remote + "/" + branch + } + + // Try to detect HEAD branch from remote + result, err := Git([]string{"remote", "show", remote}, cwd) + if err == nil && result.Success { + for line := range strings.SplitSeq(result.Stdout, "\n") { + trimmed := strings.TrimSpace(line) + if after, ok := strings.CutPrefix(trimmed, "HEAD branch:"); ok { + headBranch := strings.TrimSpace(after) + return remote + "/" + headBranch + } + } + } + + // Fallback: git config init.defaultBranch, or "master" + if defaultBranch, err := gitStdout([]string{"config", "init.defaultBranch"}, cwd); err == nil && defaultBranch != "" { + return remote + "/" + defaultBranch + } + + return remote + "/master" +} + +// processGitOutput splits git output into lines, trims whitespace, and filters out +// empty lines and node_modules paths. Matches workspace-tools processGitOutput with +// excludeNodeModules: true. +func processGitOutput(s string) []string { + if s == "" { + return nil + } + var lines []string + for line := range strings.SplitSeq(s, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.Contains(trimmed, "node_modules") { + lines = append(lines, trimmed) + } + } + return lines +} + +// splitLines splits output into non-empty lines (without node_modules filtering). +// Used for non-file-path output like remote names. +func splitLines(s string) []string { + if s == "" { + return nil + } + var lines []string + for line := range strings.SplitSeq(s, "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/go/internal/git/commands_test.go b/go/internal/git/commands_test.go new file mode 100644 index 000000000..bcba7ec2d --- /dev/null +++ b/go/internal/git/commands_test.go @@ -0,0 +1,476 @@ +package git + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +// setupGitDir creates a temp directory with git init and user config. +func setupGitDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + mustGit(t, dir, "init") + mustGit(t, dir, "config", "user.email", "test@test.com") + mustGit(t, dir, "config", "user.name", "Test") + return dir +} + +// mustGit runs a git command and fails the test if it doesn't succeed. +func mustGit(t *testing.T, cwd string, args ...string) string { + t.Helper() + result, err := Git(args, cwd) + if err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + if !result.Success { + t.Fatalf("git %v failed (exit %d): %s", args, result.ExitCode, result.Stderr) + } + return result.Stdout +} + +// writeFile creates a file in the given directory. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// writePackageJSON writes a package.json with optional fields. +func writePackageJSON(t *testing.T, dir string, fields map[string]any) { + t.Helper() + if fields == nil { + fields = map[string]any{"name": "test-pkg", "version": "1.0.0"} + } else { + if _, ok := fields["name"]; !ok { + fields["name"] = "test-pkg" + } + if _, ok := fields["version"]; !ok { + fields["version"] = "1.0.0" + } + } + data, err := json.MarshalIndent(fields, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + +// --- getUntrackedChanges.test.ts --- + +// TS: "returns untracked files using object params" +func TestGetUntrackedChanges_ReturnsUntrackedFiles(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "untracked1.txt", "content1") + writeFile(t, cwd, "untracked2.js", "content2") + + result, err := GetUntrackedChanges(cwd) + if err != nil { + t.Fatal(err) + } + sort.Strings(result) + expected := []string{"untracked1.txt", "untracked2.js"} + if !slicesEqual(result, expected) { + t.Errorf("got %v, want %v", result, expected) + } +} + +// TS: "does not include tracked files" +func TestGetUntrackedChanges_DoesNotIncludeTrackedFiles(t *testing.T) { + cwd := setupGitDir(t) + + // Commit a file + writeFile(t, cwd, "committed.txt", "committed content") + mustGit(t, cwd, "add", "committed.txt") + mustGit(t, cwd, "commit", "-m", "add committed file") + + // Stage a file + writeFile(t, cwd, "staged.txt", "staged content") + mustGit(t, cwd, "add", "staged.txt") + + // Create an untracked file + writeFile(t, cwd, "untracked.txt", "untracked content") + + result, err := GetUntrackedChanges(cwd) + if err != nil { + t.Fatal(err) + } + if !slicesEqual(result, []string{"untracked.txt"}) { + t.Errorf("got %v, want [untracked.txt]", result) + } +} + +// TS: "returns empty array when no untracked files" +func TestGetUntrackedChanges_ReturnsEmptyWhenNone(t *testing.T) { + cwd := setupGitDir(t) + + result, err := GetUntrackedChanges(cwd) + if err != nil { + t.Fatal(err) + } + if len(result) != 0 { + t.Errorf("got %v, want empty", result) + } +} + +// TS: "respects gitignore patterns" +func TestGetUntrackedChanges_RespectsGitignore(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, ".gitignore", "*.log\n") + writeFile(t, cwd, "file.txt", "content") + writeFile(t, cwd, "error.log", "log content") + + result, err := GetUntrackedChanges(cwd) + if err != nil { + t.Fatal(err) + } + sort.Strings(result) + expected := []string{".gitignore", "file.txt"} + if !slicesEqual(result, expected) { + t.Errorf("got %v, want %v", result, expected) + } +} + +// --- getStagedChanges.test.ts --- + +// TS: "returns staged file changes" +func TestGetStagedChanges_ReturnsStagedChanges(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "feature.ts", "original") + mustGit(t, cwd, "add", "feature.ts") + mustGit(t, cwd, "commit", "-m", "initial") + + // Modify and stage, and add another file + writeFile(t, cwd, "feature.ts", "modified") + writeFile(t, cwd, "stuff/new-file.ts", "new content") + mustGit(t, cwd, "add", "feature.ts", "stuff/new-file.ts") + + result, err := GetStagedChanges(cwd) + if err != nil { + t.Fatal(err) + } + sort.Strings(result) + expected := []string{"feature.ts", "stuff/new-file.ts"} + if !slicesEqual(result, expected) { + t.Errorf("got %v, want %v", result, expected) + } +} + +// TS: "does not include unstaged changes" +func TestGetStagedChanges_DoesNotIncludeUnstaged(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "staged.js", "original") + writeFile(t, cwd, "unstaged.js", "original") + mustGit(t, cwd, "add", "-A") + mustGit(t, cwd, "commit", "-m", "initial") + + writeFile(t, cwd, "staged.js", "modified") + writeFile(t, cwd, "unstaged.js", "modified") + writeFile(t, cwd, "another-file.js", "new content") + + mustGit(t, cwd, "add", "staged.js") + + result, err := GetStagedChanges(cwd) + if err != nil { + t.Fatal(err) + } + if !slicesEqual(result, []string{"staged.js"}) { + t.Errorf("got %v, want [staged.js]", result) + } +} + +// TS: "returns empty array when nothing is staged" +func TestGetStagedChanges_ReturnsEmptyWhenNothingStaged(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "file.ts", "content") + mustGit(t, cwd, "add", "file.ts") + mustGit(t, cwd, "commit", "-m", "initial") + + writeFile(t, cwd, "file.ts", "modified") + writeFile(t, cwd, "another-file.ts", "new content") + + result, err := GetStagedChanges(cwd) + if err != nil { + t.Fatal(err) + } + if len(result) != 0 { + t.Errorf("got %v, want empty", result) + } +} + +// --- getChangesBetweenRefs.test.ts --- + +// TS: "returns changes between ref and HEAD" +func TestGetChangesBetweenRefs_ReturnChanges(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "file1.ts", "initial") + mustGit(t, cwd, "add", "file1.ts") + mustGit(t, cwd, "commit", "-m", "commit1") + firstCommit := mustGit(t, cwd, "rev-parse", "HEAD") + + writeFile(t, cwd, "file2.ts", "new file") + writeFile(t, cwd, "file3.ts", "new file") + mustGit(t, cwd, "add", "-A") + mustGit(t, cwd, "commit", "-m", "commit2") + + result, err := GetChangesBetweenRefs(firstCommit, "", "", cwd) + if err != nil { + t.Fatal(err) + } + sort.Strings(result) + expected := []string{"file2.ts", "file3.ts"} + if !slicesEqual(result, expected) { + t.Errorf("got %v, want %v", result, expected) + } +} + +// TS: "supports additional diff options" (adapted for diffFilter param) +func TestGetChangesBetweenRefs_SupportsDiffFilter(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "file.ts", "initial") + mustGit(t, cwd, "add", "file.ts") + mustGit(t, cwd, "commit", "-m", "commit1") + + // Modify and add a new file + writeFile(t, cwd, "file.ts", "modified") + writeFile(t, cwd, "newfile.ts", "new file") + mustGit(t, cwd, "add", "-A") + mustGit(t, cwd, "commit", "-m", "commit2") + + // Only modified files + result, err := GetChangesBetweenRefs("HEAD~1", "M", "", cwd) + if err != nil { + t.Fatal(err) + } + if !slicesEqual(result, []string{"file.ts"}) { + t.Errorf("got %v, want [file.ts]", result) + } +} + +// TS: "supports pattern filtering" +func TestGetChangesBetweenRefs_SupportsPatternFiltering(t *testing.T) { + cwd := setupGitDir(t) + + writeFile(t, cwd, "file.ts", "initial") + mustGit(t, cwd, "add", "file.ts") + mustGit(t, cwd, "commit", "-m", "commit1") + + writeFile(t, cwd, "code.ts", "code") + writeFile(t, cwd, "readme.md", "docs") + mustGit(t, cwd, "add", "-A") + mustGit(t, cwd, "commit", "-m", "commit2") + + result, err := GetChangesBetweenRefs("HEAD~1", "", "*.ts", cwd) + if err != nil { + t.Fatal(err) + } + if !slicesEqual(result, []string{"code.ts"}) { + t.Errorf("got %v, want [code.ts]", result) + } +} + +// --- getDefaultRemote.test.ts --- + +// TS: "handles no repository field or remotes" +func TestGetDefaultRemote_NoRemotes(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, nil) + + result := GetDefaultRemote(cwd) + if result != "origin" { + t.Errorf("got %q, want %q", result, "origin") + } +} + +// TS: "defaults to upstream remote without repository field" +func TestGetDefaultRemote_PrefersUpstream(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, nil) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "origin", "https://github.com/ecraig12345/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "upstream", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "upstream" { + t.Errorf("got %q, want %q", result, "upstream") + } +} + +// TS: "defaults to origin remote without repository field or upstream remote" +func TestGetDefaultRemote_PrefersOriginOverOther(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, nil) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "origin", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "origin" { + t.Errorf("got %q, want %q", result, "origin") + } +} + +// TS: "defaults to first remote without repository field, origin, or upstream" +func TestGetDefaultRemote_FallsBackToFirst(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, nil) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "second", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "first" { + t.Errorf("got %q, want %q", result, "first") + } +} + +// TS: "finds remote matching repository string" +func TestGetDefaultRemote_MatchesRepositoryString(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, map[string]any{ + "repository": "https://github.com/microsoft/workspace-tools.git", + }) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "second", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "second" { + t.Errorf("got %q, want %q", result, "second") + } +} + +// TS: "finds remote matching repository object" +func TestGetDefaultRemote_MatchesRepositoryObject(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, map[string]any{ + "repository": map[string]string{ + "url": "https://github.com/microsoft/workspace-tools.git", + "type": "git", + }, + }) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "second", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "second" { + t.Errorf("got %q, want %q", result, "second") + } +} + +// TS: "works with SSH remote format" +func TestGetDefaultRemote_SSHRemoteFormat(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, map[string]any{ + "repository": map[string]string{ + "url": "https://github.com/microsoft/workspace-tools", + "type": "git", + }, + }) + + mustGit(t, cwd, "remote", "add", "first", "git@github.com:kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "second", "git@github.com:microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "second" { + t.Errorf("got %q, want %q", result, "second") + } +} + +// TS: "works with shorthand repository format" +func TestGetDefaultRemote_ShorthandRepositoryFormat(t *testing.T) { + cwd := setupGitDir(t) + writePackageJSON(t, cwd, map[string]any{ + "repository": map[string]string{ + "url": "github:microsoft/workspace-tools", + "type": "git", + }, + }) + + mustGit(t, cwd, "remote", "add", "first", "https://github.com/kenotron/workspace-tools.git") + mustGit(t, cwd, "remote", "add", "second", "https://github.com/microsoft/workspace-tools.git") + + result := GetDefaultRemote(cwd) + if result != "second" { + t.Errorf("got %q, want %q", result, "second") + } +} + +// ADO/VSO tests from TS are omitted: ADO/VSO URL parsing not implemented. +// See: "works with VSO repository and mismatched remote format" +// See: "works with ADO repository and mismatched remote format" + +// --- getRepositoryName.test.ts --- + +// TS: "works with HTTPS URLs" +func TestGetRepositoryName_HTTPS(t *testing.T) { + result := getRepositoryName("https://github.com/microsoft/workspace-tools") + if result != "microsoft/workspace-tools" { + t.Errorf("got %q, want %q", result, "microsoft/workspace-tools") + } +} + +// TS: "works with HTTPS URLs with .git" +func TestGetRepositoryName_HTTPSWithGit(t *testing.T) { + result := getRepositoryName("https://github.com/microsoft/workspace-tools.git") + if result != "microsoft/workspace-tools" { + t.Errorf("got %q, want %q", result, "microsoft/workspace-tools") + } +} + +// TS: "works with SSH URLs" +func TestGetRepositoryName_SSH(t *testing.T) { + result := getRepositoryName("git@github.com:microsoft/workspace-tools.git") + if result != "microsoft/workspace-tools" { + t.Errorf("got %q, want %q", result, "microsoft/workspace-tools") + } +} + +// TS: "works with git:// URLs" +func TestGetRepositoryName_GitProtocol(t *testing.T) { + result := getRepositoryName("git://github.com/microsoft/workspace-tools") + if result != "microsoft/workspace-tools" { + t.Errorf("got %q, want %q", result, "microsoft/workspace-tools") + } +} + +func TestGetRepositoryName_Empty(t *testing.T) { + if result := getRepositoryName(""); result != "" { + t.Errorf("got %q, want empty", result) + } +} + +// ADO/VSO tests from TS are omitted: ADO/VSO URL parsing not implemented. +// See getRepositoryName.test.ts "ADO" and "VSO" describe blocks. + +// --- helpers --- + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go/internal/git/ensure_shared_history.go b/go/internal/git/ensure_shared_history.go new file mode 100644 index 000000000..61b140961 --- /dev/null +++ b/go/internal/git/ensure_shared_history.go @@ -0,0 +1,42 @@ +package git + +import ( + "fmt" + "strings" + + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// EnsureSharedHistory ensures the branch ref is available, fetching if needed. +func EnsureSharedHistory(options *types.BeachballOptions) error { + cwd := options.Path + + if !HasRef(options.Branch, cwd) { + if !options.Fetch { + return fmt.Errorf( + "branch %q does not exist locally. Specify 'fetch: true' in config to auto-fetch", + options.Branch, + ) + } + + parts := strings.SplitN(options.Branch, "/", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid branch format: %s", options.Branch) + } + + logging.Info.Printf("Fetching %s from %s...", parts[1], parts[0]) + if err := Fetch(options.Branch, cwd); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + } + + if IsShallowClone(cwd) { + logging.Info.Println("Shallow clone detected, deepening...") + if err := Deepen(100, cwd); err != nil { + return fmt.Errorf("failed to deepen: %w", err) + } + } + + return nil +} diff --git a/go/internal/jsonutil/read.go b/go/internal/jsonutil/read.go new file mode 100644 index 000000000..550b29e2b --- /dev/null +++ b/go/internal/jsonutil/read.go @@ -0,0 +1,20 @@ +package jsonutil + +import ( + "encoding/json" + "fmt" + "os" +) + +// ReadJSON reads a JSON file and unmarshals it into the given type. +func ReadJSON[T any](filePath string) (T, error) { + var result T + data, err := os.ReadFile(filePath) + if err != nil { + return result, fmt.Errorf("reading %s: %w", filePath, err) + } + if err := json.Unmarshal(data, &result); err != nil { + return result, fmt.Errorf("parsing %s: %w", filePath, err) + } + return result, nil +} diff --git a/go/internal/logging/logging.go b/go/internal/logging/logging.go new file mode 100644 index 000000000..0845488f0 --- /dev/null +++ b/go/internal/logging/logging.go @@ -0,0 +1,63 @@ +package logging + +import ( + "fmt" + "io" + "log" + "os" + "strings" +) + +var ( + Verbose = log.New(io.Discard, "", 0) + Info = log.New(os.Stdout, "", 0) + Warn = log.New(os.Stderr, "WARN: ", 0) + Error = log.New(os.Stderr, "ERROR: ", 0) +) + +// SetOutput redirects all loggers to the given writer (for testing). +// +// Note: This mutates package-level globals, so it is NOT safe for use with t.Parallel(). +// Tests within a package run sequentially by default, which is fine for now. If parallel +// test execution is needed in the future, switch to injecting a per-test writer (e.g. via +// context or a Loggers struct) instead of mutating shared state. +func SetOutput(w io.Writer, verboseEnabled bool) { + Info.SetOutput(w) + Warn.SetOutput(w) + Error.SetOutput(w) + + if verboseEnabled { + Verbose.SetOutput(w) + } else { + Verbose.SetOutput(io.Discard) + } +} + +func EnableVerbose() { + Verbose.SetOutput(os.Stdout) +} + +// Reset restores loggers to their default outputs, and verbose to no output. +func Reset() { + Info.SetOutput(os.Stdout) + Warn.SetOutput(os.Stderr) + Error.SetOutput(os.Stderr) + Verbose.SetOutput(io.Discard) +} + +// Count returns "N thing" or "N things" with proper pluralization. +func Count(n int, thing string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, thing) + } + return fmt.Sprintf("%d %ss", n, thing) +} + +// BulletedList formats a list of strings as a bulleted list. +func BulletedList(items []string) string { + var sb strings.Builder + for _, item := range items { + fmt.Fprintf(&sb, " - %s\n", item) + } + return strings.TrimRight(sb.String(), "\n") +} diff --git a/go/internal/monorepo/filter_ignored.go b/go/internal/monorepo/filter_ignored.go new file mode 100644 index 000000000..8c3dc5098 --- /dev/null +++ b/go/internal/monorepo/filter_ignored.go @@ -0,0 +1,69 @@ +package monorepo + +import ( + "path/filepath" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/logging" +) + +// FilterIgnoredFiles filters out files that match ignore patterns. +// Uses matchBase behavior: patterns without path separators match against the basename. +func FilterIgnoredFiles(files []string, patterns []string) []string { + var result []string + + for _, file := range files { + ignored := false + var matchedPattern string + + for _, pattern := range patterns { + matched := matchFile(file, pattern) + if matched { + ignored = true + matchedPattern = pattern + break + } + } + + if ignored { + logging.Verbose.Printf(" - ~~%s~~ (ignored by pattern %q)", file, matchedPattern) + } else { + result = append(result, file) + } + } + + return result +} + +// matchFile checks if a file matches a pattern, using matchBase for patterns without slashes. +func matchFile(file, pattern string) bool { + // If pattern has no path separator, match against basename (matchBase behavior) + hasSlash := false + for _, c := range pattern { + if c == '/' || c == '\\' { + hasSlash = true + break + } + } + + if !hasSlash { + base := filepath.Base(file) + if matched, _ := doublestar.PathMatch(pattern, base); matched { + return true + } + } + + // Try full path match + if matched, _ := doublestar.PathMatch(pattern, file); matched { + return true + } + + // Try with **/ prefix for patterns with path separators + if hasSlash { + if matched, _ := doublestar.PathMatch("**/"+pattern, file); matched { + return true + } + } + + return false +} diff --git a/go/internal/monorepo/package_groups.go b/go/internal/monorepo/package_groups.go new file mode 100644 index 000000000..0b7aafbcc --- /dev/null +++ b/go/internal/monorepo/package_groups.go @@ -0,0 +1,82 @@ +package monorepo + +import ( + "fmt" + "path/filepath" + "slices" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// GetPackageGroups resolves version group options into actual package groups. +func GetPackageGroups(packageInfos types.PackageInfos, rootPath string, groups []types.VersionGroupOptions) types.PackageGroups { + result := make(types.PackageGroups) + + for _, g := range groups { + group := &types.PackageGroup{ + Name: g.Name, + DisallowedChangeTypes: g.DisallowedChangeTypes, + } + + for name, info := range packageInfos { + pkgDir := filepath.Dir(info.PackageJSONPath) + relPath, err := filepath.Rel(rootPath, pkgDir) + if err != nil { + continue + } + // Normalize to forward slashes for cross-platform glob matching + relPath = filepath.ToSlash(relPath) + + included := false + for _, pattern := range g.Include { + if matched, _ := doublestar.PathMatch(pattern, relPath); matched { + included = true + break + } + } + + if !included { + continue + } + + excluded := false + for _, pattern := range g.Exclude { + if matched, _ := doublestar.PathMatch(pattern, relPath); matched { + excluded = true + break + } + } + + if !excluded { + group.Packages = append(group.Packages, name) + } + } + + result[g.Name] = group + } + + // Check for packages belonging to multiple groups + packageToGroups := make(map[string][]string) + for _, group := range result { + for _, pkg := range group.Packages { + packageToGroups[pkg] = append(packageToGroups[pkg], group.Name) + } + } + + var errorItems []string + for pkg, groups := range packageToGroups { + if len(groups) > 1 { + slices.Sort(groups) + errorItems = append(errorItems, fmt.Sprintf("%s: %s", pkg, groups)) + } + } + if len(errorItems) > 0 { + slices.Sort(errorItems) + logging.Error.Printf("Found package(s) belonging to multiple groups:\n%s", + logging.BulletedList(errorItems)) + } + + return result +} diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go new file mode 100644 index 000000000..54fb3e87f --- /dev/null +++ b/go/internal/monorepo/package_groups_test.go @@ -0,0 +1,185 @@ +package monorepo_test + +import ( + "slices" + "testing" + + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" +) + +var root = testutil.FakeRoot() + +// TS: "returns empty object if no groups are defined" +func TestGetPackageGroups_ReturnsEmptyIfNoGroups(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/foo": "foo", + }) + result := monorepo.GetPackageGroups(infos, root, nil) + assert.Empty(t, result) +} + +// TS: "returns groups based on specific folders" +func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/foo": "foo", + "packages/bar": "bar", + "packages/baz": "baz", + }) + groups := []types.VersionGroupOptions{ + { + Name: "grp1", + Include: []string{"packages/foo", "packages/bar"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + assert.Len(t, result, 1) + grp := result["grp1"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) +} + +// TS: "handles single-level globs" +func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/ui-button": "ui-button", + "packages/ui-input": "ui-input", + "packages/core-utils": "core-utils", + }) + groups := []types.VersionGroupOptions{ + { + Name: "ui", + Include: []string{"packages/ui-*"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["ui"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) +} + +// TS: "handles multi-level globs" +func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/ui/button": "ui-button", + "packages/ui/input": "ui-input", + "packages/core": "core", + }) + groups := []types.VersionGroupOptions{ + { + Name: "ui", + Include: []string{"packages/ui/**"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["ui"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) +} + +// TS: "handles multiple include patterns in a single group" +func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/foo": "foo", + "libs/bar": "bar", + "other/baz": "baz", + }) + groups := []types.VersionGroupOptions{ + { + Name: "mixed", + Include: []string{"packages/*", "libs/*"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["mixed"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) +} + +// TS: "handles specific exclude patterns" +func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/foo": "foo", + "packages/bar": "bar", + "packages/internal": "internal", + }) + groups := []types.VersionGroupOptions{ + { + Name: "public", + Include: []string{"packages/*"}, + Exclude: []string{"packages/internal"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["public"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) +} + +// TS: "handles glob exclude patterns" +func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/ui/button": "ui-button", + "packages/ui/input": "ui-input", + "packages/core/utils": "core-utils", + }) + groups := []types.VersionGroupOptions{ + { + Name: "non-core", + Include: []string{"packages/**"}, + Exclude: []string{"packages/core/*"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["non-core"] + assert.NotNil(t, grp) + slices.Sort(grp.Packages) + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) +} + +// TS: "exits with error if package belongs to multiple groups" +func TestGetPackageGroups_ErrorsIfPackageBelongsToMultipleGroups(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/shared": "shared", + "packages/foo": "foo", + }) + groups := []types.VersionGroupOptions{ + { + Name: "group1", + Include: []string{"packages/*"}, + }, + { + Name: "group2", + Include: []string{"packages/shared"}, + }, + } + + buf := testutil.CaptureLogging(t) + monorepo.GetPackageGroups(infos, root, groups) + assert.Contains(t, buf.String(), "Found package(s) belonging to multiple groups") + assert.Contains(t, buf.String(), "shared") +} + +// TS: "omits empty groups" +func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { + infos := testutil.MakePackageInfos(root, map[string]string{ + "packages/foo": "foo", + }) + groups := []types.VersionGroupOptions{ + { + Name: "empty", + Include: []string{"nonexistent/*"}, + }, + } + result := monorepo.GetPackageGroups(infos, root, groups) + grp := result["empty"] + assert.NotNil(t, grp) + assert.Empty(t, grp.Packages) +} diff --git a/go/internal/monorepo/package_infos.go b/go/internal/monorepo/package_infos.go new file mode 100644 index 000000000..a67730d24 --- /dev/null +++ b/go/internal/monorepo/package_infos.go @@ -0,0 +1,100 @@ +package monorepo + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/jsonutil" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// GetPackageInfos discovers all packages in the workspace. +func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error) { + rootPath := options.Path + infos := make(types.PackageInfos) + + manager := DetectWorkspaceManager(rootPath) + patterns, literal := GetWorkspacePatterns(rootPath, manager) + + if len(patterns) == 0 { + // Single package repo: read the root package.json directly + rootPkgPath := filepath.Join(rootPath, "package.json") + rootPkg, err := jsonutil.ReadJSON[types.PackageJson](rootPkgPath) + if err != nil { + return nil, err + } + info := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[info.Name] = info + return infos, nil + } + + // Monorepo: add root package if it exists + rootPkgPath := filepath.Join(rootPath, "package.json") + if rootPkg, err := jsonutil.ReadJSON[types.PackageJson](rootPkgPath); err == nil && rootPkg.Name != "" { + rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[rootInfo.Name] = rootInfo + } + + if literal { + // Rush: patterns are literal paths + for _, p := range patterns { + pkgPath := filepath.Join(rootPath, p, "package.json") + if err := addPackageInfo(infos, pkgPath, rootPath); err != nil { + return nil, err + } + } + } else { + // Glob-based managers (npm, yarn, pnpm, lerna) + for _, pattern := range patterns { + pkgPattern := filepath.Join(rootPath, pattern, "package.json") + matches, err := doublestar.FilepathGlob(pkgPattern) + if err != nil { + continue + } + for _, match := range matches { + if strings.Contains(match, "node_modules") || strings.Contains(match, "__fixtures__") { + continue + } + if err := addPackageInfo(infos, match, rootPath); err != nil { + return nil, err + } + } + } + } + + return infos, nil +} + +// addPackageInfo reads a package.json and adds it to infos. Returns error on duplicate names. +func addPackageInfo(infos types.PackageInfos, pkgJsonPath string, rootPath string) error { + pkg, err := jsonutil.ReadJSON[types.PackageJson](pkgJsonPath) + if err != nil { + return nil // skip missing or unparseable files silently + } + absMatch, _ := filepath.Abs(pkgJsonPath) + info := packageInfoFromJSON(&pkg, absMatch) + + if existing, ok := infos[info.Name]; ok { + rootRel1, _ := filepath.Rel(rootPath, existing.PackageJSONPath) + rootRel2, _ := filepath.Rel(rootPath, absMatch) + logging.Error.Printf("Two packages have the same name %q. Please rename one of these packages:\n%s", + info.Name, logging.BulletedList([]string{rootRel1, rootRel2})) + return fmt.Errorf("duplicate package name: %s", info.Name) + } + infos[info.Name] = info + return nil +} + +func packageInfoFromJSON(pkg *types.PackageJson, jsonPath string) *types.PackageInfo { + absPath, _ := filepath.Abs(jsonPath) + return &types.PackageInfo{ + Name: pkg.Name, + Version: pkg.Version, + Private: pkg.Private, + PackageJSONPath: absPath, + PackageOptions: pkg.Beachball, + } +} diff --git a/go/internal/monorepo/path_included_test.go b/go/internal/monorepo/path_included_test.go new file mode 100644 index 000000000..033fe86d2 --- /dev/null +++ b/go/internal/monorepo/path_included_test.go @@ -0,0 +1,51 @@ +package monorepo_test + +// Note: Go tests FilterIgnoredFiles (used for ignorePatterns filtering), +// not TS's isPathIncluded (used for scope include/exclude). +// These are related but different functions. + +import ( + "testing" + + "github.com/microsoft/beachball/internal/monorepo" + "github.com/stretchr/testify/assert" +) + +func TestFilterIgnoredFiles_MatchesBasenamePatterns(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"src/foo.test.js"}, []string{"*.test.js"}) + assert.Empty(t, result) +} + +func TestFilterIgnoredFiles_MatchesPathPatterns(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"tests/stuff.js"}, []string{"tests/**"}) + assert.Empty(t, result) +} + +func TestFilterIgnoredFiles_DoesNotMatchUnrelatedFiles(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"src/index.js"}, []string{"*.test.js"}) + assert.Equal(t, []string{"src/index.js"}, result) +} + +func TestFilterIgnoredFiles_MatchesChangeDirPattern(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"change/foo.json"}, []string{"change/*.json"}) + assert.Empty(t, result) +} + +func TestFilterIgnoredFiles_MatchesCHANGELOG(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"packages/foo/CHANGELOG.md"}, []string{"CHANGELOG.md"}) + assert.Empty(t, result) +} + +func TestFilterIgnoredFiles_HandlesMultiplePatterns(t *testing.T) { + files := []string{"src/foo.test.js", "tests/stuff.js", "src/index.js"} + patterns := []string{"*.test.js", "tests/**"} + result := monorepo.FilterIgnoredFiles(files, patterns) + assert.Equal(t, []string{"src/index.js"}, result) +} + +func TestFilterIgnoredFiles_KeepsNonMatchingFiles(t *testing.T) { + files := []string{"src/index.js", "src/foo.test.js", "lib/utils.js", "CHANGELOG.md"} + patterns := []string{"*.test.js", "CHANGELOG.md"} + result := monorepo.FilterIgnoredFiles(files, patterns) + assert.Equal(t, []string{"src/index.js", "lib/utils.js"}, result) +} diff --git a/go/internal/monorepo/scoped_packages.go b/go/internal/monorepo/scoped_packages.go new file mode 100644 index 000000000..4d4225bdc --- /dev/null +++ b/go/internal/monorepo/scoped_packages.go @@ -0,0 +1,83 @@ +package monorepo + +import ( + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/types" +) + +// GetScopedPackages returns the set of packages that are in scope. +func GetScopedPackages(options *types.BeachballOptions, packageInfos types.PackageInfos) types.ScopedPackages { + scoped := make(types.ScopedPackages) + + if len(options.Scope) == 0 { + // All packages are in scope + for name := range packageInfos { + scoped[name] = true + } + return scoped + } + + // Start with all packages, then apply include/exclude patterns + included := make(map[string]bool) + excluded := make(map[string]bool) + + for _, pattern := range options.Scope { + isExclude := strings.HasPrefix(pattern, "!") + cleanPattern := pattern + if isExclude { + cleanPattern = pattern[1:] + } + + for name, info := range packageInfos { + pkgDir := filepath.Dir(info.PackageJSONPath) + // Try matching the pattern against the relative path from the project root + relPath, err := filepath.Rel(options.Path, pkgDir) + if err != nil { + continue + } + // Normalize to forward slashes for cross-platform glob matching + relPath = filepath.ToSlash(relPath) + + matched, _ := doublestar.PathMatch(cleanPattern, relPath) + if !matched { + matched, _ = doublestar.PathMatch(cleanPattern, name) + } + + if matched { + if isExclude { + excluded[name] = true + } else { + included[name] = true + } + } + } + } + + hasIncludes := false + for _, pattern := range options.Scope { + if !strings.HasPrefix(pattern, "!") { + hasIncludes = true + break + } + } + + if hasIncludes { + for name := range included { + if !excluded[name] { + scoped[name] = true + } + } + } else { + // Only excludes: start with all and remove excluded + for name := range packageInfos { + if !excluded[name] { + scoped[name] = true + } + } + } + + return scoped +} diff --git a/go/internal/monorepo/workspace_manager.go b/go/internal/monorepo/workspace_manager.go new file mode 100644 index 000000000..0c901a995 --- /dev/null +++ b/go/internal/monorepo/workspace_manager.go @@ -0,0 +1,146 @@ +package monorepo + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/microsoft/beachball/internal/git" + "gopkg.in/yaml.v3" +) + +// WorkspaceManager identifies a monorepo/workspace manager. +type WorkspaceManager string + +const ( + ManagerNpm WorkspaceManager = "npm" + ManagerYarn WorkspaceManager = "yarn" + ManagerPnpm WorkspaceManager = "pnpm" + ManagerLerna WorkspaceManager = "lerna" + ManagerRush WorkspaceManager = "rush" +) + +// managerByFile maps config file names to their manager, in precedence order +// matching workspace-tools. +var managerByFile = map[string]WorkspaceManager{ + "lerna.json": ManagerLerna, + "rush.json": ManagerRush, + "yarn.lock": ManagerYarn, + "pnpm-workspace.yaml": ManagerPnpm, + "package-lock.json": ManagerNpm, +} + +// DetectWorkspaceManager determines the workspace manager for the given root directory. +func DetectWorkspaceManager(rootPath string) WorkspaceManager { + // Check in precedence order (matching git.ManagerFiles) + for _, file := range git.ManagerFiles { + if _, err := os.Stat(filepath.Join(rootPath, file)); err == nil { + return managerByFile[file] + } + } + // Default to npm if package.json exists with workspaces + return ManagerNpm +} + +// GetWorkspacePatterns returns the workspace glob patterns (or literal paths for rush) +// for the detected manager at rootPath. +func GetWorkspacePatterns(rootPath string, manager WorkspaceManager) (patterns []string, literal bool) { + switch manager { + case ManagerPnpm: + return getPnpmPatterns(rootPath), false + case ManagerLerna: + return getLernaPatterns(rootPath), false + case ManagerRush: + return getRushPaths(rootPath), true + default: // npm, yarn + return getNpmYarnPatterns(rootPath), false + } +} + +// getNpmYarnPatterns reads workspace patterns from package.json workspaces field. +func getNpmYarnPatterns(rootPath string) []string { + data, err := os.ReadFile(filepath.Join(rootPath, "package.json")) + if err != nil { + return nil + } + + // Try array format: "workspaces": ["packages/*"] + var arrayFormat struct { + Workspaces []string `json:"workspaces"` + } + if err := json.Unmarshal(data, &arrayFormat); err == nil && len(arrayFormat.Workspaces) > 0 { + return arrayFormat.Workspaces + } + + // Try object format: "workspaces": {"packages": ["packages/*"]} + var objectFormat struct { + Workspaces struct { + Packages []string `json:"packages"` + } `json:"workspaces"` + } + if err := json.Unmarshal(data, &objectFormat); err == nil && len(objectFormat.Workspaces.Packages) > 0 { + return objectFormat.Workspaces.Packages + } + + return nil +} + +// getPnpmPatterns reads workspace patterns from pnpm-workspace.yaml. +func getPnpmPatterns(rootPath string) []string { + data, err := os.ReadFile(filepath.Join(rootPath, "pnpm-workspace.yaml")) + if err != nil { + return nil + } + var config struct { + Packages []string `yaml:"packages"` + } + if err := yaml.Unmarshal(data, &config); err != nil { + return nil + } + return config.Packages +} + +// getLernaPatterns reads workspace patterns from lerna.json. +// Falls back to npm/yarn/pnpm if lerna.json doesn't specify packages. +func getLernaPatterns(rootPath string) []string { + data, err := os.ReadFile(filepath.Join(rootPath, "lerna.json")) + if err != nil { + return nil + } + var config struct { + Packages []string `json:"packages"` + } + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + if len(config.Packages) > 0 { + return config.Packages + } + + // Lerna without packages: delegate to the actual package manager + if _, err := os.Stat(filepath.Join(rootPath, "pnpm-workspace.yaml")); err == nil { + return getPnpmPatterns(rootPath) + } + return getNpmYarnPatterns(rootPath) +} + +// getRushPaths reads project paths from rush.json (literal paths, not globs). +func getRushPaths(rootPath string) []string { + data, err := os.ReadFile(filepath.Join(rootPath, "rush.json")) + if err != nil { + return nil + } + var config struct { + Projects []struct { + ProjectFolder string `json:"projectFolder"` + } `json:"projects"` + } + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + paths := make([]string, len(config.Projects)) + for i, p := range config.Projects { + paths[i] = p.ProjectFolder + } + return paths +} diff --git a/go/internal/monorepo/workspace_manager_test.go b/go/internal/monorepo/workspace_manager_test.go new file mode 100644 index 000000000..c4b377891 --- /dev/null +++ b/go/internal/monorepo/workspace_manager_test.go @@ -0,0 +1,116 @@ +package monorepo_test + +import ( + "testing" + + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestOptions(rootPath string) types.BeachballOptions { + opts := types.DefaultOptions() + opts.Path = rootPath + opts.Branch = testutil.DefaultRemoteBranch + opts.Fetch = false + return opts +} + +// TS: pnpm workspace detection +func TestGetPackageInfos_PnpmWorkspace(t *testing.T) { + // Create a monorepo with packages but no npm workspaces in package.json + rootPkg := map[string]any{ + "name": "pnpm-monorepo", + "version": "1.0.0", + "private": true, + } + packages := map[string]map[string]any{ + "foo": {"name": "foo", "version": "1.0.0"}, + "bar": {"name": "bar", "version": "1.0.0"}, + } + factory := testutil.NewCustomRepositoryFactory(t, rootPkg, map[string]map[string]map[string]any{ + "packages": packages, + }) + repo := factory.CloneRepository() + + // Add pnpm-workspace.yaml + repo.WriteFileContent("pnpm-workspace.yaml", "packages:\n - 'packages/*'\n") + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "add pnpm config"}) + + opts := getTestOptions(repo.RootPath()) + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, opts) + infos, err := monorepo.GetPackageInfos(&parsed.Options) + require.NoError(t, err) + + assert.Contains(t, infos, "foo") + assert.Contains(t, infos, "bar") +} + +// TS: lerna workspace detection +func TestGetPackageInfos_LernaWorkspace(t *testing.T) { + rootPkg := map[string]any{ + "name": "lerna-monorepo", + "version": "1.0.0", + "private": true, + } + packages := map[string]map[string]any{ + "foo": {"name": "foo", "version": "1.0.0"}, + "bar": {"name": "bar", "version": "1.0.0"}, + } + factory := testutil.NewCustomRepositoryFactory(t, rootPkg, map[string]map[string]map[string]any{ + "packages": packages, + }) + repo := factory.CloneRepository() + + // Add lerna.json + repo.WriteFileContent("lerna.json", `{"packages": ["packages/*"]}`) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "add lerna config"}) + + opts := getTestOptions(repo.RootPath()) + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, opts) + infos, err := monorepo.GetPackageInfos(&parsed.Options) + require.NoError(t, err) + + assert.Contains(t, infos, "foo") + assert.Contains(t, infos, "bar") +} + +// TS: rush workspace detection +func TestGetPackageInfos_RushWorkspace(t *testing.T) { + rootPkg := map[string]any{ + "name": "rush-monorepo", + "version": "1.0.0", + "private": true, + } + packages := map[string]map[string]any{ + "foo": {"name": "foo", "version": "1.0.0"}, + "bar": {"name": "bar", "version": "1.0.0"}, + } + factory := testutil.NewCustomRepositoryFactory(t, rootPkg, map[string]map[string]map[string]any{ + "packages": packages, + }) + repo := factory.CloneRepository() + + // Add rush.json with project folders + repo.WriteFileContent("rush.json", `{ + "projects": [ + {"packageName": "foo", "projectFolder": "packages/foo"}, + {"packageName": "bar", "projectFolder": "packages/bar"} + ] + }`) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "add rush config"}) + + opts := getTestOptions(repo.RootPath()) + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, opts) + infos, err := monorepo.GetPackageInfos(&parsed.Options) + require.NoError(t, err) + + assert.Contains(t, infos, "foo") + assert.Contains(t, infos, "bar") +} diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go new file mode 100644 index 000000000..a8af10823 --- /dev/null +++ b/go/internal/options/get_options.go @@ -0,0 +1,155 @@ +package options + +import ( + "path/filepath" + + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// GetParsedOptions merges defaults, repo config, and CLI options. +func GetParsedOptions(cwd string, cli types.CliOptions) (types.ParsedOptions, error) { + if cli.Path != "" { + cwd = cli.Path + } + + absPath, err := filepath.Abs(cwd) + if err != nil { + absPath = cwd + } + + // Find project root + projectRoot, err := git.FindProjectRoot(absPath) + if err != nil { + projectRoot = absPath + } + + opts := types.DefaultOptions() + opts.Path = projectRoot + + // Load repo config + repoCfg, _ := LoadRepoConfig(projectRoot, cli.ConfigPath) + if repoCfg != nil { + applyRepoConfig(&opts, repoCfg) + } + + // Apply CLI overrides + applyCliOptions(&opts, &cli) + + if opts.Verbose { + logging.EnableVerbose() + } + + // Only resolve the branch from git if CLI didn't specify one (matching TS getRepoOptions). + // This avoids unnecessary git operations and potential log noise when --branch is explicit. + if opts.Branch == "" { + // No branch specified at all — detect default remote and branch + opts.Branch = git.GetDefaultRemoteBranch(projectRoot, "") + } else { + // Config branch without remote prefix — add the default remote + opts.Branch = git.GetDefaultRemoteBranch(projectRoot, opts.Branch) + } + + return types.ParsedOptions{Options: opts, CliOptions: cli}, nil +} + +// GetParsedOptionsForTest creates parsed options for testing with explicit overrides. +// opts is the repo options as if from the beachball config file. +// cwd is used as opts.Path if opts.Path is empty. +// NOTE: Properties of opts currently are not deep-copied (only matters for slices). +func GetParsedOptionsForTest(cwd string, cli types.CliOptions, opts types.BeachballOptions) types.ParsedOptions { + if opts.Path == "" { + opts.Path = cwd + } + + // Apply CLI overrides + applyCliOptions(&opts, &cli) + + return types.ParsedOptions{Options: opts, CliOptions: cli} +} + +func applyRepoConfig(opts *types.BeachballOptions, cfg *RepoConfig) { + if cfg.AuthType != "" { + opts.AuthType = types.AuthType(cfg.AuthType) + } + if cfg.Branch != "" { + opts.Branch = cfg.Branch + } + if cfg.ChangeDir != "" { + opts.ChangeDir = cfg.ChangeDir + } + if cfg.ChangeHint != "" { + opts.ChangeHint = cfg.ChangeHint + } + if cfg.Commit != nil { + opts.Commit = *cfg.Commit + } + if cfg.DependentChangeType != "" { + opts.DependentChangeType = types.ChangeType(cfg.DependentChangeType) + } + if cfg.DisallowedChangeTypes != nil { + dct := make([]types.ChangeType, len(cfg.DisallowedChangeTypes)) + for i, s := range cfg.DisallowedChangeTypes { + dct[i] = types.ChangeType(s) + } + opts.DisallowedChangeTypes = dct + } + if cfg.DisallowDeletedChangeFiles != nil { + opts.DisallowDeletedChangeFiles = *cfg.DisallowDeletedChangeFiles + } + if cfg.Fetch != nil { + opts.Fetch = *cfg.Fetch + } + if cfg.GroupChanges != nil { + opts.GroupChanges = *cfg.GroupChanges + } + if cfg.IgnorePatterns != nil { + opts.IgnorePatterns = cfg.IgnorePatterns + } + if cfg.Scope != nil { + opts.Scope = cfg.Scope + } + if cfg.Groups != nil { + opts.Groups = cfg.Groups + } +} + +func applyCliOptions(opts *types.BeachballOptions, cli *types.CliOptions) { + if cli.All != nil { + opts.All = *cli.All + } + if cli.Branch != "" { + opts.Branch = cli.Branch + } + if cli.Command != "" { + opts.Command = cli.Command + } + if cli.ChangeType != "" { + opts.Type = types.ChangeType(cli.ChangeType) + } + if cli.Commit != nil { + opts.Commit = *cli.Commit + } + if cli.DependentChangeType != "" { + opts.DependentChangeType = types.ChangeType(cli.DependentChangeType) + } + if cli.Fetch != nil { + opts.Fetch = *cli.Fetch + } + if cli.Message != "" { + opts.Message = cli.Message + } + if cli.Package != nil { + opts.Package = cli.Package + } + if cli.Path != "" { + opts.Path = cli.Path + } + if cli.Scope != nil { + opts.Scope = cli.Scope + } + if cli.Verbose != nil { + opts.Verbose = *cli.Verbose + } +} diff --git a/go/internal/options/repo_options.go b/go/internal/options/repo_options.go new file mode 100644 index 000000000..34c44b7b0 --- /dev/null +++ b/go/internal/options/repo_options.go @@ -0,0 +1,68 @@ +package options + +import ( + "path/filepath" + + "github.com/microsoft/beachball/internal/jsonutil" + "github.com/microsoft/beachball/internal/types" +) + +// RepoConfig represents beachball config found in a JSON file or package.json. +type RepoConfig struct { + AuthType string `json:"authType,omitempty"` + Branch string `json:"branch,omitempty"` + ChangeDir string `json:"changeDir,omitempty"` + ChangeHint string `json:"changehint,omitempty"` + Commit *bool `json:"commit,omitempty"` + DependentChangeType string `json:"dependentChangeType,omitempty"` + DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` + DisallowDeletedChangeFiles *bool `json:"disallowDeletedChangeFiles,omitempty"` + Fetch *bool `json:"fetch,omitempty"` + GroupChanges *bool `json:"groupChanges,omitempty"` + IgnorePatterns []string `json:"ignorePatterns,omitempty"` + Scope []string `json:"scope,omitempty"` + Groups []types.VersionGroupOptions `json:"groups,omitempty"` +} + +// LoadRepoConfig reads the beachball config from projectRoot (absolute path). +// configPath is from an optional CLI arg and may be relative or absolute. +// If configPath is not specified, looks for .beachballrc.json or package.json +// "beachball" field. Returns nil if no config is found. +func LoadRepoConfig(projectRoot string, configPath string) (*RepoConfig, error) { + if configPath != "" { + return loadConfigFile(filepath.Join(projectRoot, configPath)) + } + + // Try .beachballrc.json + rcPath := filepath.Join(projectRoot, ".beachballrc.json") + if cfg, err := loadConfigFile(rcPath); err == nil { + return cfg, nil + } + + // Try package.json "beachball" field + pkgPath := filepath.Join(projectRoot, "package.json") + if cfg, err := loadFromPackageJSON(pkgPath); err == nil && cfg != nil { + return cfg, nil + } + + return nil, nil +} + +func loadConfigFile(path string) (*RepoConfig, error) { + cfg, err := jsonutil.ReadJSON[RepoConfig](path) + if err != nil { + return nil, err + } + return &cfg, nil +} + +func loadFromPackageJSON(path string) (*RepoConfig, error) { + type pkgWithBeachball struct { + Beachball *RepoConfig `json:"beachball"` + } + pkg, err := jsonutil.ReadJSON[pkgWithBeachball](path) + if err != nil { + return nil, err + } + return pkg.Beachball, nil +} diff --git a/go/internal/testutil/capture_logging.go b/go/internal/testutil/capture_logging.go new file mode 100644 index 000000000..0f722fda4 --- /dev/null +++ b/go/internal/testutil/capture_logging.go @@ -0,0 +1,25 @@ +package testutil + +import ( + "bytes" + "testing" + + "github.com/microsoft/beachball/internal/logging" +) + +// CaptureLogging redirects all loggers to a buffer for the duration of the test. +func CaptureLogging(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + logging.SetOutput(&buf, false) + t.Cleanup(logging.Reset) + return &buf +} + +func CaptureVerboseLogging(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + logging.SetOutput(&buf, true) + t.Cleanup(logging.Reset) + return &buf +} diff --git a/go/internal/testutil/change_files.go b/go/internal/testutil/change_files.go new file mode 100644 index 000000000..a507feaaf --- /dev/null +++ b/go/internal/testutil/change_files.go @@ -0,0 +1,81 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/types" +) + +const FakeEmail = "test@test.com" + +// WriteChangeFilesFn is the function used by GenerateChangeFiles to write change files. +// Tests must set this to changefile.WriteChangeFiles to break the import cycle. +// Example: testutil.WriteChangeFilesFn = changefile.WriteChangeFiles +var WriteChangeFilesFn func(options *types.BeachballOptions, changes []types.ChangeFileInfo) error + +// GetChange creates a ChangeFileInfo with sensible defaults. +// Mirrors the TS getChange fixture helper. +func GetChange(packageName string, comment string, changeType types.ChangeType) types.ChangeFileInfo { + if comment == "" { + comment = packageName + " comment" + } + if changeType == "" { + changeType = types.ChangeTypeMinor + } + return types.ChangeFileInfo{ + Type: changeType, + Comment: comment, + PackageName: packageName, + Email: FakeEmail, + DependentChangeType: types.ChangeTypePatch, + } +} + +// GenerateChanges creates ChangeFileInfo entries from package names with defaults. +// Mirrors the TS generateChanges fixture helper. +func GenerateChanges(packages []string) []types.ChangeFileInfo { + changes := make([]types.ChangeFileInfo, len(packages)) + for i, pkg := range packages { + changes[i] = GetChange(pkg, "", "") + } + return changes +} + +// GenerateChangeFiles creates change files for the given packages using the real +// WriteChangeFiles implementation. Mirrors the TS generateChangeFiles fixture helper. +// WriteChangeFilesFn must be set before calling this function. +func GenerateChangeFiles(t *testing.T, packages []string, options *types.BeachballOptions, repo *Repository) { + t.Helper() + + if WriteChangeFilesFn == nil { + t.Fatal("testutil.WriteChangeFilesFn must be set (e.g. testutil.WriteChangeFilesFn = changefile.WriteChangeFiles)") + } + + changes := make([]types.ChangeFileInfo, len(packages)) + for i, pkg := range packages { + changes[i] = GetChange(pkg, "test change", types.ChangeTypePatch) + } + + if err := WriteChangeFilesFn(options, changes); err != nil { + t.Fatalf("failed to write change files: %v", err) + } +} + +// GetChangeFiles returns the list of change file paths. +func GetChangeFiles(options *types.BeachballOptions) []string { + changePath := filepath.Join(options.Path, options.ChangeDir) + entries, err := os.ReadDir(changePath) + if err != nil { + return nil + } + + var files []string + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".json" { + files = append(files, filepath.Join(changePath, entry.Name())) + } + } + return files +} diff --git a/go/internal/testutil/fixtures.go b/go/internal/testutil/fixtures.go new file mode 100644 index 000000000..371f96bde --- /dev/null +++ b/go/internal/testutil/fixtures.go @@ -0,0 +1,93 @@ +package testutil + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Fixture helpers for common repo types. + +func writePkgJSON(dir string, pkg map[string]any) { + data, _ := json.MarshalIndent(pkg, "", " ") + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644) +} + +// SetupSinglePackage sets up a single-package repo fixture. +func SetupSinglePackage(dir string) { + writePkgJSON(dir, map[string]any{ + "name": "foo", + "version": "1.0.0", + "dependencies": map[string]string{ + "bar": "1.0.0", + "baz": "1.0.0", + }, + }) +} + +// SetupMonorepo sets up a monorepo fixture with multiple packages. +func SetupMonorepo(dir string) { + writePkgJSON(dir, map[string]any{ + "name": "monorepo", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + + packages := map[string]map[string]any{ + "foo": {"name": "foo", "version": "1.0.0"}, + "bar": {"name": "bar", "version": "1.0.0"}, + "baz": {"name": "baz", "version": "1.0.0"}, + "a": {"name": "a", "version": "1.0.0"}, + "b": {"name": "b", "version": "1.0.0"}, + } + + for name, pkg := range packages { + writePkgJSON(filepath.Join(dir, "packages", name), pkg) + } +} + +// SetupMultiProject sets up a multi-project repo fixture. +func SetupMultiProject(dir string) { + // Project A + projA := filepath.Join(dir, "project-a") + writePkgJSON(projA, map[string]any{ + "name": "project-a", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + writePkgJSON(filepath.Join(projA, "packages", "foo"), map[string]any{ + "name": "@project-a/foo", + "version": "1.0.0", + }) + writePkgJSON(filepath.Join(projA, "packages", "bar"), map[string]any{ + "name": "@project-a/bar", + "version": "1.0.0", + }) + + // Project B + projB := filepath.Join(dir, "project-b") + writePkgJSON(projB, map[string]any{ + "name": "project-b", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + writePkgJSON(filepath.Join(projB, "packages", "foo"), map[string]any{ + "name": "@project-b/foo", + "version": "1.0.0", + }) +} + +// SetupCustomMonorepo sets up a monorepo with custom package definitions. +func SetupCustomMonorepo(dir string, rootPkg map[string]any, groups map[string]map[string]map[string]any) { + writePkgJSON(dir, rootPkg) + + for groupDir, packages := range groups { + for name, pkg := range packages { + writePkgJSON(filepath.Join(dir, groupDir, name), pkg) + } + } +} diff --git a/go/internal/testutil/package_infos.go b/go/internal/testutil/package_infos.go new file mode 100644 index 000000000..47061adb7 --- /dev/null +++ b/go/internal/testutil/package_infos.go @@ -0,0 +1,29 @@ +package testutil + +import ( + "path/filepath" + + "github.com/microsoft/beachball/internal/types" +) + +// MakePackageInfos builds PackageInfos from a map of folder->name, with root prefix. +func MakePackageInfos(root string, folders map[string]string) types.PackageInfos { + infos := make(types.PackageInfos) + for folder, name := range folders { + infos[name] = &types.PackageInfo{ + Name: name, + Version: "1.0.0", + PackageJSONPath: filepath.Join(root, folder, "package.json"), + } + } + return infos +} + +// MakePackageInfosSimple builds PackageInfos from names, defaulting folder to packages/{name}. +func MakePackageInfosSimple(root string, names ...string) types.PackageInfos { + folders := make(map[string]string, len(names)) + for _, name := range names { + folders[filepath.Join("packages", name)] = name + } + return MakePackageInfos(root, folders) +} diff --git a/go/internal/testutil/paths.go b/go/internal/testutil/paths.go new file mode 100644 index 000000000..f326a51ca --- /dev/null +++ b/go/internal/testutil/paths.go @@ -0,0 +1,15 @@ +package testutil + +import "runtime" + +const DefaultBranch = "master" +const DefaultRemoteBranch = "origin/master" + +// FakeRoot returns a fake root path appropriate for the current OS +// (e.g. "/fake-root" on Unix, `C:\fake-root` on Windows). +func FakeRoot() string { + if runtime.GOOS == "windows" { + return `C:\fake-root` + } + return "/fake-root" +} diff --git a/go/internal/testutil/repository.go b/go/internal/testutil/repository.go new file mode 100644 index 000000000..32ab9751d --- /dev/null +++ b/go/internal/testutil/repository.go @@ -0,0 +1,109 @@ +package testutil + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/git" +) + +// Repository is a test helper for a cloned git repo. +type Repository struct { + t *testing.T + rootDir string +} + +// NewRepository creates a Repository wrapper for a directory. +func NewRepository(t *testing.T, dir string) *Repository { + return &Repository{t: t, rootDir: dir} +} + +// RootPath returns the root directory of the repository. +func (r *Repository) RootPath() string { + return r.rootDir +} + +// PathTo returns an absolute path relative to the repo root. +func (r *Repository) PathTo(parts ...string) string { + return filepath.Join(append([]string{r.rootDir}, parts...)...) +} + +// Git runs a git command in the repository. +func (r *Repository) Git(args []string) string { + result, err := git.Git(args, r.rootDir) + if err != nil { + r.t.Fatalf("git %v failed: %v", args, err) + } + if !result.Success { + r.t.Fatalf("git %v failed (exit %d): %s", args, result.ExitCode, result.Stderr) + } + return result.Stdout +} + +// Checkout runs git checkout. +func (r *Repository) Checkout(args ...string) { + r.Git(append([]string{"checkout"}, args...)) +} + +// WriteFile creates a file at the given path (relative to repo root). +func (r *Repository) WriteFile(relPath string) { + r.WriteFileContent(relPath, "test content") +} + +// WriteFileContent creates a file with specific content. +func (r *Repository) WriteFileContent(relPath, content string) { + fullPath := filepath.Join(r.rootDir, relPath) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + r.t.Fatalf("failed to create dir %s: %v", dir, err) + } + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + r.t.Fatalf("failed to write file %s: %v", fullPath, err) + } +} + +// StageChange creates and stages a file. +func (r *Repository) StageChange(relPath string) { + r.WriteFile(relPath) + r.Git([]string{"add", relPath}) +} + +// CommitChange creates, stages, and commits a file. +func (r *Repository) CommitChange(relPath string) { + r.StageChange(relPath) + r.Git([]string{"commit", "-m", "Commit " + relPath}) +} + +// Push pushes to origin. +func (r *Repository) Push() { + r.Git([]string{"push", "origin", "HEAD"}) +} + +// Status returns git status. +func (r *Repository) Status() string { + return r.Git([]string{"status", "--short"}) +} + +// UpdatePackageJson reads a package.json, applies updates, writes it back, and commits. +func (r *Repository) UpdatePackageJson(relPkgJsonPath string, updates map[string]any) { + fullPath := filepath.Join(r.rootDir, relPkgJsonPath) + data, err := os.ReadFile(fullPath) + if err != nil { + r.t.Fatalf("failed to read %s: %v", relPkgJsonPath, err) + } + var pkg map[string]any + if err := json.Unmarshal(data, &pkg); err != nil { + r.t.Fatalf("failed to parse %s: %v", relPkgJsonPath, err) + } + for k, v := range updates { + pkg[k] = v + } + data, _ = json.MarshalIndent(pkg, "", " ") + if err := os.WriteFile(fullPath, data, 0o644); err != nil { + r.t.Fatalf("failed to write %s: %v", relPkgJsonPath, err) + } + r.Git([]string{"add", relPkgJsonPath}) + r.Git([]string{"commit", "-m", "Update " + relPkgJsonPath}) +} diff --git a/go/internal/testutil/repository_factory.go b/go/internal/testutil/repository_factory.go new file mode 100644 index 000000000..8aa0f3b9a --- /dev/null +++ b/go/internal/testutil/repository_factory.go @@ -0,0 +1,112 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/git" +) + +// RepositoryFactory creates test git repositories. +type RepositoryFactory struct { + t *testing.T + bareDir string + fixtureType string + customRoot map[string]any + customGroups map[string]map[string]map[string]any +} + +// NewRepositoryFactory creates a factory for the given fixture type. +func NewRepositoryFactory(t *testing.T, fixtureType string) *RepositoryFactory { + t.Helper() + + bareDir, err := os.MkdirTemp("", "beachball-bare-*") + if err != nil { + t.Fatalf("failed to create bare dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(bareDir) }) + + f := &RepositoryFactory{ + t: t, + bareDir: bareDir, + fixtureType: fixtureType, + } + + f.initBareRepo() + return f +} + +// NewCustomRepositoryFactory creates a factory with custom package definitions. +func NewCustomRepositoryFactory(t *testing.T, rootPkg map[string]any, groups map[string]map[string]map[string]any) *RepositoryFactory { + t.Helper() + + bareDir, err := os.MkdirTemp("", "beachball-bare-*") + if err != nil { + t.Fatalf("failed to create bare dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(bareDir) }) + + f := &RepositoryFactory{ + t: t, + bareDir: bareDir, + fixtureType: "custom", + customRoot: rootPkg, + customGroups: groups, + } + + f.initBareRepo() + return f +} + +func (f *RepositoryFactory) initBareRepo() { + // Initialize bare repo with master as default branch + git.Git([]string{"init", "--bare", "--initial-branch=master", f.bareDir}, f.bareDir) + + // Create a temporary clone to add initial content + tmpDir, err := os.MkdirTemp("", "beachball-init-*") + if err != nil { + f.t.Fatalf("failed to create init dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cloneDir := filepath.Join(tmpDir, "repo") + git.Git([]string{"clone", f.bareDir, cloneDir}, tmpDir) + + // Configure git for commits + git.Git([]string{"config", "user.email", "test@test.com"}, cloneDir) + git.Git([]string{"config", "user.name", "Test"}, cloneDir) + + // Set up fixtures + switch f.fixtureType { + case "single": + SetupSinglePackage(cloneDir) + case "monorepo": + SetupMonorepo(cloneDir) + case "multi-project": + SetupMultiProject(cloneDir) + case "custom": + SetupCustomMonorepo(cloneDir, f.customRoot, f.customGroups) + } + + // Commit and push + git.Git([]string{"add", "-A"}, cloneDir) + git.Git([]string{"commit", "-m", "Initial commit"}, cloneDir) + git.Git([]string{"push", "origin", "HEAD"}, cloneDir) +} + +// CloneRepository creates a new clone of the bare repo. +func (f *RepositoryFactory) CloneRepository() *Repository { + cloneDir, err := os.MkdirTemp("", "beachball-clone-*") + if err != nil { + f.t.Fatalf("failed to create clone dir: %v", err) + } + f.t.Cleanup(func() { os.RemoveAll(cloneDir) }) + + repoDir := filepath.Join(cloneDir, "repo") + git.Git([]string{"clone", f.bareDir, repoDir}, cloneDir) + git.Git([]string{"config", "user.email", "test@test.com"}, repoDir) + git.Git([]string{"config", "user.name", "Test"}, repoDir) + + return NewRepository(f.t, repoDir) +} diff --git a/go/internal/types/change_info.go b/go/internal/types/change_info.go new file mode 100644 index 000000000..502a19018 --- /dev/null +++ b/go/internal/types/change_info.go @@ -0,0 +1,89 @@ +package types + +import ( + "encoding/json" + "fmt" +) + +// ChangeType represents the type of version bump. +type ChangeType string + +const ( + ChangeTypeNone ChangeType = "none" + ChangeTypePrerelease ChangeType = "prerelease" + ChangeTypePrepatch ChangeType = "prepatch" + ChangeTypePatch ChangeType = "patch" + ChangeTypePreminor ChangeType = "preminor" + ChangeTypeMinor ChangeType = "minor" + ChangeTypePremajor ChangeType = "premajor" + ChangeTypeMajor ChangeType = "major" +) + +var validChangeTypes = map[ChangeType]bool{ + ChangeTypeNone: true, + ChangeTypePrerelease: true, + ChangeTypePrepatch: true, + ChangeTypePatch: true, + ChangeTypePreminor: true, + ChangeTypeMinor: true, + ChangeTypePremajor: true, + ChangeTypeMajor: true, +} + +func (c ChangeType) String() string { + return string(c) +} + +// ParseChangeType validates and returns a ChangeType from a string. +func ParseChangeType(s string) (ChangeType, error) { + ct := ChangeType(s) + if validChangeTypes[ct] { + return ct, nil + } + return "", fmt.Errorf("invalid change type: %q", s) +} + +func (c ChangeType) MarshalJSON() ([]byte, error) { + return json.Marshal(string(c)) +} + +func (c *ChangeType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + ct, err := ParseChangeType(s) + if err != nil { + return err + } + *c = ct + return nil +} + +// IsValidChangeType checks if a string is a valid change type. +func IsValidChangeType(s string) bool { + return validChangeTypes[ChangeType(s)] +} + +// ChangeFileInfo is the info saved in each change file. +type ChangeFileInfo struct { + Type ChangeType `json:"type"` + Comment string `json:"comment"` + PackageName string `json:"packageName"` + Email string `json:"email"` + DependentChangeType ChangeType `json:"dependentChangeType"` +} + +// ChangeInfoMultiple is the info saved in grouped change files. +type ChangeInfoMultiple struct { + Changes []ChangeFileInfo `json:"changes"` +} + +// ChangeSetEntry is one entry in a change set. +type ChangeSetEntry struct { + Change ChangeFileInfo + ChangeFile string +} + +// ChangeSet is a list of change file infos. +type ChangeSet []ChangeSetEntry diff --git a/go/internal/types/options.go b/go/internal/types/options.go new file mode 100644 index 000000000..cbdcd1d9d --- /dev/null +++ b/go/internal/types/options.go @@ -0,0 +1,86 @@ +package types + +// AuthType represents the authentication type for npm registry. +type AuthType string + +const ( + AuthTypeAuthToken AuthType = "authtoken" + AuthTypePassword AuthType = "password" +) + +// BeachballOptions holds all beachball configuration. +// TODO: this mixes RepoOptions and merged options +type BeachballOptions struct { + All bool + AuthType AuthType + Branch string + ChangeDir string + ChangeHint string + Command string + Commit bool + DependentChangeType ChangeType + DisallowedChangeTypes []ChangeType + DisallowDeletedChangeFiles bool + Fetch bool + GroupChanges bool + Groups []VersionGroupOptions + IgnorePatterns []string + Message string + Package []string + Path string + Scope []string + Token string + Type ChangeType + Verbose bool +} + +// DefaultOptions returns BeachballOptions with sensible defaults. +func DefaultOptions() BeachballOptions { + return BeachballOptions{ + AuthType: AuthTypeAuthToken, + Branch: "origin/master", + ChangeDir: "change", + ChangeHint: "Run 'beachball change' to create a change file", + Command: "change", + Commit: true, + Fetch: true, + } +} + +// PackageOptions represents beachball-specific options in package.json. +type PackageOptions struct { + ShouldPublish *bool `json:"shouldPublish,omitempty"` + DisallowedChangeTypes []ChangeType `json:"disallowedChangeTypes,omitempty"` + DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` +} + +// VersionGroupOptions configures version groups. +type VersionGroupOptions struct { + Name string `json:"name"` + Include []string `json:"include"` + Exclude []string `json:"exclude,omitempty"` + DisallowedChangeTypes []ChangeType `json:"disallowedChangeTypes,omitempty"` +} + +// CliOptions holds CLI-specific options that override config. +type CliOptions struct { + All *bool + Branch string + Command string + ChangeType string + Commit *bool + ConfigPath string + DependentChangeType string + Fetch *bool + Message string + Package []string + Path string + Scope []string + Verbose *bool +} + +// ParsedOptions holds the final merged options. +type ParsedOptions struct { + Options BeachballOptions + CliOptions CliOptions +} diff --git a/go/internal/types/package_info.go b/go/internal/types/package_info.go new file mode 100644 index 000000000..a35d4d89f --- /dev/null +++ b/go/internal/types/package_info.go @@ -0,0 +1,36 @@ +package types + +// PackageJson represents a parsed package.json file. +type PackageJson struct { + Name string `json:"name"` + Version string `json:"version"` + Private bool `json:"private"` + Workspaces []string `json:"workspaces,omitempty"` + Dependencies map[string]string `json:"dependencies,omitempty"` + Beachball *PackageOptions `json:"beachball,omitempty"` +} + +// PackageInfo holds information about a single package. +type PackageInfo struct { + Name string + Version string + Private bool + PackageJSONPath string + PackageOptions *PackageOptions +} + +// PackageInfos maps package names to their info. +type PackageInfos map[string]*PackageInfo + +// ScopedPackages is a set of package names that are in scope. +type ScopedPackages map[string]bool + +// PackageGroup represents a version group. +type PackageGroup struct { + Name string + Packages []string + DisallowedChangeTypes []ChangeType +} + +// PackageGroups maps group name to group info. +type PackageGroups map[string]*PackageGroup diff --git a/go/internal/validation/are_change_files_deleted.go b/go/internal/validation/are_change_files_deleted.go new file mode 100644 index 000000000..72077b046 --- /dev/null +++ b/go/internal/validation/are_change_files_deleted.go @@ -0,0 +1,27 @@ +package validation + +import ( + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/types" +) + +// AreChangeFilesDeleted checks if any change files have been deleted. +func AreChangeFilesDeleted(options *types.BeachballOptions) bool { + changePath := changefile.GetChangePath(options) + + logging.Info.Printf("Checking for deleted change files against %q", options.Branch) + + deleted, err := git.GetChangesBetweenRefs(options.Branch, "D", "*.json", changePath) + if err != nil { + return false + } + + if len(deleted) > 0 { + logging.Error.Printf("The following change files were deleted:\n%s", + logging.BulletedList(deleted)) + } + + return len(deleted) > 0 +} diff --git a/go/internal/validation/are_change_files_deleted_test.go b/go/internal/validation/are_change_files_deleted_test.go new file mode 100644 index 000000000..698e56dc2 --- /dev/null +++ b/go/internal/validation/are_change_files_deleted_test.go @@ -0,0 +1,89 @@ +package validation_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/microsoft/beachball/internal/validation" + "github.com/stretchr/testify/assert" +) + +// get default options with a specified root path (fetch disabled) +func getDefaultOptionsWithPath(rootPath string) types.BeachballOptions { + defaultOptions := types.DefaultOptions() + defaultOptions.Branch = testutil.DefaultRemoteBranch + defaultOptions.Fetch = false + defaultOptions.Path = rootPath + + return defaultOptions +} + +// TS: "is false when no change files are deleted" +func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + // Create a change file on master and push it + opts := getDefaultOptionsWithPath(repo.RootPath()) + + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + // Checkout a new branch — no deletions + repo.Checkout("-b", "test-no-delete", testutil.DefaultBranch) + + result := validation.AreChangeFilesDeleted(&opts) + assert.False(t, result) +} + +// TS: "is true when change files are deleted" +func TestAreChangeFilesDeleted_TrueWhenChangeFilesDeleted(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + // Create a change file on master and push it + opts := getDefaultOptionsWithPath(repo.RootPath()) + + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + // Checkout a new branch and delete the change files using git rm + repo.Checkout("-b", "test-delete", testutil.DefaultBranch) + + changePath := filepath.Join(repo.RootPath(), opts.ChangeDir) + repo.Git([]string{"rm", "-r", changePath}) + repo.Git([]string{"commit", "-m", "Delete change files"}) + + // Recreate the directory so git can run from it + os.MkdirAll(changePath, 0o755) + + result := validation.AreChangeFilesDeleted(&opts) + assert.True(t, result) +} + +// TS: "deletes change files when changeDir option is specified" +func TestAreChangeFilesDeleted_WorksWithCustomChangeDir(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + opts := getDefaultOptionsWithPath(repo.RootPath()) + opts.ChangeDir = "custom-changes" + + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + repo.Checkout("-b", "test-custom-delete", testutil.DefaultBranch) + + changePath := filepath.Join(repo.RootPath(), opts.ChangeDir) + repo.Git([]string{"rm", "-r", changePath}) + repo.Git([]string{"commit", "-m", "Delete custom change files"}) + + // Recreate the directory so git can run from it + os.MkdirAll(changePath, 0o755) + + result := validation.AreChangeFilesDeleted(&opts) + assert.True(t, result) +} diff --git a/go/internal/validation/testmain_test.go b/go/internal/validation/testmain_test.go new file mode 100644 index 000000000..376322dd0 --- /dev/null +++ b/go/internal/validation/testmain_test.go @@ -0,0 +1,18 @@ +package validation_test + +// TestMain wires up testutil.WriteChangeFilesFn with the real changefile.WriteChangeFiles +// implementation, breaking the import cycle between testutil and changefile. +// This runs before any tests in this package. + +import ( + "os" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/testutil" +) + +func TestMain(m *testing.M) { + testutil.WriteChangeFilesFn = changefile.WriteChangeFiles + os.Exit(m.Run()) +} diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go new file mode 100644 index 000000000..d630166eb --- /dev/null +++ b/go/internal/validation/validate.go @@ -0,0 +1,163 @@ +package validation + +import ( + "fmt" + "slices" + "strings" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/types" +) + +// ValidateOptions controls what validation checks are performed. +type ValidateOptions struct { + CheckChangeNeeded bool + AllowMissingChangeFiles bool +} + +// ValidationResult holds the result of validation. +type ValidationResult struct { + IsChangeNeeded bool + ChangedPackages []string + PackageInfos types.PackageInfos + PackageGroups types.PackageGroups + ScopedPackages types.ScopedPackages + ChangeSet types.ChangeSet +} + +// Validate runs validation of options, change files, and packages. +func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*ValidationResult, error) { + options := &parsed.Options + hasError := false + + logError := func(msg string) { + logging.Error.Println(msg) + hasError = true + } + + logging.Info.Println("\nValidating options and change files...") + + // Check for untracked changes + untracked, _ := git.GetUntrackedChanges(options.Path) + if len(untracked) > 0 { + logging.Warn.Printf("There are untracked changes in your repository:\n%s\n", + logging.BulletedList(untracked)) + } + + packageInfos, err := monorepo.GetPackageInfos(options) + if err != nil { + return nil, fmt.Errorf("failed to get package infos: %w", err) + } + + if options.All && len(options.Package) > 0 { + logError("Cannot specify both \"all\" and \"package\" options") + } else if len(options.Package) > 0 { + var invalidReasons []string + for _, pkg := range options.Package { + info, ok := packageInfos[pkg] + if !ok { + invalidReasons = append(invalidReasons, fmt.Sprintf("%q was not found", pkg)) + } else if info.Private { + invalidReasons = append(invalidReasons, fmt.Sprintf("%q is marked as private", pkg)) + } + } + if len(invalidReasons) > 0 { + logError(fmt.Sprintf("Invalid package(s) specified:\n%s", logging.BulletedList(invalidReasons))) + } + } + + if options.AuthType != "" && !IsValidAuthType(options.AuthType) { + logError(fmt.Sprintf("authType %q is not valid", options.AuthType)) + } + + if options.Command == "publish" && options.Token != "" { + if options.Token == "" { + logError("token should not be an empty string") + } else if strings.HasPrefix(options.Token, "$") && options.AuthType != types.AuthTypePassword { + logError(fmt.Sprintf("token appears to be a variable reference: %q", options.Token)) + } + } + + if options.DependentChangeType != "" && !IsValidChangeType(options.DependentChangeType) { + logError(fmt.Sprintf("dependentChangeType %q is not valid", options.DependentChangeType)) + } + + if options.Type != "" && !IsValidChangeType(options.Type) { + logError(fmt.Sprintf("Change type %q is not valid", options.Type)) + } + + packageGroups := monorepo.GetPackageGroups(packageInfos, options.Path, options.Groups) + scopedPackages := monorepo.GetScopedPackages(options, packageInfos) + changeSet := changefile.ReadChangeFiles(options, packageInfos, scopedPackages) + + for _, entry := range changeSet { + disallowed := changefile.GetDisallowedChangeTypes(entry.Change.PackageName, packageInfos, packageGroups, options) + + changeType := entry.Change.Type + if changeType == "" { + logError(fmt.Sprintf("Change type is missing in %s", entry.ChangeFile)) + } else if !IsValidChangeType(changeType) { + logError(fmt.Sprintf("Invalid change type detected in %s: %q", entry.ChangeFile, changeType)) + } else { + if slices.Contains(disallowed, changeType) { + logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeType)) + } + } + + depType := entry.Change.DependentChangeType + if depType == "" { + logError(fmt.Sprintf("dependentChangeType is missing in %s", entry.ChangeFile)) + } else if !IsValidDependentChangeType(depType, disallowed) { + logError(fmt.Sprintf("Invalid dependentChangeType detected in %s: %q", entry.ChangeFile, depType)) + } + } + + if hasError { + return nil, fmt.Errorf("validation failed") + } + + result := &ValidationResult{ + PackageInfos: packageInfos, + PackageGroups: packageGroups, + ScopedPackages: scopedPackages, + ChangeSet: changeSet, + } + + if validateOpts.CheckChangeNeeded { + changedPackages, err := changefile.GetChangedPackages(options, packageInfos, scopedPackages) + if err != nil { + return nil, err + } + result.ChangedPackages = changedPackages + result.IsChangeNeeded = len(changedPackages) > 0 + + if result.IsChangeNeeded { + msg := "Found changes in the following packages" + if options.All { + msg = "Considering the following packages due to --all" + } else if len(options.Package) > 0 { + msg = "Considering the specific --package" + } + sorted := slices.Sorted(slices.Values(changedPackages)) + logging.Info.Printf("%s:\n%s\n", msg, logging.BulletedList(sorted)) + } + + if result.IsChangeNeeded && !validateOpts.AllowMissingChangeFiles { + logError("Change files are needed!") + logging.Info.Println(options.ChangeHint) + return nil, fmt.Errorf("change files needed") + } + + if options.DisallowDeletedChangeFiles && AreChangeFilesDeleted(options) { + logError("Change files must not be deleted!") + return nil, fmt.Errorf("change files deleted") + } + } + + logging.Info.Println() + + return result, nil +} diff --git a/go/internal/validation/validate_test.go b/go/internal/validation/validate_test.go new file mode 100644 index 000000000..ef12e4faa --- /dev/null +++ b/go/internal/validation/validate_test.go @@ -0,0 +1,78 @@ +package validation_test + +import ( + "testing" + + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" + "github.com/microsoft/beachball/internal/validation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// get default options for this file (fetch disabled) +func getDefaultOptions() types.BeachballOptions { + defaultOptions := types.DefaultOptions() + defaultOptions.Branch = testutil.DefaultRemoteBranch + defaultOptions.Fetch = false + + return defaultOptions +} + +// TS: "succeeds with no changes" +func TestSucceedsWithNoChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + result, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + }) + require.NoError(t, err) + assert.False(t, result.IsChangeNeeded) + assert.Contains(t, buf.String(), "Validating options and change files...") +} + +// TS: "exits with error by default if change files are needed" +func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/test.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + _, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + }) + assert.Error(t, err) + assert.Contains(t, buf.String(), "ERROR: Change files are needed!") + assert.Contains(t, buf.String(), "Found changes in the following packages") +} + +// TS: "returns and does not log an error if change files are needed and allowMissingChangeFiles is true" +func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", testutil.DefaultBranch) + repo.CommitChange("packages/foo/test.js") + + buf := testutil.CaptureLogging(t) + repoOpts := getDefaultOptions() + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + result, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + AllowMissingChangeFiles: true, + }) + require.NoError(t, err) + assert.True(t, result.IsChangeNeeded) + assert.NotContains(t, buf.String(), "ERROR:") +} diff --git a/go/internal/validation/validators.go b/go/internal/validation/validators.go new file mode 100644 index 000000000..933e19912 --- /dev/null +++ b/go/internal/validation/validators.go @@ -0,0 +1,30 @@ +package validation + +import ( + "slices" + + "github.com/microsoft/beachball/internal/types" +) + +var validAuthTypes = map[types.AuthType]bool{ + types.AuthTypeAuthToken: true, + types.AuthTypePassword: true, +} + +// IsValidAuthType checks if the auth type is valid. +func IsValidAuthType(authType types.AuthType) bool { + return validAuthTypes[authType] +} + +// IsValidChangeType checks if a change type is valid. +func IsValidChangeType(ct types.ChangeType) bool { + return types.IsValidChangeType(string(ct)) +} + +// IsValidDependentChangeType checks if a dependent change type is valid. +func IsValidDependentChangeType(ct types.ChangeType, disallowed []types.ChangeType) bool { + if !types.IsValidChangeType(string(ct)) { + return false + } + return !slices.Contains(disallowed, ct) +} diff --git a/package.json b/package.json index 52a89221c..a726ae2ca 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "checkchange": "node ./lib/cli.js check", "docs": "echo \"Run this from the docs folder instead\" && exit 1", "docs:build": "echo \"Run this from the docs folder instead\" && exit 1", - "format": "prettier --write '**/*'", + "format": "prettier --write .", + "format:check": "prettier --check .", "prepare": "husky install", "lint": "yarn lint:deps && yarn lint:code", "lint:code": "eslint --color --max-warnings=0 src", @@ -46,9 +47,9 @@ "update-snapshots": "yarn test:unit -u && yarn test:func -u && yarn test:e2e -u" }, "lint-staged": { - "*": [ - "prettier --write" - ] + "*.rs": "cd rust && rustfmt", + "*.go": "cd go && gofmt -w", + "*": "prettier --write" }, "dependencies": { "cosmiconfig": "^9.0.0", diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 000000000..e8921e41e --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,677 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "beachball" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "glob", + "globset", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "uuid", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b913a3b5fe84142e269d63cc62b64319ccaf89b748fc31fe025177f767a756c4" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..dc6a43e77 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "beachball" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +globset = "0.4" +glob = "0.3" +uuid = { version = "1", features = ["v4"] } +anyhow = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 000000000..00cde6c92 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,98 @@ +# Beachball (Rust) + +A Rust re-implementation of beachball's `check` and `change` commands. + + + +## Prerequisites + +- Rust 1.85+ (edition 2024 required by dependencies) +- `git` on PATH + +## Building + +```bash +cargo build +cargo build --release +``` + +## Formatting + +```bash +cargo fmt +``` + +To check without modifying (as CI does): + +```bash +cargo fmt --check +``` + +## Linting + +```bash +cargo clippy --all-targets +``` + +To treat warnings as errors (as CI does): + +```bash +cargo clippy --all-targets -- -D warnings +``` + +To fix (`--allow-dirty` to allow fixes with uncommitted changes): + +```bash +cargo clippy --all-targets --fix --allow-dirty -- -D warnings +``` + +**Note:** If VS Code shows stale warnings after fixing lint issues, run "Rust Analyzer: Restart Server" from the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). + +## Testing + +```bash +cargo test +``` + +Run a specific test: + +```bash +cargo test excludes_packages_with_existing_change_files +``` + +## Running + +```bash +cargo run -- check +cargo run -- change --type patch --message "my change" +``` + +Or after building: + +```bash +./target/debug/beachball check +./target/debug/beachball change -t minor -m "add feature" +``` + +## CLI Options + +Currently the `check` command and non-interactive `change` command have been implemented. Options should be the same as JS Beachball. + +## Project Structure + +``` +src/ + main.rs # CLI entry point + lib.rs # Module re-exports + types/ # Basic types + options/ # CLI parsing, config loading, option merging + git/ # Git operations (shell out to git) + monorepo/ # Package discovery, scoping, groups, filtering + changefile/ # Change file read/write, changed package detection + validation/ # Validation logic + commands/ # check and change commands + logging.rs # Output helpers +tests/ + common/ # Shared test helpers (repository, fixtures) + *_test.rs # Test files +``` diff --git a/rust/src/changefile/change_types.rs b/rust/src/changefile/change_types.rs new file mode 100644 index 000000000..47bea807b --- /dev/null +++ b/rust/src/changefile/change_types.rs @@ -0,0 +1,40 @@ +use crate::types::change_info::ChangeType; + +/// Check if a string is a valid change type. +pub fn is_valid_change_type(s: &str) -> bool { + s.parse::().is_ok() +} + +/// Check if a change type value is valid. +pub fn is_valid_change_type_value(ct: ChangeType) -> bool { + ChangeType::SORTED.contains(&ct) +} + +/// Get the disallowed change types for a package, considering package options, +/// group options, and repo-level options. +pub fn get_disallowed_change_types( + package_name: &str, + package_infos: &crate::types::package_info::PackageInfos, + package_groups: &crate::types::package_info::PackageGroups, + repo_disallowed: &Option>, +) -> Option> { + // Check if the package is in a group (group disallowedChangeTypes take precedence) + for group_info in package_groups.values() { + if group_info.package_names.contains(&package_name.to_string()) + && group_info.disallowed_change_types.is_some() + { + return group_info.disallowed_change_types.clone(); + } + } + + // Check package-level options + if let Some(info) = package_infos.get(package_name) + && let Some(ref opts) = info.package_options + && opts.disallowed_change_types.is_some() + { + return opts.disallowed_change_types.clone(); + } + + // Fall back to repo-level + repo_disallowed.clone() +} diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs new file mode 100644 index 000000000..55bfb4ecb --- /dev/null +++ b/rust/src/changefile/changed_packages.rs @@ -0,0 +1,253 @@ +use anyhow::Result; +use std::collections::HashSet; +use std::path::Path; + +use crate::git::commands; +use crate::git::ensure_shared_history::ensure_shared_history; +use crate::log_info; +use crate::log_verbose; +use crate::monorepo::filter_ignored::filter_ignored_files; +use crate::types::change_info::{ChangeFileInfo, ChangeInfoMultiple}; +use crate::types::options::BeachballOptions; +use crate::types::package_info::{PackageInfo, PackageInfos, ScopedPackages}; + +use super::read_change_files::get_change_path; + +/// Check whether a package should be included in changed packages. +fn is_package_included( + package_info: Option<&PackageInfo>, + scoped_packages: &ScopedPackages, +) -> (bool, String) { + match package_info { + None => (false, "no corresponding package found".to_string()), + Some(info) if info.private => (false, format!("{} is private", info.name)), + Some(info) + if info.package_options.as_ref().and_then(|o| o.should_publish) == Some(false) => + { + ( + false, + format!("{} has beachball.shouldPublish=false", info.name), + ) + } + Some(info) if !scoped_packages.contains(&info.name) => { + (false, format!("{} is out of scope", info.name)) + } + _ => (true, String::new()), + } +} + +/// Find which package a changed file belongs to by walking up directories. +fn get_matching_package<'a>( + file: &str, + cwd: &str, + packages_by_path: &'a std::collections::HashMap, +) -> Option<&'a PackageInfo> { + let cwd_path = Path::new(cwd); + let abs_file = cwd_path.join(file); + let mut dir = abs_file.parent()?; + + loop { + let dir_str = dir.to_string_lossy().to_string(); + if let Some(info) = packages_by_path.get(&dir_str) { + return Some(info); + } + if dir == cwd_path { + break; + } + dir = dir.parent()?; + } + None +} + +/// Get all changed packages regardless of existing change files. +fn get_all_changed_packages( + options: &BeachballOptions, + package_infos: &PackageInfos, + scoped_packages: &ScopedPackages, +) -> Result> { + let cwd = &options.path; + + // If --all, return all in-scope non-private packages + if options.all { + log_verbose!( + "--all option was provided, so including all packages that are in scope (regardless of changes)" + ); + let mut result: Vec = vec![]; + for pkg in package_infos.values() { + let (included, reason) = is_package_included(Some(pkg), scoped_packages); + if included { + log_verbose!(" - {}", pkg.name); + result.push(pkg.name.clone()); + } else { + let short_reason = reason + .strip_prefix(&format!("{} ", pkg.name)) + .unwrap_or(&reason); + log_verbose!(" - ~~{}~~ ({})", pkg.name, short_reason); + } + } + return Ok(result); + } + + log_info!("Checking for changes against \"{}\"", options.branch); + + ensure_shared_history(options)?; + + // With --relative, git returns paths relative to cwd (options.path). + // Canonicalize to resolve symlinks (e.g. macOS /tmp -> /private/tmp) so paths + // from git match paths from package_json_path. + let canonical_cwd = std::fs::canonicalize(cwd) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| cwd.to_string()); + + let mut changes = commands::get_branch_changes(&options.branch, cwd)?; + let staged = commands::get_staged_changes(cwd)?; + changes.extend(staged); + + { + let count = changes.len(); + log_verbose!( + "Found {} changed file{} in current branch (before filtering)", + count, + if count == 1 { "" } else { "s" } + ); + } + + if changes.is_empty() { + return Ok(vec![]); + } + + // Filter ignored files + let mut ignore_patterns: Vec = options.ignore_patterns.clone().unwrap_or_default(); + ignore_patterns.push(format!("{}/*.json", options.change_dir)); + ignore_patterns.push("CHANGELOG.{md,json}".to_string()); + + // For CHANGELOG matching, we need to handle the brace expansion manually + // since globset doesn't support {md,json} syntax the same way + let expanded_patterns: Vec = ignore_patterns + .iter() + .flat_map(|p| { + if p.contains('{') && p.contains('}') { + // Simple brace expansion for common case + let start = p.find('{').unwrap(); + let end = p.find('}').unwrap(); + let prefix = &p[..start]; + let suffix = &p[end + 1..]; + let alts = &p[start + 1..end]; + alts.split(',') + .map(|alt| format!("{prefix}{alt}{suffix}")) + .collect::>() + } else { + vec![p.clone()] + } + }) + .collect(); + + let non_ignored = filter_ignored_files(&changes, &expanded_patterns); + + if non_ignored.is_empty() { + log_verbose!("All files were ignored"); + return Ok(vec![]); + } + + // Build a map from package directory path to PackageInfo. + // Canonicalize paths to match the canonicalized git_root. + let mut packages_by_path: std::collections::HashMap = + std::collections::HashMap::new(); + for info in package_infos.values() { + let dir = Path::new(&info.package_json_path) + .parent() + .unwrap_or(Path::new(".")); + let canonical = std::fs::canonicalize(dir) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| dir.to_string_lossy().to_string()); + packages_by_path.insert(canonical, info); + } + + let mut included_packages: HashSet = HashSet::new(); + let mut file_count = 0; + + for file in &non_ignored { + let pkg_info = get_matching_package(file, &canonical_cwd, &packages_by_path); + let (included, reason) = is_package_included(pkg_info, scoped_packages); + + if !included { + log_verbose!(" - ~~{file}~~ ({reason})"); + } else { + included_packages.insert(pkg_info.unwrap().name.clone()); + file_count += 1; + log_verbose!(" - {file}"); + } + } + + { + let pkg_count = included_packages.len(); + log_verbose!( + "Found {} file{} in {} package{} that should be published", + file_count, + if file_count == 1 { "" } else { "s" }, + pkg_count, + if pkg_count == 1 { "" } else { "s" }, + ); + } + + Ok(included_packages.into_iter().collect()) +} + +/// Get changed packages that don't already have change files. +/// If `options.package` is set, return those as-is. +/// If `options.all` is set, return all in-scope packages. +pub fn get_changed_packages( + options: &BeachballOptions, + package_infos: &PackageInfos, + scoped_packages: &ScopedPackages, +) -> Result> { + // If --package is specified, return those names directly + if let Some(ref packages) = options.package { + return Ok(packages.clone()); + } + + let changed_packages = get_all_changed_packages(options, package_infos, scoped_packages)?; + + let change_path = get_change_path(options); + if !Path::new(&change_path).exists() { + return Ok(changed_packages); + } + + // Check which packages already have change files + let change_files = commands::get_changes_between_refs( + &options.branch, + Some("A"), + Some("*.json"), + &change_path, + ) + .unwrap_or_default(); + + let mut existing_packages: HashSet = HashSet::new(); + + for file in &change_files { + let file_path = Path::new(&change_path).join(file); + if let Ok(contents) = std::fs::read_to_string(&file_path) { + if let Ok(multi) = serde_json::from_str::(&contents) { + for change in &multi.changes { + existing_packages.insert(change.package_name.clone()); + } + } else if let Ok(single) = serde_json::from_str::(&contents) { + existing_packages.insert(single.package_name.clone()); + } + } + } + + if !existing_packages.is_empty() { + let mut sorted: Vec<&String> = existing_packages.iter().collect(); + sorted.sort(); + log_info!( + "Your local repository already has change files for these packages:\n{}", + crate::logging::bulleted_list(&sorted.iter().map(|s| s.as_str()).collect::>()) + ); + } + + Ok(changed_packages + .into_iter() + .filter(|pkg| !existing_packages.contains(pkg)) + .collect()) +} diff --git a/rust/src/changefile/mod.rs b/rust/src/changefile/mod.rs new file mode 100644 index 000000000..3935c9a25 --- /dev/null +++ b/rust/src/changefile/mod.rs @@ -0,0 +1,4 @@ +pub mod change_types; +pub mod changed_packages; +pub mod read_change_files; +pub mod write_change_files; diff --git a/rust/src/changefile/read_change_files.rs b/rust/src/changefile/read_change_files.rs new file mode 100644 index 000000000..db6332766 --- /dev/null +++ b/rust/src/changefile/read_change_files.rs @@ -0,0 +1,116 @@ +use std::path::Path; + +use crate::log_warn; +use crate::types::change_info::{ChangeFileInfo, ChangeInfoMultiple, ChangeSet, ChangeSetEntry}; +use crate::types::options::BeachballOptions; +use crate::types::package_info::{PackageInfos, ScopedPackages}; + +/// Get the path to the change files directory. +pub fn get_change_path(options: &BeachballOptions) -> String { + Path::new(&options.path) + .join(&options.change_dir) + .to_string_lossy() + .to_string() +} + +/// Read all change files from the change directory. +pub fn read_change_files( + options: &BeachballOptions, + package_infos: &PackageInfos, + scoped_packages: &ScopedPackages, +) -> ChangeSet { + let change_path = get_change_path(options); + let change_dir = Path::new(&change_path); + + if !change_dir.exists() { + return vec![]; + } + + let mut entries: Vec<(String, std::time::SystemTime)> = vec![]; + + if let Ok(dir_entries) = std::fs::read_dir(change_dir) { + for entry in dir_entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + let filename = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let mtime = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + entries.push((filename, mtime)); + } + } + } + + // Sort by mtime (most recent first) + entries.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut change_set = ChangeSet::new(); + + for (filename, _) in entries { + let file_path = change_dir.join(&filename); + let contents = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log_warn!("Error reading change file {filename}: {e}"); + continue; + } + }; + + // Try parsing as grouped first, then single + let changes: Vec = + if let Ok(multi) = serde_json::from_str::(&contents) { + multi.changes + } else if let Ok(single) = serde_json::from_str::(&contents) { + vec![single] + } else { + log_warn!( + "{} does not appear to be a change file", + file_path.display() + ); + continue; + }; + + for change in changes { + // Warn about nonexistent/private packages + let warning_type = if !package_infos.contains_key(&change.package_name) { + Some("nonexistent") + } else if package_infos[&change.package_name].private { + Some("private") + } else { + None + }; + + if let Some(wt) = warning_type { + let resolution = if options.group_changes { + "remove the entry from this file" + } else { + "delete this file" + }; + log_warn!( + "Change detected for {} package {}; {}: {}", + wt, + change.package_name, + resolution, + file_path.display() + ); + continue; + } + + if !scoped_packages.contains(&change.package_name) { + continue; + } + + change_set.push(ChangeSetEntry { + change, + change_file: filename.clone(), + }); + } + } + + change_set +} diff --git a/rust/src/changefile/write_change_files.rs b/rust/src/changefile/write_change_files.rs new file mode 100644 index 000000000..265b706fa --- /dev/null +++ b/rust/src/changefile/write_change_files.rs @@ -0,0 +1,88 @@ +use anyhow::Result; +use std::path::Path; + +use crate::git::commands; +use crate::log_info; +use crate::types::change_info::ChangeFileInfo; +use crate::types::options::BeachballOptions; + +use super::read_change_files::get_change_path; + +/// Write change files to disk, stage them, and optionally commit. +/// Returns the list of created file paths. +pub fn write_change_files( + changes: &[ChangeFileInfo], + options: &BeachballOptions, +) -> Result> { + if changes.is_empty() { + return Ok(vec![]); + } + + let change_path = get_change_path(options); + let cwd = &options.path; + + // Create directory if needed + if !Path::new(&change_path).exists() { + std::fs::create_dir_all(&change_path)?; + } + + let mut change_files: Vec = vec![]; + + if options.group_changes { + // Write all changes to a single grouped file + let uuid = uuid::Uuid::new_v4(); + let file_path = Path::new(&change_path) + .join(format!("change-{uuid}.json")) + .to_string_lossy() + .to_string(); + + let grouped = serde_json::json!({ "changes": changes }); + std::fs::write(&file_path, serde_json::to_string_pretty(&grouped)?)?; + change_files.push(file_path); + } else { + // Write each change to its own file + for change in changes { + let sanitized_name = change + .package_name + .replace(|c: char| !c.is_alphanumeric() && c != '@', "-"); + let uuid = uuid::Uuid::new_v4(); + let file_path = Path::new(&change_path) + .join(format!("{sanitized_name}-{uuid}.json")) + .to_string_lossy() + .to_string(); + + let json = serde_json::to_string_pretty(change)?; + std::fs::write(&file_path, json)?; + change_files.push(file_path); + } + } + + // Stage and maybe commit if in a git repo + if commands::get_branch_name(cwd)?.is_some() { + let file_refs: Vec<&str> = change_files.iter().map(|s| s.as_str()).collect(); + commands::stage(&file_refs, cwd)?; + + if options.commit { + let commit_pattern = Path::new(&change_path) + .join("*.json") + .to_string_lossy() + .to_string(); + commands::commit("Change files", cwd, &["--only", &commit_pattern])?; + } + } + + log_info!( + "git {} these change files:{}", + if options.commit { + "committed" + } else { + "staged" + }, + change_files + .iter() + .map(|f| format!("\n - {f}")) + .collect::() + ); + + Ok(change_files) +} diff --git a/rust/src/commands/change.rs b/rust/src/commands/change.rs new file mode 100644 index 000000000..30f6d886b --- /dev/null +++ b/rust/src/commands/change.rs @@ -0,0 +1,79 @@ +use anyhow::{Result, bail}; + +use crate::changefile::changed_packages::get_changed_packages; +use crate::changefile::write_change_files::write_change_files; +use crate::git::commands::get_user_email; +use crate::log_info; +use crate::types::change_info::{ChangeFileInfo, ChangeType}; +use crate::types::options::ParsedOptions; +use crate::validation::validate::{ValidateOptions, ValidationResult, validate}; + +/// Run the change command (non-interactive only). +/// Requires --type and --message to be specified. +pub fn change(parsed: &ParsedOptions) -> Result<()> { + let options = &parsed.options; + + let ValidationResult { + is_change_needed, + package_infos, + scoped_packages, + changed_packages, + .. + } = validate( + parsed, + &ValidateOptions { + check_change_needed: true, + allow_missing_change_files: true, + ..Default::default() + }, + )?; + + if !is_change_needed && options.package.is_none() { + log_info!("No change files are needed"); + return Ok(()); + } + + let changed = changed_packages.unwrap_or_else(|| { + get_changed_packages(options, &package_infos, &scoped_packages).unwrap_or_default() + }); + + if changed.is_empty() { + return Ok(()); + } + + // Non-interactive: require --type and --message + let change_type = match options.change_type { + Some(ct) => ct, + None => bail!("Non-interactive mode requires --type to be specified"), + }; + + if options.message.is_empty() { + bail!("Non-interactive mode requires --message (-m) to be specified"); + } + + let email = get_user_email(&options.path).unwrap_or_else(|| "email not defined".to_string()); + + let dependent_change_type = + options + .dependent_change_type + .unwrap_or(if change_type == ChangeType::None { + ChangeType::None + } else { + ChangeType::Patch + }); + + let changes: Vec = changed + .iter() + .map(|pkg| ChangeFileInfo { + change_type, + comment: options.message.clone(), + package_name: pkg.clone(), + email: email.clone(), + dependent_change_type, + }) + .collect(); + + write_change_files(&changes, options)?; + + Ok(()) +} diff --git a/rust/src/commands/check.rs b/rust/src/commands/check.rs new file mode 100644 index 000000000..1439ced89 --- /dev/null +++ b/rust/src/commands/check.rs @@ -0,0 +1,19 @@ +use anyhow::Result; + +use crate::log_info; +use crate::types::options::ParsedOptions; +use crate::validation::validate::{ValidateOptions, validate}; + +/// Run the check command: validate that change files are present where needed. +pub fn check(parsed: &ParsedOptions) -> Result<()> { + validate( + parsed, + &ValidateOptions { + check_change_needed: true, + check_dependencies: true, + ..Default::default() + }, + )?; + log_info!("No change files are needed"); + Ok(()) +} diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs new file mode 100644 index 000000000..f954c289d --- /dev/null +++ b/rust/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod change; +pub mod check; diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs new file mode 100644 index 000000000..6476f4050 --- /dev/null +++ b/rust/src/git/commands.rs @@ -0,0 +1,447 @@ +use anyhow::{Context, Result, bail}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Result of running a git command. +#[derive(Debug)] +pub struct GitResult { + pub success: bool, + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +/// Run a git command and return the result. +pub fn git(args: &[&str], cwd: &str) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .with_context(|| format!("failed to run git {}", args.join(" ")))?; + + Ok(GitResult { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + exit_code: output.status.code().unwrap_or(-1), + }) +} + +/// Run a git command and return stdout, failing if the command fails. +pub fn git_stdout(args: &[&str], cwd: &str) -> Result { + let result = git(args, cwd)?; + if !result.success { + bail!( + "git {} failed (exit {}): {}", + args.join(" "), + result.exit_code, + result.stderr + ); + } + Ok(result.stdout) +} + +/// Find the git root directory. +pub fn find_git_root(cwd: &str) -> Result { + git_stdout(&["rev-parse", "--show-toplevel"], cwd) +} + +/// Workspace/monorepo manager config files, in precedence order matching workspace-tools. +pub const MANAGER_FILES: &[&str] = &[ + "lerna.json", + "rush.json", + "yarn.lock", + "pnpm-workspace.yaml", + "package-lock.json", +]; + +/// Walk up the directory tree from `cwd` looking for any of the given files. +/// Returns the full path of the first match, or None if not found. +pub fn search_up(files: &[&str], cwd: &str) -> Option { + let mut dir = Path::new(cwd).to_path_buf(); + if let Ok(abs) = std::fs::canonicalize(&dir) { + dir = abs; + } + + loop { + for f in files { + let candidate = dir.join(f); + if candidate.exists() { + return Some(candidate); + } + } + if !dir.pop() { + break; + } + } + None +} + +/// Find the project root by searching up for workspace manager config files, +/// falling back to the git root. Matches workspace-tools findProjectRoot. +pub fn find_project_root(cwd: &str) -> Result { + if let Some(found) = search_up(MANAGER_FILES, cwd) + && let Some(parent) = found.parent() + { + return Ok(parent.to_string_lossy().to_string()); + } + find_git_root(cwd) +} + +/// Get the current branch name. +pub fn get_branch_name(cwd: &str) -> Result> { + let result = git(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)?; + if result.success { + Ok(Some(result.stdout)) + } else { + Ok(None) + } +} + +/// Get the user's email from git config. +pub fn get_user_email(cwd: &str) -> Option { + git_stdout(&["config", "user.email"], cwd).ok() +} + +/// Get files changed between the current branch and the target branch. +pub fn get_branch_changes(branch: &str, cwd: &str) -> Result> { + let result = git( + &[ + "--no-pager", + "diff", + "--name-only", + "--relative", + &format!("{branch}..."), + ], + cwd, + )?; + if !result.success { + return Ok(vec![]); + } + Ok(process_git_output(&result.stdout)) +} + +/// Get staged changes. +pub fn get_staged_changes(cwd: &str) -> Result> { + let result = git( + &[ + "--no-pager", + "diff", + "--staged", + "--name-only", + "--relative", + ], + cwd, + )?; + if !result.success { + return Ok(vec![]); + } + Ok(process_git_output(&result.stdout)) +} + +/// Get changes between two refs, optionally filtering by pattern and diff filter. +pub fn get_changes_between_refs( + from_ref: &str, + diff_filter: Option<&str>, + pattern: Option<&str>, + cwd: &str, +) -> Result> { + let diff_flag = diff_filter.map(|f| format!("--diff-filter={f}")); + let range = format!("{from_ref}..."); + let mut args: Vec<&str> = vec!["--no-pager", "diff", "--name-only", "--relative"]; + if let Some(ref flag) = diff_flag { + args.push(flag); + } + args.push(&range); + if let Some(pat) = pattern { + args.push("--"); + args.push(pat); + } + + let result = git(&args, cwd)?; + if !result.success { + return Ok(vec![]); + } + Ok(process_git_output(&result.stdout)) +} + +/// Get untracked files. +pub fn get_untracked_changes(cwd: &str) -> Result> { + let result = git(&["ls-files", "--others", "--exclude-standard"], cwd)?; + Ok(process_git_output(&result.stdout)) +} + +/// Stage files. +pub fn stage(patterns: &[&str], cwd: &str) -> Result<()> { + let mut args = vec!["add"]; + args.extend(patterns); + git_stdout(&args, cwd)?; + Ok(()) +} + +/// Commit with a message. Extra options can be passed (e.g. --only path). +pub fn commit(message: &str, cwd: &str, extra_options: &[&str]) -> Result<()> { + let mut args = vec!["commit", "-m", message]; + args.extend(extra_options); + git_stdout(&args, cwd)?; + Ok(()) +} + +/// Check if a ref exists. +pub fn rev_parse_verify(reference: &str, cwd: &str) -> bool { + git(&["rev-parse", "--verify", reference], cwd) + .map(|r| r.success) + .unwrap_or(false) +} + +/// Check if the repository is a shallow clone. +pub fn is_shallow_repository(cwd: &str) -> bool { + git_stdout(&["rev-parse", "--is-shallow-repository"], cwd) + .map(|s| s == "true") + .unwrap_or(false) +} + +/// Find the merge base of two refs. +pub fn merge_base(ref1: &str, ref2: &str, cwd: &str) -> Result> { + let result = git(&["merge-base", ref1, ref2], cwd)?; + if result.success { + Ok(Some(result.stdout)) + } else { + Ok(None) + } +} + +/// Parse a remote branch string like "origin/main" into (remote, branch). +pub fn parse_remote_branch(branch: &str) -> Option<(String, String)> { + let slash_pos = branch.find('/')?; + Some(( + branch[..slash_pos].to_string(), + branch[slash_pos + 1..].to_string(), + )) +} + +/// Returns the default remote name, matching workspace-tools getDefaultRemote. +/// +/// The order of preference is: +/// 1. If `repository` is defined in package.json at the git root, the remote with a matching URL +/// 2. "upstream" if defined +/// 3. "origin" if defined +/// 4. The first defined remote +/// 5. "origin" as final fallback +/// +/// Note: ADO/VSO URL formats are not currently handled by `get_repository_name`. +/// This is probably fine since usage of forks with ADO is uncommon. +pub fn get_default_remote(cwd: &str) -> String { + let git_root = match find_git_root(cwd) { + Ok(root) => root, + Err(_) => return "origin".to_string(), + }; + + // Read package.json at git root for repository field + let mut repository_name = String::new(); + let package_json_path = Path::new(&git_root).join("package.json"); + match std::fs::read_to_string(&package_json_path) { + Err(_) => { + crate::log_info!( + r#"Valid "repository" key not found in "{}". Consider adding this info for more accurate git remote detection."#, + package_json_path.display() + ); + } + Ok(contents) => { + if let Ok(pkg) = serde_json::from_str::(&contents) { + let repo_url = match pkg.get("repository") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(obj) => obj + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + None => String::new(), + }; + if repo_url.is_empty() { + crate::log_info!( + r#"Valid "repository" key not found in "{}". Consider adding this info for more accurate git remote detection."#, + package_json_path.display() + ); + } else { + repository_name = get_repository_name(&repo_url); + } + } else { + crate::log_info!( + r#"Valid "repository" key not found in "{}". Consider adding this info for more accurate git remote detection."#, + package_json_path.display() + ); + } + } + } + + // Get remotes with URLs + let remotes_result = match git(&["remote", "-v"], cwd) { + Ok(r) if r.success => r, + _ => return "origin".to_string(), + }; + + // Build mapping from repository name → remote name + let mut remotes_by_repo_name: std::collections::HashMap = + std::collections::HashMap::new(); + let mut all_remote_names: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for line in remotes_result.stdout.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 2 { + continue; + } + let remote_name = fields[0]; + let remote_url = fields[1]; + let repo_name = get_repository_name(remote_url); + if !repo_name.is_empty() { + remotes_by_repo_name.insert(repo_name, remote_name.to_string()); + } + if seen.insert(remote_name.to_string()) { + all_remote_names.push(remote_name.to_string()); + } + } + + // 1. Match by repository name from package.json + if !repository_name.is_empty() + && let Some(matched) = remotes_by_repo_name.get(&repository_name) + { + return matched.clone(); + } + + // 2-4. Fall back to upstream > origin > first + if all_remote_names.iter().any(|r| r == "upstream") { + return "upstream".to_string(); + } + if all_remote_names.iter().any(|r| r == "origin") { + return "origin".to_string(); + } + if let Some(first) = all_remote_names.first() { + return first.clone(); + } + + "origin".to_string() +} + +/// Extracts the "owner/repo" full name from a git URL. +/// Handles HTTPS, SSH, git://, and shorthand (github:owner/repo) formats. +/// +/// Note: Azure DevOps and Visual Studio Online URL formats are not currently handled. +/// Those would require more complex parsing similar to workspace-tools' git-url-parse usage. +pub fn get_repository_name(raw_url: &str) -> String { + if raw_url.is_empty() { + return String::new(); + } + + // SSH format: git@github.com:owner/repo.git or user@host:path + if let Some(colon_pos) = raw_url.find(':') + && raw_url[..colon_pos].contains('@') + && !raw_url[..colon_pos].contains('/') + { + let path = &raw_url[colon_pos + 1..]; + // Skip if path starts with / (would be ssh://user@host/path, not SCP syntax) + if !path.starts_with('/') { + return path.trim_end_matches(".git").to_string(); + } + } + + // Shorthand format: github:owner/repo (scheme without //) + if let Some(colon_pos) = raw_url.find(':') { + let scheme = &raw_url[..colon_pos]; + let rest = &raw_url[colon_pos + 1..]; + if scheme.chars().all(|c| c.is_ascii_lowercase()) + && !rest.starts_with("//") + && !rest.is_empty() + { + return rest.trim_end_matches(".git").to_string(); + } + } + + // HTTPS or git:// format — parse as URL + if let Some(path_start) = raw_url.find("://") { + let after_scheme = &raw_url[path_start + 3..]; + // Find the path part (after host) + if let Some(slash_pos) = after_scheme.find('/') { + let path = &after_scheme[slash_pos + 1..]; + let path = path.trim_end_matches(".git"); + if !path.is_empty() { + return path.to_string(); + } + } + } + + String::new() +} + +/// Get the default remote branch (tries to detect from git remote). +/// If `branch` is Some, combines it with the default remote. +/// If `branch` is None, detects the remote's HEAD branch, falling back to +/// git config init.defaultBranch or "master". +pub fn get_default_remote_branch(cwd: &str) -> Result { + let remote = get_default_remote(cwd); + + // Try to detect HEAD branch from remote + if let Ok(r) = git(&["remote", "show", &remote], cwd) + && r.success + { + for line in r.stdout.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("HEAD branch:") { + let branch = rest.trim(); + return Ok(format!("{remote}/{branch}")); + } + } + } + + // Fallback: try git config init.defaultBranch + if let Ok(default_branch) = git_stdout(&["config", "init.defaultBranch"], cwd) { + return Ok(format!("{remote}/{default_branch}")); + } + + Ok(format!("{remote}/master")) +} + +/// Get the default remote branch with a specific branch name. +/// Combines the branch with the default remote name. +pub fn get_default_remote_branch_for(cwd: &str, branch: &str) -> String { + let remote = get_default_remote(cwd); + format!("{remote}/{branch}") +} + +/// List all tracked files matching a pattern. +pub fn list_tracked_files(pattern: &str, cwd: &str) -> Result> { + let result = git(&["ls-files", pattern], cwd)?; + Ok(process_git_output(&result.stdout)) +} + +/// Fetch a branch from a remote. +pub fn fetch(remote: &str, branch: &str, cwd: &str, depth: Option) -> Result<()> { + let mut args = vec!["fetch".to_string(), "--".to_string(), remote.to_string()]; + if let Some(d) = depth { + args.push(format!("--depth={d}")); + } + args.push(branch.to_string()); + + let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let result = git(&str_args, cwd)?; + if !result.success { + bail!( + "Fetching branch \"{branch}\" from remote \"{remote}\" failed: {}", + result.stderr + ); + } + Ok(()) +} + +/// Splits git output into lines, trims whitespace, and filters out +/// empty lines and node_modules paths. Matches workspace-tools processGitOutput +/// with excludeNodeModules: true. +fn process_git_output(stdout: &str) -> Vec { + stdout + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.contains("node_modules")) + .map(str::to_string) + .collect() +} diff --git a/rust/src/git/ensure_shared_history.rs b/rust/src/git/ensure_shared_history.rs new file mode 100644 index 000000000..5f9407a5e --- /dev/null +++ b/rust/src/git/ensure_shared_history.rs @@ -0,0 +1,54 @@ +use anyhow::{Result, bail}; + +use crate::log_info; +use crate::types::options::BeachballOptions; + +use super::commands; + +/// Ensure the local repo has shared history with the remote branch for accurate diffing. +pub fn ensure_shared_history(options: &BeachballOptions) -> Result<()> { + let cwd = &options.path; + let branch = &options.branch; + + // Check if the branch ref exists locally + if commands::rev_parse_verify(branch, cwd) { + // Check if we have shared history + if commands::merge_base(branch, "HEAD", cwd)?.is_some() { + return Ok(()); + } + } + + if !options.fetch { + if !commands::rev_parse_verify(branch, cwd) { + bail!("Branch \"{branch}\" does not exist locally and --no-fetch was specified"); + } + return Ok(()); + } + + // Parse remote/branch + let (remote, branch_name) = commands::parse_remote_branch(branch) + .unwrap_or_else(|| ("origin".to_string(), branch.to_string())); + + // Fetch the branch + if options.verbose { + log_info!("Fetching {branch_name} from {remote}..."); + } + commands::fetch(&remote, &branch_name, cwd, options.depth)?; + + // If shallow, try to deepen until we have shared history + if commands::is_shallow_repository(cwd) { + let mut current_depth = options.depth.unwrap_or(100); + for _ in 0..5 { + if commands::merge_base(branch, "HEAD", cwd)?.is_some() { + return Ok(()); + } + current_depth *= 2; + if options.verbose { + log_info!("Deepening fetch to {current_depth}..."); + } + commands::fetch(&remote, &branch_name, cwd, Some(current_depth))?; + } + } + + Ok(()) +} diff --git a/rust/src/git/mod.rs b/rust/src/git/mod.rs new file mode 100644 index 000000000..3fed4de5c --- /dev/null +++ b/rust/src/git/mod.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod ensure_shared_history; diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 000000000..ec553dc28 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,8 @@ +pub mod changefile; +pub mod commands; +pub mod git; +pub mod logging; +pub mod monorepo; +pub mod options; +pub mod types; +pub mod validation; diff --git a/rust/src/logging.rs b/rust/src/logging.rs new file mode 100644 index 000000000..770391562 --- /dev/null +++ b/rust/src/logging.rs @@ -0,0 +1,132 @@ +// User-facing logging with test capture support. +// +// All CLI output goes through log_info!/log_warn!/log_error!/log_verbose! macros +// instead of println!/eprintln! directly. This allows tests to capture and assert +// on output (matching the TS tests' jest.spyOn(console, ...) pattern). +// +// We use thread-local storage rather than a global Mutex so that Rust's parallel +// test runner works correctly — each test thread gets its own independent capture +// buffer with no cross-test interference. +// +// We don't use the `log` crate because this is user-facing CLI output (not +// diagnostic logging), and env_logger would add unwanted formatting (timestamps, +// module paths, etc.). + +use std::cell::RefCell; +use std::io::Write; + +thread_local! { + // When Some, output is captured into the buffer. When None, output goes to stdout/stderr. + static LOG_CAPTURE: RefCell>> = const { RefCell::new(None) }; + // Whether verbose output is enabled (for log_verbose! macro). + static VERBOSE_ENABLED: RefCell = const { RefCell::new(false) }; +} + +/// Start capturing log output on the current thread (verbose disabled). +pub fn set_output() { + LOG_CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new())); + VERBOSE_ENABLED.with(|v| *v.borrow_mut() = false); +} + +/// Start capturing log output on the current thread with verbose enabled. +pub fn set_output_verbose() { + LOG_CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new())); + VERBOSE_ENABLED.with(|v| *v.borrow_mut() = true); +} + +/// Enable verbose output (for CLI --verbose flag). +pub fn enable_verbose() { + VERBOSE_ENABLED.with(|v| *v.borrow_mut() = true); +} + +/// Stop capturing and restore default stdout/stderr output. +pub fn reset() { + LOG_CAPTURE.with(|c| *c.borrow_mut() = None); + VERBOSE_ENABLED.with(|v| *v.borrow_mut() = false); +} + +/// Get captured log output as a string. +pub fn get_output() -> String { + LOG_CAPTURE.with(|c| { + let borrow = c.borrow(); + match &*borrow { + Some(buf) => String::from_utf8_lossy(buf).into_owned(), + None => String::new(), + } + }) +} + +pub enum Level { + Info, + Warn, + Error, + Verbose, +} + +pub fn write_log(level: Level, msg: &str) { + // Verbose: check if enabled, skip if not + if matches!(level, Level::Verbose) { + let enabled = VERBOSE_ENABLED.with(|v| *v.borrow()); + if !enabled { + return; + } + } + + LOG_CAPTURE.with(|c| { + let mut borrow = c.borrow_mut(); + if let Some(ref mut buf) = *borrow { + match level { + Level::Info | Level::Verbose => writeln!(buf, "{msg}").ok(), + Level::Warn => writeln!(buf, "WARN: {msg}").ok(), + Level::Error => writeln!(buf, "ERROR: {msg}").ok(), + }; + return; + } + drop(borrow); + match level { + Level::Info | Level::Verbose => println!("{msg}"), + Level::Warn => eprintln!("WARN: {msg}"), + Level::Error => eprintln!("ERROR: {msg}"), + } + }); +} + +#[macro_export] +macro_rules! log_info { + () => { + $crate::logging::write_log($crate::logging::Level::Info, "") + }; + ($($arg:tt)*) => { + $crate::logging::write_log($crate::logging::Level::Info, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { + $crate::logging::write_log($crate::logging::Level::Warn, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_error { + ($($arg:tt)*) => { + $crate::logging::write_log($crate::logging::Level::Error, &format!($($arg)*)) + }; +} + +#[macro_export] +macro_rules! log_verbose { + ($($arg:tt)*) => { + $crate::logging::write_log($crate::logging::Level::Verbose, &format!($($arg)*)) + }; +} + +/// Format items as a bulleted list. +pub fn bulleted_list(items: &[&str]) -> String { + items + .iter() + .map(|item| format!(" - {item}")) + .collect::>() + .join("\n") +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 000000000..5779b1090 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,46 @@ +use std::process; + +use beachball::git::commands::find_git_root; +use beachball::options::cli_options::get_cli_options; +use beachball::options::get_options::get_parsed_options; + +fn main() { + let cwd = std::env::current_dir() + .expect("failed to get current directory") + .to_string_lossy() + .to_string(); + + if find_git_root(&cwd).is_err() { + eprintln!("beachball only works in a git repository. Please initialize git and try again."); + process::exit(1); + } + + let cli = get_cli_options(); + let parsed = match get_parsed_options(&cwd, cli) { + Ok(p) => p, + Err(e) => { + eprintln!("Error parsing options: {e}"); + process::exit(1); + } + }; + + let result = match parsed.options.command.as_str() { + "check" => beachball::commands::check::check(&parsed), + "change" => beachball::commands::change::change(&parsed), + other => { + eprintln!("Invalid command: {other}"); + process::exit(1); + } + }; + + if let Err(e) = result { + if e.downcast_ref::() + .is_some() + { + process::exit(1); + } + eprintln!("An error has been detected while running beachball!"); + eprintln!("{e:#}"); + process::exit(1); + } +} diff --git a/rust/src/monorepo/filter_ignored.rs b/rust/src/monorepo/filter_ignored.rs new file mode 100644 index 000000000..93464755c --- /dev/null +++ b/rust/src/monorepo/filter_ignored.rs @@ -0,0 +1,20 @@ +use super::path_included::match_with_base; +use crate::log_verbose; + +/// Filter out file paths that match any of the ignore patterns. +/// Uses matchBase: true behavior (patterns without '/' match against basename). +pub fn filter_ignored_files(file_paths: &[String], ignore_patterns: &[String]) -> Vec { + file_paths + .iter() + .filter(|path| { + for pattern in ignore_patterns { + if match_with_base(path, pattern) { + log_verbose!(" - ~~{path}~~ (ignored by pattern \"{pattern}\")"); + return false; + } + } + true + }) + .cloned() + .collect() +} diff --git a/rust/src/monorepo/mod.rs b/rust/src/monorepo/mod.rs new file mode 100644 index 000000000..d710495da --- /dev/null +++ b/rust/src/monorepo/mod.rs @@ -0,0 +1,6 @@ +pub mod filter_ignored; +pub mod package_groups; +pub mod package_infos; +pub mod path_included; +pub mod scoped_packages; +pub mod workspace_manager; diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs new file mode 100644 index 000000000..5fd44c6f8 --- /dev/null +++ b/rust/src/monorepo/package_groups.rs @@ -0,0 +1,78 @@ +use anyhow::{Result, bail}; + +use crate::log_error; +use crate::types::options::VersionGroupInclude; +use crate::types::package_info::{PackageGroupInfo, PackageGroups, PackageInfos}; + +use super::package_infos::get_package_rel_path; +use super::path_included::is_path_included; + +/// Build package groups from the groups config. +pub fn get_package_groups( + package_infos: &PackageInfos, + root: &str, + groups: &Option>, +) -> Result { + let groups = match groups { + Some(g) => g, + None => return Ok(PackageGroups::new()), + }; + + let mut result = PackageGroups::new(); + // Track which group each package belongs to (for multi-group detection) + let mut package_to_group: std::collections::HashMap = + std::collections::HashMap::new(); + + for group in groups { + let mut package_names = vec![]; + + for info in package_infos.values() { + let rel_path = get_package_rel_path(info, root); + + let included = match &group.include { + VersionGroupInclude::All => true, + VersionGroupInclude::Patterns(patterns) => is_path_included(&rel_path, patterns), + }; + + if !included { + continue; + } + + // Check exclude patterns + if let Some(ref exclude) = group.exclude + && !is_path_included(&rel_path, exclude) + { + continue; + } + + // Check for multi-group membership + if let Some(existing_group) = package_to_group.get(&info.name) { + log_error!( + "Found package(s) belonging to multiple groups:\n - {}: {}, {}", + info.name, + existing_group, + group.name + ); + bail!( + "Package \"{}\" belongs to multiple groups: \"{}\" and \"{}\"", + info.name, + existing_group, + group.name + ); + } + + package_to_group.insert(info.name.clone(), group.name.clone()); + package_names.push(info.name.clone()); + } + + result.insert( + group.name.clone(), + PackageGroupInfo { + package_names, + disallowed_change_types: group.disallowed_change_types.clone(), + }, + ); + } + + Ok(result) +} diff --git a/rust/src/monorepo/package_infos.rs b/rust/src/monorepo/package_infos.rs new file mode 100644 index 000000000..ad3f07a44 --- /dev/null +++ b/rust/src/monorepo/package_infos.rs @@ -0,0 +1,137 @@ +use anyhow::{Result, bail}; +use std::path::{Path, PathBuf}; + +use crate::log_error; +use crate::monorepo::workspace_manager::{detect_workspace_manager, get_workspace_patterns}; +use crate::types::options::BeachballOptions; +use crate::types::package_info::{PackageInfo, PackageInfos, PackageJson, PackageOptions}; + +/// Get package infos for all packages in the project. +pub fn get_package_infos(options: &BeachballOptions) -> Result { + let cwd = &options.path; + let mut infos = PackageInfos::new(); + + let manager = detect_workspace_manager(cwd); + let (patterns, literal) = get_workspace_patterns(cwd, manager); + + if patterns.is_empty() { + // Single package repo + let root_pkg_path = Path::new(cwd).join("package.json"); + if !root_pkg_path.exists() { + bail!("No package.json found at {cwd}"); + } + let info = read_package_info(&root_pkg_path)?; + let name = info.name.clone(); + infos.insert(name, info); + return Ok(infos); + } + + // Monorepo: add root package if it exists + let root_pkg_path = Path::new(cwd).join("package.json"); + if root_pkg_path.exists() + && let Ok(info) = read_package_info(&root_pkg_path) + && !info.name.is_empty() + { + let name = info.name.clone(); + infos.insert(name, info); + } + + if literal { + // Rush: patterns are literal paths + for p in &patterns { + let pkg_json_path = Path::new(cwd).join(p).join("package.json"); + if pkg_json_path.exists() { + add_package_info(&mut infos, &pkg_json_path)?; + } + } + } else { + // Glob-based managers (npm, yarn, pnpm, lerna) + for pattern in &patterns { + let full_pattern = Path::new(cwd).join(pattern); + let pattern_str = full_pattern.to_string_lossy().to_string(); + + let entries = glob::glob(&pattern_str) + .map_err(|e| anyhow::anyhow!("invalid glob pattern {pattern_str}: {e}"))?; + + for entry in entries.flatten() { + let pkg_json_path = entry.join("package.json"); + let path_str = pkg_json_path.to_string_lossy(); + if path_str.contains("node_modules") || path_str.contains("__fixtures__") { + continue; + } + if pkg_json_path.exists() { + add_package_info(&mut infos, &pkg_json_path)?; + } + } + } + } + + // Apply package-level options from CLI if needed + apply_package_options(&mut infos, options); + + Ok(infos) +} + +fn add_package_info(infos: &mut PackageInfos, pkg_json_path: &PathBuf) -> Result<()> { + if let Ok(info) = read_package_info(pkg_json_path) { + if infos.contains_key(&info.name) { + log_error!( + "Two packages have the same name \"{}\". Please rename one of these packages:\n{}", + info.name, + crate::logging::bulleted_list(&[ + &infos[&info.name].package_json_path, + &info.package_json_path, + ]) + ); + bail!( + "Duplicate package name \"{}\" found at {} and {}", + info.name, + infos[&info.name].package_json_path, + info.package_json_path + ); + } + let name = info.name.clone(); + infos.insert(name, info); + } + Ok(()) +} + +fn read_package_info(pkg_json_path: &PathBuf) -> Result { + let contents = std::fs::read_to_string(pkg_json_path)?; + let pkg: PackageJson = serde_json::from_str(&contents)?; + + let package_options = pkg + .beachball + .as_ref() + .and_then(|bb| serde_json::from_value::(bb.clone()).ok()); + + Ok(PackageInfo { + name: pkg.name.clone(), + package_json_path: pkg_json_path.to_string_lossy().to_string(), + version: pkg.version, + dependencies: pkg.dependencies, + dev_dependencies: pkg.dev_dependencies, + peer_dependencies: pkg.peer_dependencies, + optional_dependencies: pkg.optional_dependencies, + private: pkg.private.unwrap_or(false), + package_options, + }) +} + +fn apply_package_options(_infos: &mut PackageInfos, _options: &BeachballOptions) { + // CLI-level disallowedChangeTypes etc. are applied during validation, not here. + // Package-level options are already read from the beachball field. +} + +/// Get the relative path of a package directory from the root. +pub fn get_package_rel_path(package_info: &PackageInfo, root: &str) -> String { + let pkg_dir = Path::new(&package_info.package_json_path) + .parent() + .unwrap_or(Path::new(".")); + let root_path = Path::new(root); + pkg_dir + .strip_prefix(root_path) + .unwrap_or(pkg_dir) + .to_string_lossy() + .replace('\\', "/") +} diff --git a/rust/src/monorepo/path_included.rs b/rust/src/monorepo/path_included.rs new file mode 100644 index 000000000..67394b7ee --- /dev/null +++ b/rust/src/monorepo/path_included.rs @@ -0,0 +1,57 @@ +use globset::{Glob, GlobMatcher}; + +/// Check if a relative path is included by the given scope patterns. +/// Supports negation patterns (starting with '!'). +/// Uses full-path matching (no matchBase). +pub fn is_path_included(rel_path: &str, patterns: &[String]) -> bool { + let mut included = false; + let mut has_positive = false; + + for pattern in patterns { + if let Some(neg_pattern) = pattern.strip_prefix('!') { + // Negation pattern: exclude if matches + if let Ok(glob) = Glob::new(neg_pattern) { + let matcher = glob.compile_matcher(); + if matcher.is_match(rel_path) { + return false; + } + } + } else { + has_positive = true; + if let Ok(glob) = Glob::new(pattern) { + let matcher = glob.compile_matcher(); + if matcher.is_match(rel_path) { + included = true; + } + } + } + } + + // If no positive patterns, everything is included (only negations apply) + if !has_positive { + return true; + } + + included +} + +/// Match a file path against a pattern with matchBase behavior: +/// patterns without '/' match against the basename only. +pub fn match_with_base(path: &str, pattern: &str) -> bool { + // If pattern contains no path separator, match against basename + if !pattern.contains('/') { + let basename = path.rsplit('/').next().unwrap_or(path); + if let Ok(glob) = Glob::new(pattern) { + let matcher: GlobMatcher = glob.compile_matcher(); + return matcher.is_match(basename); + } + return false; + } + + // Otherwise match against the full path + if let Ok(glob) = Glob::new(pattern) { + let matcher: GlobMatcher = glob.compile_matcher(); + return matcher.is_match(path); + } + false +} diff --git a/rust/src/monorepo/scoped_packages.rs b/rust/src/monorepo/scoped_packages.rs new file mode 100644 index 000000000..ff3a0d824 --- /dev/null +++ b/rust/src/monorepo/scoped_packages.rs @@ -0,0 +1,28 @@ +use crate::types::options::BeachballOptions; +use crate::types::package_info::{PackageInfos, ScopedPackages}; + +use super::package_infos::get_package_rel_path; +use super::path_included::is_path_included; + +/// Get the set of packages that are in scope based on scope patterns. +pub fn get_scoped_packages( + options: &BeachballOptions, + package_infos: &PackageInfos, +) -> ScopedPackages { + let scope = match &options.scope { + Some(s) if !s.is_empty() => s, + _ => { + // No scope filtering: return all package names + return package_infos.keys().cloned().collect(); + } + }; + + package_infos + .values() + .filter(|info| { + let rel_path = get_package_rel_path(info, &options.path); + is_path_included(&rel_path, scope) + }) + .map(|info| info.name.clone()) + .collect() +} diff --git a/rust/src/monorepo/workspace_manager.rs b/rust/src/monorepo/workspace_manager.rs new file mode 100644 index 000000000..879a70a51 --- /dev/null +++ b/rust/src/monorepo/workspace_manager.rs @@ -0,0 +1,158 @@ +use std::path::Path; + +use crate::git::commands::MANAGER_FILES; + +/// Workspace/monorepo manager type. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WorkspaceManager { + Npm, + Yarn, + Pnpm, + Lerna, + Rush, +} + +/// Map from manager config file name to manager type. +fn manager_for_file(filename: &str) -> WorkspaceManager { + match filename { + "lerna.json" => WorkspaceManager::Lerna, + "rush.json" => WorkspaceManager::Rush, + "yarn.lock" => WorkspaceManager::Yarn, + "pnpm-workspace.yaml" => WorkspaceManager::Pnpm, + "package-lock.json" => WorkspaceManager::Npm, + _ => WorkspaceManager::Npm, + } +} + +/// Detect the workspace manager by checking for config files in precedence order. +pub fn detect_workspace_manager(root: &str) -> WorkspaceManager { + let root_path = Path::new(root); + for file in MANAGER_FILES { + if root_path.join(file).exists() { + return manager_for_file(file); + } + } + WorkspaceManager::Npm +} + +/// Get workspace patterns for the detected manager. +/// Returns (patterns, is_literal) where is_literal=true means paths not globs (rush). +pub fn get_workspace_patterns(root: &str, manager: WorkspaceManager) -> (Vec, bool) { + match manager { + WorkspaceManager::Pnpm => (get_pnpm_patterns(root), false), + WorkspaceManager::Lerna => (get_lerna_patterns(root), false), + WorkspaceManager::Rush => (get_rush_paths(root), true), + _ => (get_npm_yarn_patterns(root), false), + } +} + +/// Read workspace patterns from package.json workspaces field (npm/yarn). +fn get_npm_yarn_patterns(root: &str) -> Vec { + let pkg_path = Path::new(root).join("package.json"); + let data = match std::fs::read_to_string(&pkg_path) { + Ok(d) => d, + Err(_) => return vec![], + }; + + // Try array format: "workspaces": ["packages/*"] + #[derive(serde::Deserialize)] + struct ArrayFormat { + workspaces: Option>, + } + if let Ok(parsed) = serde_json::from_str::(&data) + && let Some(ws) = parsed.workspaces + && !ws.is_empty() + { + return ws; + } + + // Try object format: "workspaces": {"packages": ["packages/*"]} + #[derive(serde::Deserialize)] + struct ObjectFormat { + workspaces: Option, + } + #[derive(serde::Deserialize)] + struct WorkspacesObject { + packages: Option>, + } + if let Ok(parsed) = serde_json::from_str::(&data) + && let Some(ws) = parsed.workspaces + && let Some(pkgs) = ws.packages + && !pkgs.is_empty() + { + return pkgs; + } + + vec![] +} + +/// Read workspace patterns from pnpm-workspace.yaml. +fn get_pnpm_patterns(root: &str) -> Vec { + let yaml_path = Path::new(root).join("pnpm-workspace.yaml"); + let data = match std::fs::read_to_string(&yaml_path) { + Ok(d) => d, + Err(_) => return vec![], + }; + + #[derive(serde::Deserialize)] + struct PnpmWorkspace { + packages: Option>, + } + + match serde_yaml::from_str::(&data) { + Ok(config) => config.packages.unwrap_or_default(), + Err(_) => vec![], + } +} + +/// Read workspace patterns from lerna.json. Falls back to npm/pnpm if absent. +fn get_lerna_patterns(root: &str) -> Vec { + let lerna_path = Path::new(root).join("lerna.json"); + if let Ok(data) = std::fs::read_to_string(&lerna_path) { + #[derive(serde::Deserialize)] + struct LernaConfig { + packages: Option>, + } + if let Ok(config) = serde_json::from_str::(&data) + && let Some(pkgs) = config.packages + && !pkgs.is_empty() + { + return pkgs; + } + } + + // Lerna without packages: delegate to the actual package manager + if Path::new(root).join("pnpm-workspace.yaml").exists() { + return get_pnpm_patterns(root); + } + get_npm_yarn_patterns(root) +} + +/// Read project paths from rush.json (literal paths, not globs). +fn get_rush_paths(root: &str) -> Vec { + let rush_path = Path::new(root).join("rush.json"); + let data = match std::fs::read_to_string(&rush_path) { + Ok(d) => d, + Err(_) => return vec![], + }; + + #[derive(serde::Deserialize)] + struct RushConfig { + projects: Option>, + } + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct RushProject { + project_folder: String, + } + + match serde_json::from_str::(&data) { + Ok(config) => config + .projects + .unwrap_or_default() + .into_iter() + .map(|p| p.project_folder) + .collect(), + Err(_) => vec![], + } +} diff --git a/rust/src/options/cli_options.rs b/rust/src/options/cli_options.rs new file mode 100644 index 000000000..87d5f259e --- /dev/null +++ b/rust/src/options/cli_options.rs @@ -0,0 +1,152 @@ +use clap::Parser; + +use crate::types::change_info::ChangeType; +use crate::types::options::CliOptions; + +/// Beachball: automated package publishing and change management. +#[derive(Parser, Debug)] +#[command(name = "beachball", version, about)] +pub struct CliArgs { + /// Command to run (check, change) + #[arg(default_value = "change")] + pub command: String, + + /// Target branch + #[arg(short = 'b', long)] + pub branch: Option, + + /// Directory for change files + #[arg(long = "change-dir")] + pub change_dir: Option, + + /// Path to beachball config file + #[arg(short = 'c', long = "config-path")] + pub config_path: Option, + + /// Consider all packages as changed + #[arg(long)] + pub all: bool, + + /// Commit change files (use --no-commit to only stage) + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + pub commit: bool, + + /// Don't commit change files, only stage them + #[arg(long = "no-commit")] + pub no_commit: bool, + + /// Fetch from remote before checking + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + pub fetch: bool, + + /// Don't fetch from remote + #[arg(long = "no-fetch")] + pub no_fetch: bool, + + /// Print additional info + #[arg(long)] + pub verbose: bool, + + /// Change description for all changed packages + #[arg(short = 'm', long)] + pub message: Option, + + /// Change type + #[arg(long = "type")] + pub change_type: Option, + + /// Force change files for specific packages + #[arg(short = 'p', long = "package")] + pub package: Option>, + + /// Only consider packages matching these patterns + #[arg(long)] + pub scope: Option>, + + /// Change types that are not allowed + #[arg(long = "disallowed-change-types")] + pub disallowed_change_types: Option>, + + /// Hint message when change files are needed + #[arg(long)] + pub changehint: Option, + + /// Change type for dependent packages + #[arg(long = "dependent-change-type")] + pub dependent_change_type: Option, + + /// Error if change files are deleted + #[arg(long = "disallow-deleted-change-files")] + pub disallow_deleted_change_files: bool, + + /// Put multiple changes in a single changefile + #[arg(long = "group-changes")] + pub group_changes: bool, + + /// Depth of git history for shallow clones + #[arg(long)] + pub depth: Option, + + /// Consider changes since this git ref + #[arg(long = "since", alias = "from-ref")] + pub from_ref: Option, + + /// Auth type for npm publish + #[arg(long = "auth-type")] + pub auth_type: Option, + + /// Auth token + #[arg(long)] + pub token: Option, + + /// Skip confirmation prompts + #[arg(short = 'y', long)] + pub yes: bool, +} + +/// Parse CLI arguments into CliOptions. +pub fn get_cli_options() -> CliOptions { + get_cli_options_from_args(std::env::args().collect()) +} + +/// Parse CLI arguments from a specific argv (for testing). +pub fn get_cli_options_from_args(args: Vec) -> CliOptions { + let cli = CliArgs::parse_from(args); + + CliOptions { + command: Some(cli.command), + branch: cli.branch, + change_dir: cli.change_dir, + all: if cli.all { Some(true) } else { None }, + commit: if cli.no_commit { + Some(false) + } else { + None // use default + }, + fetch: if cli.no_fetch { + Some(false) + } else { + None // use default + }, + verbose: if cli.verbose { Some(true) } else { None }, + message: cli.message, + change_type: cli.change_type, + package: cli.package, + scope: cli.scope, + disallowed_change_types: cli.disallowed_change_types, + changehint: cli.changehint, + dependent_change_type: cli.dependent_change_type, + disallow_deleted_change_files: if cli.disallow_deleted_change_files { + Some(true) + } else { + None + }, + group_changes: if cli.group_changes { Some(true) } else { None }, + depth: cli.depth, + from_ref: cli.from_ref, + config_path: cli.config_path, + auth_type: cli.auth_type, + token: cli.token, + yes: if cli.yes { Some(true) } else { None }, + } +} diff --git a/rust/src/options/default_options.rs b/rust/src/options/default_options.rs new file mode 100644 index 000000000..4adb9f13f --- /dev/null +++ b/rust/src/options/default_options.rs @@ -0,0 +1,6 @@ +use crate::types::options::BeachballOptions; + +/// Return the default options, matching the TS getDefaultOptions. +pub fn get_default_options() -> BeachballOptions { + BeachballOptions::default() +} diff --git a/rust/src/options/get_options.rs b/rust/src/options/get_options.rs new file mode 100644 index 000000000..c78accbfc --- /dev/null +++ b/rust/src/options/get_options.rs @@ -0,0 +1,171 @@ +use anyhow::Result; + +use crate::git::commands; +use crate::types::options::{BeachballOptions, CliOptions, ParsedOptions}; + +use super::default_options::get_default_options; +use super::repo_options::get_repo_options; + +/// Parse and merge all options: defaults <- repo config <- CLI options. +pub fn get_parsed_options(cwd: &str, cli: CliOptions) -> Result { + let defaults = get_default_options(); + + let repo_opts = get_repo_options(cwd, cli.config_path.as_deref())?; + + let mut merged = merge_options(defaults, repo_opts); + merged = apply_cli_options(merged, &cli); + merged.path = cwd.to_string(); + + // If branch doesn't contain '/', resolve the remote + if let Some(ref branch) = cli.branch + && !branch.contains('/') + { + merged.branch = commands::get_default_remote_branch_for(cwd, branch); + } + + Ok(ParsedOptions { + cli_options: cli, + options: merged, + }) +} + +/// For tests: parse options with overrides for repo-level settings. +pub fn get_parsed_options_for_test( + cwd: &str, + cli: CliOptions, + test_repo_options: BeachballOptions, +) -> ParsedOptions { + let defaults = get_default_options(); + let mut merged = merge_options(defaults, test_repo_options); + merged = apply_cli_options(merged, &cli); + merged.path = cwd.to_string(); + + ParsedOptions { + cli_options: cli, + options: merged, + } +} + +fn merge_options(base: BeachballOptions, overlay: BeachballOptions) -> BeachballOptions { + // overlay values take precedence over base when they differ from defaults + BeachballOptions { + command: overlay.command.clone(), + branch: if overlay.branch != "origin/master" { + overlay.branch + } else { + base.branch + }, + change_dir: if overlay.change_dir != "change" { + overlay.change_dir + } else { + base.change_dir + }, + path: if !overlay.path.is_empty() { + overlay.path + } else { + base.path + }, + all: overlay.all || base.all, + commit: overlay.commit, + fetch: overlay.fetch, + verbose: overlay.verbose || base.verbose, + message: if !overlay.message.is_empty() { + overlay.message + } else { + base.message + }, + change_type: overlay.change_type.or(base.change_type), + package: overlay.package.or(base.package), + scope: overlay.scope.or(base.scope), + ignore_patterns: overlay.ignore_patterns.or(base.ignore_patterns), + disallowed_change_types: overlay + .disallowed_change_types + .or(base.disallowed_change_types), + groups: overlay.groups.or(base.groups), + changehint: if overlay.changehint != "Run \"beachball change\" to create a change file" { + overlay.changehint + } else { + base.changehint + }, + dependent_change_type: overlay.dependent_change_type.or(base.dependent_change_type), + disallow_deleted_change_files: overlay.disallow_deleted_change_files + || base.disallow_deleted_change_files, + group_changes: overlay.group_changes || base.group_changes, + depth: overlay.depth.or(base.depth), + from_ref: overlay.from_ref.or(base.from_ref), + config_path: overlay.config_path.or(base.config_path), + auth_type: overlay.auth_type.or(base.auth_type), + token: overlay.token.or(base.token), + yes: overlay.yes || base.yes, + } +} + +fn apply_cli_options(mut opts: BeachballOptions, cli: &CliOptions) -> BeachballOptions { + if let Some(ref cmd) = cli.command { + opts.command = cmd.clone(); + } + if let Some(ref v) = cli.branch { + opts.branch = v.clone(); + } + if let Some(ref v) = cli.change_dir { + opts.change_dir = v.clone(); + } + if let Some(v) = cli.all { + opts.all = v; + } + if let Some(v) = cli.commit { + opts.commit = v; + } + if let Some(v) = cli.fetch { + opts.fetch = v; + } + if let Some(v) = cli.verbose { + opts.verbose = v; + } + if let Some(ref v) = cli.message { + opts.message = v.clone(); + } + if let Some(v) = cli.change_type { + opts.change_type = Some(v); + } + if let Some(ref v) = cli.package { + opts.package = Some(v.clone()); + } + if let Some(ref v) = cli.scope { + opts.scope = Some(v.clone()); + } + if let Some(ref v) = cli.disallowed_change_types { + opts.disallowed_change_types = Some(v.clone()); + } + if let Some(ref v) = cli.changehint { + opts.changehint = v.clone(); + } + if let Some(v) = cli.dependent_change_type { + opts.dependent_change_type = Some(v); + } + if let Some(v) = cli.disallow_deleted_change_files { + opts.disallow_deleted_change_files = v; + } + if let Some(v) = cli.group_changes { + opts.group_changes = v; + } + if let Some(v) = cli.depth { + opts.depth = Some(v); + } + if let Some(ref v) = cli.from_ref { + opts.from_ref = Some(v.clone()); + } + if let Some(ref v) = cli.config_path { + opts.config_path = Some(v.clone()); + } + if let Some(ref v) = cli.auth_type { + opts.auth_type = Some(v.clone()); + } + if let Some(ref v) = cli.token { + opts.token = Some(v.clone()); + } + if let Some(v) = cli.yes { + opts.yes = v; + } + opts +} diff --git a/rust/src/options/mod.rs b/rust/src/options/mod.rs new file mode 100644 index 000000000..800f44f8c --- /dev/null +++ b/rust/src/options/mod.rs @@ -0,0 +1,4 @@ +pub mod cli_options; +pub mod default_options; +pub mod get_options; +pub mod repo_options; diff --git a/rust/src/options/repo_options.rs b/rust/src/options/repo_options.rs new file mode 100644 index 000000000..b72d25ce1 --- /dev/null +++ b/rust/src/options/repo_options.rs @@ -0,0 +1,169 @@ +use anyhow::Result; +use std::path::Path; + +use crate::git::commands::get_default_remote_branch_for; +use crate::types::change_info::ChangeType; +use crate::types::options::{BeachballOptions, VersionGroupInclude, VersionGroupOptions}; + +/// Repo-level config from .beachballrc.json or package.json "beachball" field. +#[derive(Debug, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawRepoConfig { + branch: Option, + change_dir: Option, + commit: Option, + fetch: Option, + changehint: Option, + disallowed_change_types: Option>, + disallow_deleted_change_files: Option, + group_changes: Option, + ignore_patterns: Option>, + scope: Option>, + groups: Option>, + message: Option, + depth: Option, + from_ref: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawVersionGroupOptions { + name: String, + include: serde_json::Value, + exclude: Option, + disallowed_change_types: Option>, +} + +/// Load repo-level beachball config from project_root (absolute path). +/// config_path is from an optional CLI arg and may be relative or absolute. +/// If config_path is not specified, looks for .beachballrc.json or package.json +/// "beachball" field. +pub fn get_repo_options(project_root: &str, config_path: Option<&str>) -> Result { + let mut opts = BeachballOptions::default(); + + let raw = if let Some(path) = config_path { + let resolved = Path::new(project_root).join(path); + load_json_config(resolved.to_str().unwrap_or_default())? + } else { + let root = Path::new(project_root); + // Check for .beachballrc.json + let rc_path = root.join(".beachballrc.json"); + if rc_path.exists() { + load_json_config(rc_path.to_str().unwrap_or_default())? + } else { + // Check for package.json "beachball" field + let pkg_path = root.join("package.json"); + if pkg_path.exists() { + load_from_package_json(pkg_path.to_str().unwrap_or_default())? + } else { + None + } + } + }; + + if let Some(raw) = raw { + apply_raw_config(&mut opts, raw, project_root)?; + } + + Ok(opts) +} + +fn load_json_config(path: &str) -> Result> { + let contents = std::fs::read_to_string(path)?; + let config: RawRepoConfig = serde_json::from_str(&contents)?; + Ok(Some(config)) +} + +fn load_from_package_json(path: &str) -> Result> { + let contents = std::fs::read_to_string(path)?; + let pkg: serde_json::Value = serde_json::from_str(&contents)?; + if let Some(beachball) = pkg.get("beachball") { + let config: RawRepoConfig = serde_json::from_value(beachball.clone())?; + return Ok(Some(config)); + } + Ok(None) +} + +fn apply_raw_config(opts: &mut BeachballOptions, raw: RawRepoConfig, cwd: &str) -> Result<()> { + if let Some(branch) = raw.branch { + // If branch doesn't contain '/', resolve the remote + if branch.contains('/') { + opts.branch = branch; + } else { + opts.branch = get_default_remote_branch_for(cwd, &branch); + } + } + + if let Some(v) = raw.change_dir { + opts.change_dir = v; + } + if let Some(v) = raw.commit { + opts.commit = v; + } + if let Some(v) = raw.fetch { + opts.fetch = v; + } + if let Some(v) = raw.changehint { + opts.changehint = v; + } + if let Some(v) = raw.disallowed_change_types { + opts.disallowed_change_types = Some(v); + } + if let Some(v) = raw.disallow_deleted_change_files { + opts.disallow_deleted_change_files = v; + } + if let Some(v) = raw.group_changes { + opts.group_changes = v; + } + if let Some(v) = raw.ignore_patterns { + opts.ignore_patterns = Some(v); + } + if let Some(v) = raw.scope { + opts.scope = Some(v); + } + if let Some(v) = raw.message { + opts.message = v; + } + if let Some(v) = raw.depth { + opts.depth = Some(v); + } + if let Some(v) = raw.from_ref { + opts.from_ref = Some(v); + } + if let Some(raw_groups) = raw.groups { + let groups = raw_groups + .into_iter() + .map(|g| { + let include = match &g.include { + serde_json::Value::Bool(true) => VersionGroupInclude::All, + serde_json::Value::String(s) => VersionGroupInclude::Patterns(vec![s.clone()]), + serde_json::Value::Array(arr) => VersionGroupInclude::Patterns( + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + ), + _ => VersionGroupInclude::Patterns(vec![]), + }; + + let exclude = g.exclude.map(|e| match e { + serde_json::Value::String(s) => vec![s], + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => vec![], + }); + + VersionGroupOptions { + name: g.name, + include, + exclude, + disallowed_change_types: g.disallowed_change_types, + } + }) + .collect(); + opts.groups = Some(groups); + } + + Ok(()) +} diff --git a/rust/src/types/change_info.rs b/rust/src/types/change_info.rs new file mode 100644 index 000000000..0d766008f --- /dev/null +++ b/rust/src/types/change_info.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangeType { + None, + Prerelease, + Prepatch, + Patch, + Preminor, + Minor, + Premajor, + Major, +} + +impl ChangeType { + /// Ordered from least to most significant (matching TS SortedChangeTypes). + pub const SORTED: &[ChangeType] = &[ + ChangeType::None, + ChangeType::Prerelease, + ChangeType::Prepatch, + ChangeType::Patch, + ChangeType::Preminor, + ChangeType::Minor, + ChangeType::Premajor, + ChangeType::Major, + ]; + + pub fn rank(self) -> usize { + Self::SORTED.iter().position(|&t| t == self).unwrap_or(0) + } +} + +impl PartialOrd for ChangeType { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ChangeType { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.rank().cmp(&other.rank()) + } +} + +impl fmt::Display for ChangeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ChangeType::None => write!(f, "none"), + ChangeType::Prerelease => write!(f, "prerelease"), + ChangeType::Prepatch => write!(f, "prepatch"), + ChangeType::Patch => write!(f, "patch"), + ChangeType::Preminor => write!(f, "preminor"), + ChangeType::Minor => write!(f, "minor"), + ChangeType::Premajor => write!(f, "premajor"), + ChangeType::Major => write!(f, "major"), + } + } +} + +impl std::str::FromStr for ChangeType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(ChangeType::None), + "prerelease" => Ok(ChangeType::Prerelease), + "prepatch" => Ok(ChangeType::Prepatch), + "patch" => Ok(ChangeType::Patch), + "preminor" => Ok(ChangeType::Preminor), + "minor" => Ok(ChangeType::Minor), + "premajor" => Ok(ChangeType::Premajor), + "major" => Ok(ChangeType::Major), + _ => Err(format!("invalid change type: {s}")), + } + } +} + +/// Info saved in each change file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeFileInfo { + #[serde(rename = "type")] + pub change_type: ChangeType, + pub comment: String, + pub package_name: String, + pub email: String, + pub dependent_change_type: ChangeType, +} + +/// Grouped change file format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChangeInfoMultiple { + pub changes: Vec, +} + +/// A single entry in a ChangeSet. +#[derive(Debug, Clone)] +pub struct ChangeSetEntry { + pub change: ChangeFileInfo, + /// Filename the change came from (under changeDir). + pub change_file: String, +} + +/// List of change file infos. +pub type ChangeSet = Vec; diff --git a/rust/src/types/mod.rs b/rust/src/types/mod.rs new file mode 100644 index 000000000..07da90465 --- /dev/null +++ b/rust/src/types/mod.rs @@ -0,0 +1,3 @@ +pub mod change_info; +pub mod options; +pub mod package_info; diff --git a/rust/src/types/options.rs b/rust/src/types/options.rs new file mode 100644 index 000000000..815bef9ee --- /dev/null +++ b/rust/src/types/options.rs @@ -0,0 +1,116 @@ +use super::change_info::ChangeType; + +/// Options for bumping package versions together. +#[derive(Debug, Clone)] +pub struct VersionGroupOptions { + pub name: String, + /// Patterns for package paths to include. `None` means include all (equivalent to `true` in TS). + pub include: VersionGroupInclude, + /// Patterns for package paths to exclude. + pub exclude: Option>, + pub disallowed_change_types: Option>, +} + +#[derive(Debug, Clone)] +pub enum VersionGroupInclude { + /// Include all packages (equivalent to `true` in TS). + All, + /// Include packages matching these patterns. + Patterns(Vec), +} + +/// Merged beachball options (CLI + repo config + defaults). +#[derive(Debug, Clone)] +pub struct BeachballOptions { + pub command: String, + pub branch: String, + pub change_dir: String, + pub path: String, + pub all: bool, + pub commit: bool, + pub fetch: bool, + pub verbose: bool, + pub message: String, + pub change_type: Option, + pub package: Option>, + pub scope: Option>, + pub ignore_patterns: Option>, + pub disallowed_change_types: Option>, + pub groups: Option>, + pub changehint: String, + pub dependent_change_type: Option, + pub disallow_deleted_change_files: bool, + pub group_changes: bool, + pub depth: Option, + pub from_ref: Option, + pub config_path: Option, + pub auth_type: Option, + pub token: Option, + pub yes: bool, +} + +impl Default for BeachballOptions { + fn default() -> Self { + Self { + command: "change".to_string(), + branch: "origin/master".to_string(), + change_dir: "change".to_string(), + path: String::new(), + all: false, + commit: true, + fetch: true, + verbose: false, + message: String::new(), + change_type: None, + package: None, + scope: None, + ignore_patterns: None, + disallowed_change_types: None, + groups: None, + changehint: "Run \"beachball change\" to create a change file".to_string(), + dependent_change_type: None, + disallow_deleted_change_files: false, + group_changes: false, + depth: None, + from_ref: None, + config_path: None, + auth_type: None, + token: None, + yes: std::env::var("CI").is_ok(), + } + } +} + +/// Separate CLI-only options from merged options. +#[derive(Debug, Clone)] +pub struct ParsedOptions { + pub cli_options: CliOptions, + pub options: BeachballOptions, +} + +/// CLI-only option values (before merging with defaults/repo config). +#[derive(Debug, Clone, Default)] +pub struct CliOptions { + pub command: Option, + pub branch: Option, + pub change_dir: Option, + pub all: Option, + pub commit: Option, + pub fetch: Option, + pub verbose: Option, + pub message: Option, + pub change_type: Option, + pub package: Option>, + pub scope: Option>, + pub disallowed_change_types: Option>, + pub changehint: Option, + pub dependent_change_type: Option, + pub disallow_deleted_change_files: Option, + pub group_changes: Option, + pub depth: Option, + pub from_ref: Option, + pub config_path: Option, + pub auth_type: Option, + pub token: Option, + pub yes: Option, +} diff --git a/rust/src/types/package_info.rs b/rust/src/types/package_info.rs new file mode 100644 index 000000000..41ac6e1bf --- /dev/null +++ b/rust/src/types/package_info.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use super::change_info::ChangeType; + +pub type PackageDeps = HashMap; + +/// Raw package.json structure for deserialization. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PackageJson { + #[serde(default)] + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub private: Option, + #[serde(default)] + pub dependencies: Option, + #[serde(default)] + pub dev_dependencies: Option, + #[serde(default)] + pub peer_dependencies: Option, + #[serde(default)] + pub optional_dependencies: Option, + #[serde(default)] + pub workspaces: Option>, + /// The "beachball" config key in package.json. + #[serde(default)] + pub beachball: Option, + /// Catch-all for other fields. + #[serde(flatten)] + pub other: HashMap, +} + +/// Package-level beachball options (from the "beachball" field in package.json). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageOptions { + pub tag: Option, + pub default_npm_tag: Option, + pub disallowed_change_types: Option>, + pub git_tags: Option, + pub should_publish: Option, +} + +/// Internal representation of a package. +#[derive(Debug, Clone, Default)] +pub struct PackageInfo { + pub name: String, + pub package_json_path: String, + pub version: String, + pub dependencies: Option, + pub dev_dependencies: Option, + pub peer_dependencies: Option, + pub optional_dependencies: Option, + pub private: bool, + pub package_options: Option, +} + +/// Map of package name to PackageInfo. +pub type PackageInfos = HashMap; + +/// Info about a version group. +#[derive(Debug, Clone)] +pub struct PackageGroupInfo { + pub package_names: Vec, + pub disallowed_change_types: Option>, +} + +/// Map of group name to group info. +pub type PackageGroups = HashMap; + +/// Set of package names that are in scope. +pub type ScopedPackages = HashSet; diff --git a/rust/src/validation/are_change_files_deleted.rs b/rust/src/validation/are_change_files_deleted.rs new file mode 100644 index 000000000..aa743160c --- /dev/null +++ b/rust/src/validation/are_change_files_deleted.rs @@ -0,0 +1,31 @@ +use crate::git::commands; +use crate::types::options::BeachballOptions; +use crate::{log_error, log_info}; + +/// Check if any change files have been deleted (compared to the target branch). +pub fn are_change_files_deleted(options: &BeachballOptions) -> bool { + let change_path = crate::changefile::read_change_files::get_change_path(options); + + log_info!( + "Checking for deleted change files against \"{}\"", + options.branch + ); + + let deleted = commands::get_changes_between_refs( + &options.branch, + Some("D"), + Some("*.json"), + &change_path, + ) + .unwrap_or_default(); + + if !deleted.is_empty() { + let items: Vec<&str> = deleted.iter().map(|s| s.as_str()).collect(); + log_error!( + "The following change files were deleted:\n{}", + crate::logging::bulleted_list(&items) + ); + } + + !deleted.is_empty() +} diff --git a/rust/src/validation/mod.rs b/rust/src/validation/mod.rs new file mode 100644 index 000000000..9446e6f94 --- /dev/null +++ b/rust/src/validation/mod.rs @@ -0,0 +1,3 @@ +pub mod are_change_files_deleted; +pub mod validate; +pub mod validators; diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs new file mode 100644 index 000000000..ef2ad797a --- /dev/null +++ b/rust/src/validation/validate.rs @@ -0,0 +1,271 @@ +use anyhow::Result; + +use crate::changefile::change_types::get_disallowed_change_types; +use crate::changefile::changed_packages::get_changed_packages; +use crate::changefile::read_change_files::read_change_files; +use crate::git::commands::get_untracked_changes; +use crate::logging::bulleted_list; +use crate::monorepo::package_groups::get_package_groups; +use crate::monorepo::package_infos::get_package_infos; +use crate::monorepo::scoped_packages::get_scoped_packages; +use crate::types::change_info::ChangeSet; +use crate::types::options::ParsedOptions; +use crate::types::package_info::{PackageGroups, PackageInfos, ScopedPackages}; +use crate::validation::are_change_files_deleted::are_change_files_deleted; +use crate::validation::validators::*; +use crate::{log_error, log_info, log_warn}; + +#[derive(Default)] +pub struct ValidateOptions { + pub check_change_needed: bool, + pub allow_missing_change_files: bool, + pub check_dependencies: bool, +} + +#[derive(Debug)] +pub struct ValidationResult { + pub is_change_needed: bool, + pub package_infos: PackageInfos, + pub package_groups: PackageGroups, + pub scoped_packages: ScopedPackages, + pub change_set: ChangeSet, + pub changed_packages: Option>, +} + +/// Validation error that indicates the process should exit with code 1. +#[derive(Debug)] +pub struct ValidationError { + pub message: String, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ValidationError {} + +/// Log a validation error and set the flag. +fn log_validation_error(msg: &str, has_error: &mut bool) { + log_error!("{msg}"); + *has_error = true; +} + +/// Run validation of options, change files, and packages. +pub fn validate( + parsed: &ParsedOptions, + validate_options: &ValidateOptions, +) -> Result { + let options = &parsed.options; + + log_info!("\nValidating options and change files..."); + + let mut has_error = false; + + // Check for untracked changes + let untracked = get_untracked_changes(&options.path).unwrap_or_default(); + if !untracked.is_empty() { + log_warn!( + "There are untracked changes in your repository:\n{}", + bulleted_list(&untracked.iter().map(|s| s.as_str()).collect::>()) + ); + } + + // Get package infos + let package_infos = get_package_infos(options)?; + + // Validate --all and --package conflict + if options.all && options.package.is_some() { + log_validation_error( + "Cannot specify both \"all\" and \"package\" options", + &mut has_error, + ); + } else if let Some(ref packages) = options.package { + let mut invalid_reasons = vec![]; + for pkg in packages { + if !package_infos.contains_key(pkg) { + invalid_reasons.push(format!("\"{pkg}\" was not found")); + } else if package_infos[pkg].private { + invalid_reasons.push(format!("\"{pkg}\" is marked as private")); + } + } + if !invalid_reasons.is_empty() { + log_validation_error( + &format!( + "Invalid package(s) specified:\n{}", + bulleted_list( + &invalid_reasons + .iter() + .map(|s| s.as_str()) + .collect::>() + ) + ), + &mut has_error, + ); + } + } + + // Validate auth type + if let Some(ref auth_type) = options.auth_type + && !is_valid_auth_type(auth_type) + { + log_validation_error( + &format!("authType \"{auth_type}\" is not valid"), + &mut has_error, + ); + } + + // Validate dependent change type + if let Some(dct) = options.dependent_change_type + && !is_valid_change_type_value(dct) + { + log_validation_error( + &format!("dependentChangeType \"{dct}\" is not valid"), + &mut has_error, + ); + } + + // Validate change type + if let Some(ct) = options.change_type + && !is_valid_change_type_value(ct) + { + log_validation_error( + &format!("Change type \"{ct}\" is not valid"), + &mut has_error, + ); + } + + // Validate group options + if let Some(ref groups) = options.groups + && !is_valid_group_options(groups) + { + has_error = true; + } + + // Get package groups + let package_groups = get_package_groups(&package_infos, &options.path, &options.groups)?; + + // Validate grouped package options + if options.groups.is_some() + && !is_valid_grouped_package_options(&package_infos, &package_groups) + { + has_error = true; + } + + let scoped_packages = get_scoped_packages(options, &package_infos); + let change_set = read_change_files(options, &package_infos, &scoped_packages); + + // Validate each change file + for entry in &change_set { + let disallowed = get_disallowed_change_types( + &entry.change.package_name, + &package_infos, + &package_groups, + &options.disallowed_change_types, + ); + + let ct_str = entry.change.change_type.to_string(); + if !is_valid_change_type(&ct_str) { + log_validation_error( + &format!( + "Invalid change type detected in {}: \"{}\"", + entry.change_file, ct_str + ), + &mut has_error, + ); + } else if let Some(ref disallowed) = disallowed + && disallowed.contains(&entry.change.change_type) + { + log_validation_error( + &format!( + "Disallowed change type detected in {}: \"{}\"", + entry.change_file, ct_str + ), + &mut has_error, + ); + } + + let dct_str = entry.change.dependent_change_type.to_string(); + if !is_valid_dependent_change_type(entry.change.dependent_change_type, &disallowed) { + log_validation_error( + &format!( + "Invalid dependentChangeType detected in {}: \"{}\"", + entry.change_file, dct_str + ), + &mut has_error, + ); + } + } + + if has_error { + return Err(ValidationError { + message: "Validation failed".to_string(), + } + .into()); + } + + // Check if change files are needed + let mut is_change_needed = false; + let mut changed_packages: Option> = None; + + if validate_options.check_change_needed { + let pkgs = get_changed_packages(options, &package_infos, &scoped_packages)?; + is_change_needed = !pkgs.is_empty(); + + if is_change_needed { + let message = if options.all { + "Considering the following packages due to --all" + } else if options.package.is_some() { + "Considering the specific --package" + } else { + "Found changes in the following packages" + }; + let mut sorted = pkgs.clone(); + sorted.sort(); + log_info!( + "{message}:\n{}", + bulleted_list(&sorted.iter().map(|s| s.as_str()).collect::>()) + ); + } + + if is_change_needed && !validate_options.allow_missing_change_files { + log_error!("Change files are needed!"); + log_info!("{}", options.changehint); + return Err(ValidationError { + message: "Change files are needed".to_string(), + } + .into()); + } + + if options.disallow_deleted_change_files && are_change_files_deleted(options) { + log_error!("Change files must not be deleted!"); + return Err(ValidationError { + message: "Change files must not be deleted".to_string(), + } + .into()); + } + + changed_packages = Some(pkgs); + } + + // Skip checkDependencies / bumpInMemory (not implemented) + if validate_options.check_dependencies + && !is_change_needed + && !change_set.is_empty() + && options.verbose + { + log_info!("(Skipping package dependency validation — not implemented in Rust port)"); + } + + log_info!(); + + Ok(ValidationResult { + is_change_needed, + package_infos, + package_groups, + scoped_packages, + change_set, + changed_packages, + }) +} diff --git a/rust/src/validation/validators.rs b/rust/src/validation/validators.rs new file mode 100644 index 000000000..4d8bca25d --- /dev/null +++ b/rust/src/validation/validators.rs @@ -0,0 +1,70 @@ +use crate::log_error; +use crate::types::change_info::ChangeType; +use crate::types::options::VersionGroupOptions; +use crate::types::package_info::{PackageGroups, PackageInfos}; + +/// Check if an auth type string is valid. +pub fn is_valid_auth_type(auth_type: &str) -> bool { + matches!(auth_type, "authtoken" | "password") +} + +/// Check if a change type string is valid. +pub fn is_valid_change_type(ct: &str) -> bool { + ct.parse::().is_ok() +} + +/// Check if a change type value is valid. +pub fn is_valid_change_type_value(ct: ChangeType) -> bool { + ChangeType::SORTED.contains(&ct) +} + +/// Check if a dependent change type is valid (not disallowed). +pub fn is_valid_dependent_change_type( + ct: ChangeType, + disallowed: &Option>, +) -> bool { + if !is_valid_change_type_value(ct) { + return false; + } + if let Some(disallowed) = disallowed + && disallowed.contains(&ct) + { + return false; + } + true +} + +/// Check if group options are structurally valid. +pub fn is_valid_group_options(groups: &[VersionGroupOptions]) -> bool { + let mut valid = true; + for group in groups { + if group.name.is_empty() { + log_error!("Group option is missing 'name'"); + valid = false; + } + } + valid +} + +/// Check that packages in groups don't have their own disallowedChangeTypes. +pub fn is_valid_grouped_package_options( + package_infos: &PackageInfos, + package_groups: &PackageGroups, +) -> bool { + let mut valid = true; + for group_info in package_groups.values() { + for pkg_name in &group_info.package_names { + if let Some(info) = package_infos.get(pkg_name) + && let Some(ref opts) = info.package_options + && opts.disallowed_change_types.is_some() + { + log_error!( + "Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ + Group-level disallowedChangeTypes take precedence." + ); + valid = false; + } + } + } + valid +} diff --git a/rust/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs new file mode 100644 index 000000000..58cdd8e23 --- /dev/null +++ b/rust/tests/are_change_files_deleted_test.rs @@ -0,0 +1,82 @@ +mod common; + +use beachball::types::options::BeachballOptions; +use beachball::validation::are_change_files_deleted::are_change_files_deleted; +use common::change_files::generate_change_files; +use common::repository_factory::RepositoryFactory; +use common::{DEFAULT_BRANCH, capture_logging, get_log_output, make_test_options, reset_logging}; + +// TS: "is false when no change files are deleted" +#[test] +fn is_false_when_no_change_files_are_deleted() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let options = make_test_options(repo.root_path(), None); + generate_change_files(&["foo"], &options, &repo); + repo.push(); + + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let options = make_test_options(repo.root_path(), None); + assert!(!are_change_files_deleted(&options)); +} + +// TS: "is true when change files are deleted" +#[test] +fn is_true_when_change_files_are_deleted() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let options = make_test_options(repo.root_path(), None); + generate_change_files(&["foo"], &options, &repo); + repo.push(); + + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + // Delete the change files (but keep the directory so git can run from it) + let change_path = std::path::Path::new(repo.root_path()).join("change"); + for entry in std::fs::read_dir(&change_path).unwrap() { + let entry = entry.unwrap(); + std::fs::remove_file(entry.path()).unwrap(); + } + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "delete change files"]); + + capture_logging(); + let options = make_test_options(repo.root_path(), None); + assert!(are_change_files_deleted(&options)); + let output = get_log_output(); + reset_logging(); + assert!(output.contains("The following change files were deleted")); +} + +// TS: "deletes change files when changeDir option is specified" +#[test] +fn works_with_custom_change_dir() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let custom_opts = BeachballOptions { + change_dir: "changeDir".to_string(), + ..Default::default() + }; + + let options = make_test_options(repo.root_path(), Some(custom_opts.clone())); + generate_change_files(&["foo"], &options, &repo); + repo.push(); + + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + // Delete the change files (but keep the directory so git can run from it) + let change_path = std::path::Path::new(repo.root_path()).join("changeDir"); + for entry in std::fs::read_dir(&change_path).unwrap() { + let entry = entry.unwrap(); + std::fs::remove_file(entry.path()).unwrap(); + } + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "delete change files"]); + + let options = make_test_options(repo.root_path(), Some(custom_opts)); + assert!(are_change_files_deleted(&options)); +} diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs new file mode 100644 index 000000000..34fdfbef6 --- /dev/null +++ b/rust/tests/change_test.rs @@ -0,0 +1,281 @@ +mod common; + +use beachball::commands::change::change; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::change_info::{ChangeFileInfo, ChangeInfoMultiple, ChangeType}; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::change_files::get_change_files; +use common::repository_factory::RepositoryFactory; +use common::{ + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_logging, get_log_output, reset_logging, +}; + +fn make_cli(message: &str, change_type: ChangeType) -> CliOptions { + CliOptions { + command: Some("change".to_string()), + message: Some(message.to_string()), + change_type: Some(change_type), + ..Default::default() + } +} + +fn make_repo_opts() -> BeachballOptions { + BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + } +} + +// TS: "does not create change files when there are no changes" +#[test] +fn does_not_create_change_files_when_no_changes() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "no-changes-test", DEFAULT_BRANCH]); + + capture_logging(); + let cli = make_cli("test change", ChangeType::Patch); + let parsed = get_parsed_options_for_test(repo.root_path(), cli, make_repo_opts()); + assert!(change(&parsed).is_ok()); + let output = get_log_output(); + reset_logging(); + + assert!(get_change_files(&parsed.options).is_empty()); + assert!(output.contains("No change files are needed")); +} + +// TS: "creates and commits a change file" (non-interactive equivalent) +#[test] +fn creates_change_file_with_type_and_message() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "creates-change-test", DEFAULT_BRANCH]); + repo.commit_change("file.js"); + + let repo_opts = BeachballOptions { + commit: false, + ..make_repo_opts() + }; + let cli = CliOptions { + commit: Some(false), + ..make_cli("test description", ChangeType::Patch) + }; + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.change_type, ChangeType::Patch); + assert_eq!(change.comment, "test description"); + assert_eq!(change.package_name, "foo"); + assert_eq!(change.dependent_change_type, ChangeType::Patch); +} + +// TS: "creates and stages a change file" +#[test] +fn creates_and_stages_a_change_file() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "stages-change-test", DEFAULT_BRANCH]); + repo.commit_change("file.js"); + + let repo_opts = BeachballOptions { + commit: false, + ..make_repo_opts() + }; + let cli = CliOptions { + commit: Some(false), + ..make_cli("stage me please", ChangeType::Patch) + }; + + capture_logging(); + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + let output = get_log_output(); + reset_logging(); + + // Verify file is staged (git status shows "A ") + let status = repo.status(); + assert!( + status.contains("A "), + "expected staged file (A prefix), got: {status}" + ); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.comment, "stage me please"); + assert_eq!(change.package_name, "foo"); + assert!(output.contains("git staged these change files:")); +} + +// TS: "creates and commits a change file" +#[test] +fn creates_and_commits_a_change_file() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "commits-change-test", DEFAULT_BRANCH]); + repo.commit_change("file.js"); + + capture_logging(); + let cli = make_cli("commit me please", ChangeType::Patch); + let parsed = get_parsed_options_for_test(repo.root_path(), cli, make_repo_opts()); + assert!(change(&parsed).is_ok()); + let output = get_log_output(); + reset_logging(); + + // Verify clean git status (committed) + let status = repo.status(); + assert!(status.is_empty(), "expected clean status, got: {status}"); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.comment, "commit me please"); + assert!(output.contains("git committed these change files:")); +} + +// TS: "creates and commits a change file with changeDir set" +#[test] +fn creates_and_commits_a_change_file_with_change_dir() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "changedir-test", DEFAULT_BRANCH]); + repo.commit_change("file.js"); + + let repo_opts = BeachballOptions { + change_dir: "changeDir".to_string(), + ..make_repo_opts() + }; + let cli = make_cli("commit me please", ChangeType::Patch); + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + + let status = repo.status(); + assert!(status.is_empty(), "expected clean status, got: {status}"); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + assert!( + files[0].contains("changeDir"), + "expected file in changeDir, got: {}", + files[0] + ); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.comment, "commit me please"); +} + +// TS: "creates a change file when there are no changes but package name is provided" +#[test] +fn creates_change_file_when_no_changes_but_package_provided() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "package-flag-test", DEFAULT_BRANCH]); + + let repo_opts = BeachballOptions { + commit: false, + ..make_repo_opts() + }; + let cli = CliOptions { + package: Some(vec!["foo".to_string()]), + commit: Some(false), + ..make_cli("forced change", ChangeType::Patch) + }; + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.package_name, "foo"); +} + +// TS: "creates and commits change files for multiple packages" +#[test] +fn creates_and_commits_change_files_for_multiple_packages() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "multi-pkg-test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + repo.commit_change("packages/bar/file.js"); + + let cli = make_cli("multi-package change", ChangeType::Minor); + let parsed = get_parsed_options_for_test(repo.root_path(), cli, make_repo_opts()); + assert!(change(&parsed).is_ok()); + + let status = repo.status(); + assert!(status.is_empty(), "expected clean status, got: {status}"); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 2); + + let mut package_names: Vec = Vec::new(); + for f in &files { + let contents = std::fs::read_to_string(f).unwrap(); + let change: ChangeFileInfo = serde_json::from_str(&contents).unwrap(); + assert_eq!(change.change_type, ChangeType::Minor); + assert_eq!(change.comment, "multi-package change"); + package_names.push(change.package_name); + } + package_names.sort(); + assert_eq!(package_names, vec!["bar", "foo"]); +} + +// Skipped TS tests: +// - "uses custom per-package prompt" — interactive prompts not implemented + +// TS: "creates and commits grouped change file for multiple packages" +#[test] +fn creates_and_commits_grouped_change_file() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "grouped-test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + repo.commit_change("packages/bar/file.js"); + + let repo_opts = BeachballOptions { + group_changes: true, + ..make_repo_opts() + }; + let cli = make_cli("grouped change", ChangeType::Minor); + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + + let status = repo.status(); + assert!(status.is_empty(), "expected clean status, got: {status}"); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + let contents = std::fs::read_to_string(&files[0]).unwrap(); + let grouped: ChangeInfoMultiple = serde_json::from_str(&contents).unwrap(); + assert_eq!(grouped.changes.len(), 2); + + let mut package_names: Vec = grouped + .changes + .iter() + .map(|c| { + assert_eq!(c.change_type, ChangeType::Minor); + assert_eq!(c.comment, "grouped change"); + c.package_name.clone() + }) + .collect(); + package_names.sort(); + assert_eq!(package_names, vec!["bar", "foo"]); +} diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs new file mode 100644 index 000000000..ea75aa16a --- /dev/null +++ b/rust/tests/changed_packages_test.rs @@ -0,0 +1,362 @@ +mod common; + +use beachball::changefile::changed_packages::get_changed_packages; +use beachball::monorepo::package_infos::get_package_infos; +use beachball::monorepo::scoped_packages::get_scoped_packages; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::change_files::generate_change_files; +use common::repository_factory::RepositoryFactory; +use common::{ + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_verbose_logging, get_log_output, reset_logging, +}; +use serde_json::json; +use std::collections::HashMap; + +fn get_options_and_packages( + repo: &common::repository::Repository, + overrides: Option, + extra_cli: Option, +) -> ( + BeachballOptions, + beachball::types::package_info::PackageInfos, + beachball::types::package_info::ScopedPackages, +) { + let cli = extra_cli.unwrap_or_default(); + let mut repo_opts = overrides.unwrap_or_default(); + repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + repo_opts.fetch = false; + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + let package_infos = get_package_infos(&parsed.options).unwrap(); + let scoped_packages = get_scoped_packages(&parsed.options, &package_infos); + (parsed.options, package_infos, scoped_packages) +} + +fn check_out_test_branch(repo: &common::repository::Repository, name: &str) { + let branch_name = name.replace(|c: char| !c.is_alphanumeric(), "-"); + repo.checkout(&["-b", &branch_name, DEFAULT_BRANCH]); +} + +// ===== Basic tests (TS: getChangedPackages (basic)) ===== + +// TS: "throws if the remote is invalid" +#[test] +fn throws_if_the_remote_is_invalid() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.git(&["remote", "add", "foo", "file:///__nonexistent"]); + check_out_test_branch(&repo, "invalid-remote"); + repo.commit_change("fake.js"); + + let opts = BeachballOptions { + fetch: true, + branch: "foo/master".to_string(), + ..Default::default() + }; + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), opts); + let infos = get_package_infos(&parsed.options).unwrap(); + let scoped = get_scoped_packages(&parsed.options, &infos); + let result = get_changed_packages(&parsed.options, &infos, &scoped); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Fetching branch") && err_msg.contains("failed"), + "expected fetch error, got: {err_msg}" + ); +} + +// TS: "returns empty list when no changes have been made" +#[test] +fn returns_empty_list_when_no_changes() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let (options, infos, scoped) = get_options_and_packages(&repo, None, None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert!(result.is_empty()); +} + +// TS: "returns package name when changes exist in a new branch" +#[test] +fn returns_package_name_when_changes_in_branch() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + check_out_test_branch(&repo, "changes_in_branch"); + repo.commit_change("packages/foo/myFilename"); + + let opts = BeachballOptions { + verbose: true, + ..Default::default() + }; + capture_verbose_logging(); + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + let output = get_log_output(); + reset_logging(); + + assert_eq!(result, vec!["foo"]); + assert!(output.contains("Checking for changes against")); +} + +// TS: "returns empty list when changes are CHANGELOG files" +#[test] +fn returns_empty_list_for_changelog_changes() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + check_out_test_branch(&repo, "changelog_changes"); + repo.commit_change("packages/foo/CHANGELOG.md"); + + let (options, infos, scoped) = get_options_and_packages(&repo, None, None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert!(result.is_empty()); +} + +// TS: "returns the given package name(s) as-is" +#[test] +fn returns_given_package_names_as_is() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let cli = CliOptions { + package: Some(vec!["foo".to_string()]), + ..Default::default() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, None, Some(cli)); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result, vec!["foo"]); + + let cli2 = CliOptions { + package: Some(vec![ + "foo".to_string(), + "bar".to_string(), + "nope".to_string(), + ]), + ..Default::default() + }; + let (options2, infos2, scoped2) = get_options_and_packages(&repo, None, Some(cli2)); + let result2 = get_changed_packages(&options2, &infos2, &scoped2).unwrap(); + assert_eq!(result2, vec!["foo", "bar", "nope"]); +} + +// TS: "returns all packages with all: true" +#[test] +fn returns_all_packages_with_all_true() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let opts = BeachballOptions { + all: true, + ..Default::default() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + let mut result = get_changed_packages(&options, &infos, &scoped).unwrap(); + result.sort(); + assert_eq!(result, vec!["a", "b", "bar", "baz", "foo"]); +} + +// ===== Single package tests (TS: getChangedPackages) ===== + +// TS: "detects changed files in single-package repo" +#[test] +fn detects_changed_files_in_single_package_repo() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + + let (options, infos, scoped) = get_options_and_packages(&repo, None, None); + assert!( + get_changed_packages(&options, &infos, &scoped) + .unwrap() + .is_empty() + ); + + repo.stage_change("foo.js"); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result, vec!["foo"]); +} + +// TS: "respects ignorePatterns option" +#[test] +fn respects_ignore_patterns() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + + let opts = BeachballOptions { + ignore_patterns: Some(vec![ + "*.test.js".to_string(), + "tests/**".to_string(), + "yarn.lock".to_string(), + ]), + verbose: true, + ..Default::default() + }; + + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + + repo.write_file("src/foo.test.js"); + repo.write_file("tests/stuff.js"); + repo.write_file_content("yarn.lock", "changed"); + repo.git(&["add", "-A"]); + + capture_verbose_logging(); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + let output = get_log_output(); + reset_logging(); + + assert!(result.is_empty()); + assert!(output.contains("ignored by pattern")); + assert!(output.contains("All files were ignored")); +} + +// ===== Monorepo tests ===== + +// TS: "detects changed files in monorepo" +#[test] +fn detects_changed_files_in_monorepo() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let (options, infos, scoped) = get_options_and_packages(&repo, None, None); + assert!( + get_changed_packages(&options, &infos, &scoped) + .unwrap() + .is_empty() + ); + + repo.stage_change("packages/foo/test.js"); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result, vec!["foo"]); +} + +// TS: "excludes packages that already have change files" +#[test] +fn excludes_packages_with_existing_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test"]); + repo.commit_change("packages/foo/test.js"); + + let opts = BeachballOptions { + verbose: true, + ..Default::default() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + generate_change_files(&["foo"], &options, &repo); + + capture_verbose_logging(); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + let output = get_log_output(); + reset_logging(); + + assert!(result.is_empty(), "Expected empty but got: {result:?}"); + assert!(output.contains("already has change files for these packages")); + assert!(output.contains("Found 1 file in 1 package that should be published")); + + // Change bar => bar is the only changed package returned + repo.stage_change("packages/bar/test.js"); + let result2 = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result2, vec!["bar"]); +} + +// Skipped TS tests: +// - "ignores change files that exist in target remote branch" — not yet implemented + +// TS: "ignores package changes as appropriate" +#[test] +fn ignores_package_changes_as_appropriate() { + let packages = HashMap::from([ + ( + "private-pkg".to_string(), + json!({"name": "private-pkg", "version": "1.0.0", "private": true}), + ), + ( + "no-publish".to_string(), + json!({"name": "no-publish", "version": "1.0.0", "beachball": {"shouldPublish": false}}), + ), + ( + "out-of-scope".to_string(), + json!({"name": "out-of-scope", "version": "1.0.0"}), + ), + ( + "ignore-pkg".to_string(), + json!({"name": "ignore-pkg", "version": "1.0.0"}), + ), + ( + "publish-me".to_string(), + json!({"name": "publish-me", "version": "1.0.0"}), + ), + ]); + + let root = json!({ + "name": "test-monorepo", + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*"] + }); + + let factory = RepositoryFactory::new_custom(root, vec![("packages".to_string(), packages)]); + let repo = factory.clone_repository(); + + repo.stage_change("packages/private-pkg/test.js"); + repo.stage_change("packages/no-publish/test.js"); + repo.stage_change("packages/out-of-scope/test.js"); + repo.stage_change("packages/ignore-pkg/jest.config.js"); + repo.stage_change("packages/ignore-pkg/CHANGELOG.md"); + repo.stage_change("packages/publish-me/test.js"); + + let opts = BeachballOptions { + scope: Some(vec!["!packages/out-of-scope".to_string()]), + ignore_patterns: Some(vec!["**/jest.config.js".to_string()]), + verbose: true, + ..Default::default() + }; + + capture_verbose_logging(); + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + let output = get_log_output(); + reset_logging(); + + assert_eq!(result, vec!["publish-me"]); + assert!(output.contains("is private")); + assert!(output.contains("is out of scope")); +} + +// TS: "detects changed files in multi-root monorepo repo" +#[test] +fn detects_changed_files_in_multi_root_monorepo() { + let factory = RepositoryFactory::new("multi-project"); + let repo = factory.clone_repository(); + + repo.stage_change("project-a/packages/foo/test.js"); + + // Test from project-a root + let path_a = repo.path_to(&["project-a"]).to_string_lossy().to_string(); + let opts_a = BeachballOptions { + path: path_a.clone(), + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + }; + + let parsed_a = get_parsed_options_for_test(&path_a, CliOptions::default(), opts_a); + let infos_a = get_package_infos(&parsed_a.options).unwrap(); + let scoped_a = get_scoped_packages(&parsed_a.options, &infos_a); + let result_a = get_changed_packages(&parsed_a.options, &infos_a, &scoped_a).unwrap(); + assert_eq!(result_a, vec!["@project-a/foo"]); + + // Test from project-b root + let path_b = repo.path_to(&["project-b"]).to_string_lossy().to_string(); + let opts_b = BeachballOptions { + path: path_b.clone(), + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + }; + + let parsed_b = get_parsed_options_for_test(&path_b, CliOptions::default(), opts_b); + let infos_b = get_package_infos(&parsed_b.options).unwrap(); + let scoped_b = get_scoped_packages(&parsed_b.options, &infos_b); + let result_b = get_changed_packages(&parsed_b.options, &infos_b, &scoped_b).unwrap(); + assert!(result_b.is_empty()); +} diff --git a/rust/tests/common/change_files.rs b/rust/tests/common/change_files.rs new file mode 100644 index 000000000..2418d03de --- /dev/null +++ b/rust/tests/common/change_files.rs @@ -0,0 +1,55 @@ +use beachball::types::change_info::ChangeFileInfo; +use beachball::types::change_info::ChangeType; +use beachball::types::options::BeachballOptions; +use std::path::Path; + +use super::repository::Repository; + +/// Generate change files for the given packages and commit them. +pub fn generate_change_files(packages: &[&str], options: &BeachballOptions, repo: &Repository) { + let change_path = Path::new(&options.path).join(&options.change_dir); + std::fs::create_dir_all(&change_path).ok(); + + for pkg in packages { + let uuid = uuid::Uuid::new_v4(); + let sanitized = pkg.replace(|c: char| !c.is_alphanumeric() && c != '@', "-"); + let filename = format!("{sanitized}-{uuid}.json"); + let file_path = change_path.join(&filename); + + let change = ChangeFileInfo { + change_type: ChangeType::Patch, + comment: "test change".to_string(), + package_name: pkg.to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }; + + let json = serde_json::to_string_pretty(&change).unwrap(); + std::fs::write(&file_path, json).unwrap(); + } + + repo.git(&["add", "-A"]); + if options.commit { + repo.git(&["commit", "-m", "Change files"]); + } +} + +/// Get the list of change file paths for the given options. +pub fn get_change_files(options: &BeachballOptions) -> Vec { + let change_path = Path::new(&options.path).join(&options.change_dir); + if !change_path.exists() { + return vec![]; + } + + let mut files: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&change_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + files.push(path.to_string_lossy().to_string()); + } + } + } + files.sort(); + files +} diff --git a/rust/tests/common/fixtures.rs b/rust/tests/common/fixtures.rs new file mode 100644 index 000000000..34e455c82 --- /dev/null +++ b/rust/tests/common/fixtures.rs @@ -0,0 +1,90 @@ +use serde_json::json; +use std::collections::HashMap; + +/// Return type for fixture functions: (root package.json, Vec of (folder, package map)). +pub type FixtureResult = ( + serde_json::Value, + Vec<(String, HashMap)>, +); + +/// Helper macro to reduce HashMap boilerplate in fixtures. +macro_rules! packages { + ($($key:expr => $val:expr),+ $(,)?) => { + HashMap::from([$(($key.to_string(), $val)),+]) + }; +} + +/// Fixture for a single-package repo. +pub fn single_package_fixture() -> FixtureResult { + let root = json!({ + "name": "foo", + "version": "1.0.0", + "dependencies": { + "bar": "1.0.0", + "baz": "1.0.0" + } + }); + (root, vec![]) +} + +/// Fixture for a monorepo, optionally scoped (e.g. "@project-a/foo"). +pub fn monorepo_fixture() -> FixtureResult { + monorepo_fixture_inner(None) +} + +/// Fixture for a monorepo with a scope prefix (used in multi-project). +pub fn scoped_monorepo_fixture(scope: &str) -> FixtureResult { + monorepo_fixture_inner(Some(scope)) +} + +fn monorepo_fixture_inner(scope: Option<&str>) -> FixtureResult { + let name = |n: &str| match scope { + Some(s) => format!("@{s}/{n}"), + None => n.to_string(), + }; + + let root = json!({ + "name": name("monorepo-fixture"), + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*", "packages/grouped/*"], + "beachball": { + "groups": [{ + "disallowedChangeTypes": null, + "name": "grouped", + "include": "group*" + }] + } + }); + + let packages = packages! { + "foo" => json!({ + "name": name("foo"), + "version": "1.0.0", + "dependencies": { name("bar"): "^1.3.4" }, + "main": "src/index.ts" + }), + "bar" => json!({ + "name": name("bar"), + "version": "1.3.4", + "dependencies": { name("baz"): "^1.3.4" } + }), + "baz" => json!({ + "name": name("baz"), + "version": "1.3.4" + }), + }; + + let grouped = packages! { + "a" => json!({ "name": name("a"), "version": "3.1.2" }), + "b" => json!({ "name": name("b"), "version": "3.1.2" }), + }; + + ( + root, + vec![ + ("packages".to_string(), packages), + ("packages/grouped".to_string(), grouped), + ], + ) +} diff --git a/rust/tests/common/mod.rs b/rust/tests/common/mod.rs new file mode 100644 index 000000000..876e9540c --- /dev/null +++ b/rust/tests/common/mod.rs @@ -0,0 +1,123 @@ +#[allow(dead_code)] +pub mod change_files; +#[allow(dead_code)] +pub mod fixtures; +#[allow(dead_code)] +pub mod repository; +#[allow(dead_code)] +pub mod repository_factory; + +use std::path::Path; +use std::process::Command; + +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use beachball::types::package_info::{PackageInfo, PackageInfos}; + +/// Start capturing log output for the current test thread (verbose disabled). +#[allow(dead_code)] +pub fn capture_logging() { + beachball::logging::set_output(); +} + +/// Start capturing log output with verbose enabled. +#[allow(dead_code)] +pub fn capture_verbose_logging() { + beachball::logging::set_output_verbose(); +} + +/// Get captured log output as a string. +#[allow(dead_code)] +pub fn get_log_output() -> String { + beachball::logging::get_output() +} + +/// Reset logging to default stdout/stderr. +#[allow(dead_code)] +pub fn reset_logging() { + beachball::logging::reset(); +} + +#[allow(dead_code)] +pub const DEFAULT_BRANCH: &str = "master"; +#[allow(dead_code)] +pub const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +/// Run a git command in the given directory. +pub fn run_git(args: &[&str], cwd: &str) -> String { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .unwrap_or_else(|e| panic!("Failed to run git {}: {e}", args.join(" "))); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("already exists") && !stderr.contains("nothing to commit") { + panic!("git {} failed in {cwd}: {}", args.join(" "), stderr); + } + } + + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +/// Returns a fake root path appropriate for the current OS +/// (e.g. `/fake-root` on Unix, `C:\fake-root` on Windows). +#[allow(dead_code)] +pub fn fake_root() -> String { + if cfg!(windows) { + r"C:\fake-root".to_string() + } else { + "/fake-root".to_string() + } +} + +/// Build PackageInfos from (name, folder) pairs with default version "1.0.0". +#[allow(dead_code)] +pub fn make_package_infos(packages: &[(&str, &str)], root: &str) -> PackageInfos { + let mut infos = PackageInfos::new(); + for (name, folder) in packages { + infos.insert( + name.to_string(), + PackageInfo { + name: name.to_string(), + package_json_path: Path::new(root) + .join(folder) + .join("package.json") + .to_string_lossy() + .to_string(), + version: "1.0.0".to_string(), + ..Default::default() + }, + ); + } + infos +} + +/// Build PackageInfos from names only. Puts each package in `packages/{name}/`. +#[allow(dead_code)] +pub fn make_package_infos_simple(names: &[&str], root: &str) -> PackageInfos { + let pairs: Vec<(&str, String)> = names + .iter() + .map(|n| { + ( + *n, + Path::new("packages").join(n).to_string_lossy().to_string(), + ) + }) + .collect(); + let refs: Vec<(&str, &str)> = pairs.iter().map(|(n, f)| (*n, f.as_str())).collect(); + make_package_infos(&refs, root) +} + +/// Build merged options for a test repo. Applies default branch/fetch settings. +#[allow(dead_code)] +pub fn make_test_options(cwd: &str, overrides: Option) -> BeachballOptions { + let cli = CliOptions::default(); + let mut repo_opts = overrides.unwrap_or_default(); + repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + repo_opts.fetch = false; + + let parsed = get_parsed_options_for_test(cwd, cli, repo_opts); + parsed.options +} diff --git a/rust/tests/common/repository.rs b/rust/tests/common/repository.rs new file mode 100644 index 000000000..f59bd9bdf --- /dev/null +++ b/rust/tests/common/repository.rs @@ -0,0 +1,108 @@ +use std::fs; +use std::path::PathBuf; + +use super::run_git; + +/// A test git repository (cloned from a bare origin). +pub struct Repository { + root: PathBuf, +} + +impl Repository { + /// Clone a bare repo into a temp directory. + pub fn new(bare_repo: &str, description: &str) -> Self { + let tmp = tempfile::Builder::new() + .prefix(&format!("beachball-{description}-")) + .tempdir() + .expect("failed to create temp dir"); + + let root = tmp.keep(); + + // Clone + run_git(&["clone", bare_repo, root.to_str().unwrap()], "."); + + // Configure user for commits + let root_str = root.to_str().unwrap(); + run_git(&["config", "user.email", "test@test.com"], root_str); + run_git(&["config", "user.name", "Test User"], root_str); + + Repository { root } + } + + pub fn root_path(&self) -> &str { + self.root.to_str().unwrap() + } + + pub fn path_to(&self, parts: &[&str]) -> PathBuf { + let mut p = self.root.clone(); + for part in parts { + p = p.join(part); + } + p + } + + /// Run a git command in this repo. + pub fn git(&self, args: &[&str]) -> String { + run_git(args, self.root_path()) + } + + /// Write a file with dummy content, creating parent dirs if needed. + pub fn write_file(&self, rel_path: &str) { + self.write_file_content(rel_path, "test content"); + } + + pub fn write_file_content(&self, rel_path: &str, content: &str) { + let path = self.root.join(rel_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&path, content).expect("failed to write file"); + } + + /// Write a file, stage it, but don't commit. + pub fn stage_change(&self, rel_path: &str) { + self.write_file(rel_path); + self.git(&["add", rel_path]); + } + + /// Write a file, stage it, and commit. + pub fn commit_change(&self, rel_path: &str) { + self.stage_change(rel_path); + self.git(&["commit", "-m", &format!("committing {rel_path}")]); + } + + /// Commit all changes. + pub fn commit_all(&self, message: &str) { + self.git(&["add", "-A"]); + self.git(&["commit", "-m", message, "--allow-empty"]); + } + + /// Checkout a branch (pass extra args like "-b" for new branch). + pub fn checkout(&self, args: &[&str]) { + let mut cmd_args = vec!["checkout"]; + cmd_args.extend(args); + self.git(&cmd_args); + } + + /// Push to origin. + pub fn push(&self) { + self.git(&["push", "origin", "HEAD"]); + } + + /// Get git status output. + pub fn status(&self) -> String { + run_git(&["status", "--porcelain"], self.root_path()) + } + + pub fn clean_up(&self) { + if std::env::var("CI").is_err() { + let _ = fs::remove_dir_all(&self.root); + } + } +} + +impl Drop for Repository { + fn drop(&mut self) { + self.clean_up(); + } +} diff --git a/rust/tests/common/repository_factory.rs b/rust/tests/common/repository_factory.rs new file mode 100644 index 000000000..b6d88ac25 --- /dev/null +++ b/rust/tests/common/repository_factory.rs @@ -0,0 +1,223 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::fixtures; +use super::repository::Repository; +use super::run_git; + +/// Creates a bare "origin" repo and provides cloning for tests. +pub struct RepositoryFactory { + root: PathBuf, + description: String, +} + +impl RepositoryFactory { + /// Create a new factory with a standard fixture type. + pub fn new(fixture_type: &str) -> Self { + match fixture_type { + "single" => { + let (root_pkg, folders) = fixtures::single_package_fixture(); + Self::create("single", root_pkg, folders, None) + } + "monorepo" => { + let (root_pkg, folders) = fixtures::monorepo_fixture(); + Self::create("monorepo", root_pkg, folders, None) + } + "multi-project" => { + let (root_a, folders_a) = fixtures::scoped_monorepo_fixture("project-a"); + let (root_b, folders_b) = fixtures::scoped_monorepo_fixture("project-b"); + Self::create_multi_project(root_a, folders_a, root_b, folders_b) + } + _ => panic!("Unknown fixture type: {fixture_type}"), + } + } + + /// Create with a custom fixture. + pub fn new_custom( + root_pkg: serde_json::Value, + folders: Vec<(String, HashMap)>, + ) -> Self { + Self::create("custom", root_pkg, folders, None) + } + + fn create( + description: &str, + root_pkg: serde_json::Value, + folders: Vec<(String, HashMap)>, + parent_folder: Option<&str>, + ) -> Self { + let tmp = tempfile::Builder::new() + .prefix(&format!("beachball-{description}-origin-")) + .tempdir() + .expect("failed to create temp dir"); + let root = tmp.keep(); + + // Init bare repo + run_git(&["init", "--bare"], root.to_str().unwrap()); + set_default_branch(&root); + + // Clone to temp, write fixtures, push + let tmp_clone = tempfile::Builder::new() + .prefix("beachball-init-") + .tempdir() + .expect("failed to create temp dir"); + let clone_path = tmp_clone.path(); + + run_git( + &[ + "clone", + root.to_str().unwrap(), + clone_path.to_str().unwrap(), + ], + ".", + ); + + let clone_str = clone_path.to_str().unwrap(); + run_git(&["config", "user.email", "test@test.com"], clone_str); + run_git(&["config", "user.name", "Test User"], clone_str); + + // Write README + fs::write(clone_path.join("README"), "").ok(); + + let base = if let Some(pf) = parent_folder { + clone_path.join(pf) + } else { + clone_path.to_path_buf() + }; + fs::create_dir_all(&base).ok(); + + // Write root package.json + let pkg_json = serde_json::to_string_pretty(&root_pkg).unwrap(); + fs::write(base.join("package.json"), pkg_json).unwrap(); + + // Write yarn.lock + fs::write(base.join("yarn.lock"), "").unwrap(); + + // Write package folders + for (folder, packages) in &folders { + for (name, pkg_json) in packages { + let pkg_dir = base.join(folder).join(name); + fs::create_dir_all(&pkg_dir).unwrap(); + let json = serde_json::to_string_pretty(pkg_json).unwrap(); + fs::write(pkg_dir.join("package.json"), json).unwrap(); + } + } + + // Commit and push + run_git(&["add", "-A"], clone_str); + run_git(&["commit", "-m", "committing fixture"], clone_str); + run_git(&["push", "origin", "HEAD"], clone_str); + + // Clean up temp clone + let _ = fs::remove_dir_all(clone_path); + + RepositoryFactory { + root, + description: description.to_string(), + } + } + + fn create_multi_project( + root_a: serde_json::Value, + folders_a: Vec<(String, HashMap)>, + root_b: serde_json::Value, + folders_b: Vec<(String, HashMap)>, + ) -> Self { + let tmp = tempfile::Builder::new() + .prefix("beachball-multi-origin-") + .tempdir() + .expect("failed to create temp dir"); + let root = tmp.keep(); + + // Init bare repo + run_git(&["init", "--bare"], root.to_str().unwrap()); + set_default_branch(&root); + + // Clone to temp, write fixtures, push + let tmp_clone = tempfile::Builder::new() + .prefix("beachball-init-") + .tempdir() + .expect("failed to create temp dir"); + let clone_path = tmp_clone.path(); + + run_git( + &[ + "clone", + root.to_str().unwrap(), + clone_path.to_str().unwrap(), + ], + ".", + ); + + let clone_str = clone_path.to_str().unwrap(); + run_git(&["config", "user.email", "test@test.com"], clone_str); + run_git(&["config", "user.name", "Test User"], clone_str); + + fs::write(clone_path.join("README"), "").ok(); + + // Write project-a + write_project_fixture(clone_path, "project-a", &root_a, &folders_a); + // Write project-b + write_project_fixture(clone_path, "project-b", &root_b, &folders_b); + + // Commit and push + run_git(&["add", "-A"], clone_str); + run_git(&["commit", "-m", "committing fixture"], clone_str); + run_git(&["push", "origin", "HEAD"], clone_str); + + let _ = fs::remove_dir_all(clone_path); + + RepositoryFactory { + root, + description: "multi-project".to_string(), + } + } + + /// Clone the origin repo into a new temp directory for use in a test. + pub fn clone_repository(&self) -> Repository { + Repository::new(self.root.to_str().unwrap(), &self.description) + } + + pub fn clean_up(&self) { + if std::env::var("CI").is_err() { + let _ = fs::remove_dir_all(&self.root); + } + } +} + +impl Drop for RepositoryFactory { + fn drop(&mut self) { + self.clean_up(); + } +} + +fn write_project_fixture( + base: &std::path::Path, + project_name: &str, + root_pkg: &serde_json::Value, + folders: &[(String, HashMap)], +) { + let project_dir = base.join(project_name); + fs::create_dir_all(&project_dir).unwrap(); + + let pkg_json = serde_json::to_string_pretty(root_pkg).unwrap(); + fs::write(project_dir.join("package.json"), pkg_json).unwrap(); + fs::write(project_dir.join("yarn.lock"), "").unwrap(); + + for (folder, packages) in folders { + for (name, pkg_json) in packages { + let pkg_dir = project_dir.join(folder).join(name); + fs::create_dir_all(&pkg_dir).unwrap(); + let json = serde_json::to_string_pretty(pkg_json).unwrap(); + fs::write(pkg_dir.join("package.json"), json).unwrap(); + } + } +} + +fn set_default_branch(bare_repo_path: &Path) { + run_git( + &["symbolic-ref", "HEAD", "refs/heads/master"], + bare_repo_path.to_str().unwrap(), + ); +} diff --git a/rust/tests/disallowed_change_types_test.rs b/rust/tests/disallowed_change_types_test.rs new file mode 100644 index 000000000..f7bc64abd --- /dev/null +++ b/rust/tests/disallowed_change_types_test.rs @@ -0,0 +1,111 @@ +mod common; + +use beachball::changefile::change_types::get_disallowed_change_types; +use beachball::types::change_info::ChangeType; +use beachball::types::package_info::{ + PackageGroupInfo, PackageGroups, PackageInfos, PackageOptions, +}; +use common::{fake_root, make_package_infos_simple}; + +fn make_infos(name: &str) -> PackageInfos { + make_package_infos_simple(&[name], &fake_root()) +} + +fn make_infos_with_disallowed(name: &str, disallowed: Vec) -> PackageInfos { + let mut infos = make_infos(name); + infos.get_mut(name).unwrap().package_options = Some(PackageOptions { + disallowed_change_types: Some(disallowed), + ..Default::default() + }); + infos +} + +// Skipped TS tests (Rust uses Option so null vs empty is handled differently): +// - "returns null if package disallowedChangeTypes is set to null" +// - "returns empty array if package disallowedChangeTypes is set to empty array" +// - "returns null if package group disallowedChangeTypes is set to null" +// - "returns empty array if package group disallowedChangeTypes is set to empty array" + +// TS: "returns null for unknown package" +#[test] +fn returns_none_for_unknown_package() { + let infos = PackageInfos::new(); + let groups = PackageGroups::new(); + let result = get_disallowed_change_types("unknown", &infos, &groups, &None); + assert_eq!(result, None); +} + +// TS: "falls back to main option for package without disallowedChangeTypes" +#[test] +fn falls_back_to_repo_option() { + let infos = make_infos("foo"); + let groups = PackageGroups::new(); + let repo_disallowed = Some(vec![ChangeType::Major]); + + let result = get_disallowed_change_types("foo", &infos, &groups, &repo_disallowed); + assert_eq!(result, Some(vec![ChangeType::Major])); +} + +// TS: "returns disallowedChangeTypes for package" +#[test] +fn returns_package_level_disallowed() { + let infos = make_infos_with_disallowed("foo", vec![ChangeType::Major, ChangeType::Minor]); + let groups = PackageGroups::new(); + + let result = get_disallowed_change_types("foo", &infos, &groups, &None); + assert_eq!(result, Some(vec![ChangeType::Major, ChangeType::Minor])); +} + +// TS: "returns disallowedChangeTypes for package group" +#[test] +fn returns_group_level_disallowed() { + let infos = make_infos("foo"); + + let mut groups = PackageGroups::new(); + groups.insert( + "grp1".to_string(), + PackageGroupInfo { + package_names: vec!["foo".to_string()], + disallowed_change_types: Some(vec![ChangeType::Major]), + }, + ); + + let result = get_disallowed_change_types("foo", &infos, &groups, &None); + assert_eq!(result, Some(vec![ChangeType::Major])); +} + +// TS: "returns disallowedChangeTypes for package if not in a group" +#[test] +fn returns_package_level_if_not_in_group() { + let infos = make_infos_with_disallowed("foo", vec![ChangeType::Minor]); + + let mut groups = PackageGroups::new(); + groups.insert( + "grp1".to_string(), + PackageGroupInfo { + package_names: vec!["bar".to_string()], + disallowed_change_types: Some(vec![ChangeType::Major]), + }, + ); + + let result = get_disallowed_change_types("foo", &infos, &groups, &None); + assert_eq!(result, Some(vec![ChangeType::Minor])); +} + +// TS: "prefers disallowedChangeTypes for group over package" +#[test] +fn prefers_group_over_package() { + let infos = make_infos_with_disallowed("foo", vec![ChangeType::Minor]); + + let mut groups = PackageGroups::new(); + groups.insert( + "grp1".to_string(), + PackageGroupInfo { + package_names: vec!["foo".to_string()], + disallowed_change_types: Some(vec![ChangeType::Major]), + }, + ); + + let result = get_disallowed_change_types("foo", &infos, &groups, &None); + assert_eq!(result, Some(vec![ChangeType::Major])); +} diff --git a/rust/tests/git_commands_test.rs b/rust/tests/git_commands_test.rs new file mode 100644 index 000000000..0576cea3c --- /dev/null +++ b/rust/tests/git_commands_test.rs @@ -0,0 +1,519 @@ +mod common; + +use beachball::git::commands::*; +use std::fs; +use std::path::Path; + +/// Create a temp directory with git init and user config. +fn setup_git_dir(description: &str) -> tempfile::TempDir { + let dir = tempfile::Builder::new() + .prefix(&format!("beachball-git-{description}-")) + .tempdir() + .expect("failed to create temp dir"); + let cwd = dir.path().to_str().unwrap(); + must_git(cwd, &["init"]); + must_git(cwd, &["config", "user.email", "test@test.com"]); + must_git(cwd, &["config", "user.name", "Test"]); + dir +} + +fn must_git(cwd: &str, args: &[&str]) -> String { + common::run_git(args, cwd) +} + +fn write_file(dir: &Path, name: &str, content: &str) { + let full = dir.join(name); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&full, content).expect("failed to write file"); +} + +fn write_package_json(dir: &Path, extra_fields: Option) { + let mut pkg = serde_json::json!({ + "name": "test-pkg", + "version": "1.0.0" + }); + if let Some(extra) = extra_fields + && let (Some(base), Some(extra_map)) = (pkg.as_object_mut(), extra.as_object()) + { + for (k, v) in extra_map { + base.insert(k.clone(), v.clone()); + } + } + let data = serde_json::to_string_pretty(&pkg).unwrap(); + fs::write(dir.join("package.json"), data).expect("failed to write package.json"); +} + +// --- get_untracked_changes (getUntrackedChanges.test.ts) --- + +/// TS: "returns untracked files using object params" +#[test] +fn test_get_untracked_changes_returns_untracked_files() { + let dir = setup_git_dir("untracked-returns"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "untracked1.txt", "content1"); + write_file(dir.path(), "untracked2.js", "content2"); + + let mut result = get_untracked_changes(cwd).unwrap(); + result.sort(); + assert_eq!(result, vec!["untracked1.txt", "untracked2.js"]); +} + +/// TS: "does not include tracked files" +#[test] +fn test_get_untracked_changes_does_not_include_tracked() { + let dir = setup_git_dir("untracked-tracked"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "committed.txt", "committed content"); + must_git(cwd, &["add", "committed.txt"]); + must_git(cwd, &["commit", "-m", "add committed file"]); + + write_file(dir.path(), "staged.txt", "staged content"); + must_git(cwd, &["add", "staged.txt"]); + + write_file(dir.path(), "untracked.txt", "untracked content"); + + let result = get_untracked_changes(cwd).unwrap(); + assert_eq!(result, vec!["untracked.txt"]); +} + +/// TS: "returns empty array when no untracked files" +#[test] +fn test_get_untracked_changes_returns_empty_when_none() { + let dir = setup_git_dir("untracked-empty"); + let cwd = dir.path().to_str().unwrap(); + + let result = get_untracked_changes(cwd).unwrap(); + assert!(result.is_empty()); +} + +/// TS: "respects gitignore patterns" +#[test] +fn test_get_untracked_changes_respects_gitignore() { + let dir = setup_git_dir("untracked-gitignore"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), ".gitignore", "*.log\n"); + write_file(dir.path(), "file.txt", "content"); + write_file(dir.path(), "error.log", "log content"); + + let mut result = get_untracked_changes(cwd).unwrap(); + result.sort(); + assert_eq!(result, vec![".gitignore", "file.txt"]); +} + +// --- get_staged_changes (getStagedChanges.test.ts) --- + +/// TS: "returns staged file changes" +#[test] +fn test_get_staged_changes_returns_staged() { + let dir = setup_git_dir("staged-returns"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "feature.ts", "original"); + must_git(cwd, &["add", "feature.ts"]); + must_git(cwd, &["commit", "-m", "initial"]); + + write_file(dir.path(), "feature.ts", "modified"); + write_file(dir.path(), "stuff/new-file.ts", "new content"); + must_git(cwd, &["add", "feature.ts", "stuff/new-file.ts"]); + + let mut result = get_staged_changes(cwd).unwrap(); + result.sort(); + assert_eq!(result, vec!["feature.ts", "stuff/new-file.ts"]); +} + +/// TS: "does not include unstaged changes" +#[test] +fn test_get_staged_changes_does_not_include_unstaged() { + let dir = setup_git_dir("staged-unstaged"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "staged.js", "original"); + write_file(dir.path(), "unstaged.js", "original"); + must_git(cwd, &["add", "-A"]); + must_git(cwd, &["commit", "-m", "initial"]); + + write_file(dir.path(), "staged.js", "modified"); + write_file(dir.path(), "unstaged.js", "modified"); + write_file(dir.path(), "another-file.js", "new content"); + must_git(cwd, &["add", "staged.js"]); + + let result = get_staged_changes(cwd).unwrap(); + assert_eq!(result, vec!["staged.js"]); +} + +/// TS: "returns empty array when nothing is staged" +#[test] +fn test_get_staged_changes_returns_empty_when_nothing_staged() { + let dir = setup_git_dir("staged-empty"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "file.ts", "content"); + must_git(cwd, &["add", "file.ts"]); + must_git(cwd, &["commit", "-m", "initial"]); + + write_file(dir.path(), "file.ts", "modified"); + write_file(dir.path(), "another-file.ts", "new content"); + + let result = get_staged_changes(cwd).unwrap(); + assert!(result.is_empty()); +} + +// --- get_changes_between_refs (getChangesBetweenRefs.test.ts) --- + +/// TS: "returns changes between ref and HEAD" +#[test] +fn test_get_changes_between_refs_returns_changes() { + let dir = setup_git_dir("refs-returns"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "file1.ts", "initial"); + must_git(cwd, &["add", "file1.ts"]); + must_git(cwd, &["commit", "-m", "commit1"]); + let first_commit = must_git(cwd, &["rev-parse", "HEAD"]); + + write_file(dir.path(), "file2.ts", "new file"); + write_file(dir.path(), "file3.ts", "new file"); + must_git(cwd, &["add", "-A"]); + must_git(cwd, &["commit", "-m", "commit2"]); + + let mut result = get_changes_between_refs(&first_commit, None, None, cwd).unwrap(); + result.sort(); + assert_eq!(result, vec!["file2.ts", "file3.ts"]); +} + +/// TS: "supports additional diff options" (adapted for diff_filter param) +#[test] +fn test_get_changes_between_refs_supports_diff_filter() { + let dir = setup_git_dir("refs-filter"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "file.ts", "initial"); + must_git(cwd, &["add", "file.ts"]); + must_git(cwd, &["commit", "-m", "commit1"]); + + write_file(dir.path(), "file.ts", "modified"); + write_file(dir.path(), "newfile.ts", "new file"); + must_git(cwd, &["add", "-A"]); + must_git(cwd, &["commit", "-m", "commit2"]); + + let result = get_changes_between_refs("HEAD~1", Some("M"), None, cwd).unwrap(); + assert_eq!(result, vec!["file.ts"]); +} + +/// TS: "supports pattern filtering" +#[test] +fn test_get_changes_between_refs_supports_pattern_filtering() { + let dir = setup_git_dir("refs-pattern"); + let cwd = dir.path().to_str().unwrap(); + + write_file(dir.path(), "file.ts", "initial"); + must_git(cwd, &["add", "file.ts"]); + must_git(cwd, &["commit", "-m", "commit1"]); + + write_file(dir.path(), "code.ts", "code"); + write_file(dir.path(), "readme.md", "docs"); + must_git(cwd, &["add", "-A"]); + must_git(cwd, &["commit", "-m", "commit2"]); + + let result = get_changes_between_refs("HEAD~1", None, Some("*.ts"), cwd).unwrap(); + assert_eq!(result, vec!["code.ts"]); +} + +// --- get_default_remote (getDefaultRemote.test.ts) --- + +/// TS: "handles no repository field or remotes" +#[test] +fn test_get_default_remote_no_remotes() { + let dir = setup_git_dir("remote-none"); + let cwd = dir.path().to_str().unwrap(); + write_package_json(dir.path(), None); + + assert_eq!(get_default_remote(cwd), "origin"); +} + +/// TS: "defaults to upstream remote without repository field" +#[test] +fn test_get_default_remote_prefers_upstream() { + let dir = setup_git_dir("remote-upstream"); + let cwd = dir.path().to_str().unwrap(); + write_package_json(dir.path(), None); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "origin", + "https://github.com/ecraig12345/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "upstream", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "upstream"); +} + +/// TS: "defaults to origin remote without repository field or upstream remote" +#[test] +fn test_get_default_remote_prefers_origin_over_other() { + let dir = setup_git_dir("remote-origin"); + let cwd = dir.path().to_str().unwrap(); + write_package_json(dir.path(), None); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "origin", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "origin"); +} + +/// TS: "defaults to first remote without repository field, origin, or upstream" +#[test] +fn test_get_default_remote_falls_back_to_first() { + let dir = setup_git_dir("remote-first"); + let cwd = dir.path().to_str().unwrap(); + write_package_json(dir.path(), None); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "second", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "first"); +} + +/// TS: "finds remote matching repository string" +#[test] +fn test_get_default_remote_matches_repository_string() { + let dir = setup_git_dir("remote-repo-string"); + let cwd = dir.path().to_str().unwrap(); + write_package_json( + dir.path(), + Some(serde_json::json!({ + "repository": "https://github.com/microsoft/workspace-tools.git" + })), + ); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "second", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "second"); +} + +/// TS: "finds remote matching repository object" +#[test] +fn test_get_default_remote_matches_repository_object() { + let dir = setup_git_dir("remote-repo-obj"); + let cwd = dir.path().to_str().unwrap(); + write_package_json( + dir.path(), + Some(serde_json::json!({ + "repository": { "url": "https://github.com/microsoft/workspace-tools.git", "type": "git" } + })), + ); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "second", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "second"); +} + +/// TS: "works with SSH remote format" +#[test] +fn test_get_default_remote_ssh_remote_format() { + let dir = setup_git_dir("remote-ssh"); + let cwd = dir.path().to_str().unwrap(); + write_package_json( + dir.path(), + Some(serde_json::json!({ + "repository": { "url": "https://github.com/microsoft/workspace-tools", "type": "git" } + })), + ); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "git@github.com:kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "second", + "git@github.com:microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "second"); +} + +/// TS: "works with shorthand repository format" +#[test] +fn test_get_default_remote_shorthand_format() { + let dir = setup_git_dir("remote-shorthand"); + let cwd = dir.path().to_str().unwrap(); + write_package_json( + dir.path(), + Some(serde_json::json!({ + "repository": { "url": "github:microsoft/workspace-tools", "type": "git" } + })), + ); + + must_git( + cwd, + &[ + "remote", + "add", + "first", + "https://github.com/kenotron/workspace-tools.git", + ], + ); + must_git( + cwd, + &[ + "remote", + "add", + "second", + "https://github.com/microsoft/workspace-tools.git", + ], + ); + + assert_eq!(get_default_remote(cwd), "second"); +} + +// ADO/VSO tests from TS are omitted: ADO/VSO URL parsing not implemented. +// See: "works with VSO repository and mismatched remote format" +// See: "works with ADO repository and mismatched remote format" + +// --- get_repository_name (getRepositoryName.test.ts) --- + +/// TS: "works with HTTPS URLs" +#[test] +fn test_get_repository_name_https() { + assert_eq!( + get_repository_name("https://github.com/microsoft/workspace-tools"), + "microsoft/workspace-tools" + ); +} + +/// TS: "works with HTTPS URLs with .git" +#[test] +fn test_get_repository_name_https_with_git() { + assert_eq!( + get_repository_name("https://github.com/microsoft/workspace-tools.git"), + "microsoft/workspace-tools" + ); +} + +/// TS: "works with SSH URLs" +#[test] +fn test_get_repository_name_ssh() { + assert_eq!( + get_repository_name("git@github.com:microsoft/workspace-tools.git"), + "microsoft/workspace-tools" + ); +} + +/// TS: "works with git:// URLs" +#[test] +fn test_get_repository_name_git_protocol() { + assert_eq!( + get_repository_name("git://github.com/microsoft/workspace-tools"), + "microsoft/workspace-tools" + ); +} + +#[test] +fn test_get_repository_name_empty() { + assert_eq!(get_repository_name(""), ""); +} + +// ADO/VSO tests from TS are omitted: ADO/VSO URL parsing not implemented. +// See getRepositoryName.test.ts "ADO" and "VSO" describe blocks. diff --git a/rust/tests/package_groups_test.rs b/rust/tests/package_groups_test.rs new file mode 100644 index 000000000..44ed18e00 --- /dev/null +++ b/rust/tests/package_groups_test.rs @@ -0,0 +1,233 @@ +mod common; + +use beachball::monorepo::package_groups::get_package_groups; +use beachball::types::options::{VersionGroupInclude, VersionGroupOptions}; +use common::{fake_root, make_package_infos}; + +// TS: "returns empty object if no groups are defined" +#[test] +fn returns_empty_if_no_groups_defined() { + let root = fake_root(); + let infos = make_package_infos(&[("foo", "packages/foo")], &root); + let result = get_package_groups(&infos, &root, &None).unwrap(); + assert!(result.is_empty()); +} + +// TS: "returns groups based on specific folders" +#[test] +fn returns_groups_based_on_specific_folders() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("pkg-a", "packages/pkg-a"), + ("pkg-b", "packages/pkg-b"), + ("pkg-c", "other/pkg-c"), + ("pkg-d", "other/pkg-d"), + ], + &root, + ); + + let groups = Some(vec![ + VersionGroupOptions { + name: "grp1".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }, + VersionGroupOptions { + name: "grp2".to_string(), + include: VersionGroupInclude::Patterns(vec!["other/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }, + ]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + assert_eq!(result.len(), 2); + + let mut grp1_pkgs = result["grp1"].package_names.clone(); + grp1_pkgs.sort(); + assert_eq!(grp1_pkgs, vec!["pkg-a", "pkg-b"]); + + let mut grp2_pkgs = result["grp2"].package_names.clone(); + grp2_pkgs.sort(); + assert_eq!(grp2_pkgs, vec!["pkg-c", "pkg-d"]); +} + +// TS: "handles single-level globs" +#[test] +fn handles_single_level_globs() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("ui-pkg-1", "packages/ui-pkg-1"), + ("ui-pkg-2", "packages/ui-pkg-2"), + ("data-pkg-1", "packages/data-pkg-1"), + ], + &root, + ); + + let groups = Some(vec![VersionGroupOptions { + name: "ui".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/ui-*".to_string()]), + exclude: None, + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + let mut ui_pkgs = result["ui"].package_names.clone(); + ui_pkgs.sort(); + assert_eq!(ui_pkgs, vec!["ui-pkg-1", "ui-pkg-2"]); +} + +// TS: "handles multi-level globs" +#[test] +fn handles_multi_level_globs() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("nested-a", "packages/ui/nested-a"), + ("nested-b", "packages/ui/sub/nested-b"), + ("other", "packages/data/other"), + ], + &root, + ); + + let groups = Some(vec![VersionGroupOptions { + name: "ui".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/ui/**/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + let mut ui_pkgs = result["ui"].package_names.clone(); + ui_pkgs.sort(); + assert_eq!(ui_pkgs, vec!["nested-a", "nested-b"]); +} + +// TS: "handles multiple include patterns in a single group" +#[test] +fn handles_multiple_include_patterns() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("ui-a", "ui/ui-a"), + ("comp-b", "components/comp-b"), + ("other-c", "other/other-c"), + ], + &root, + ); + + let groups = Some(vec![VersionGroupOptions { + name: "frontend".to_string(), + include: VersionGroupInclude::Patterns(vec![ + "ui/*".to_string(), + "components/*".to_string(), + ]), + exclude: None, + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + let mut pkgs = result["frontend"].package_names.clone(); + pkgs.sort(); + assert_eq!(pkgs, vec!["comp-b", "ui-a"]); +} + +// TS: "handles specific exclude patterns" +#[test] +fn handles_specific_exclude_patterns() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("pkg-a", "packages/pkg-a"), + ("internal", "packages/internal"), + ("pkg-b", "packages/pkg-b"), + ], + &root, + ); + + let groups = Some(vec![VersionGroupOptions { + name: "public".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/*".to_string()]), + exclude: Some(vec!["!packages/internal".to_string()]), + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + let mut pkgs = result["public"].package_names.clone(); + pkgs.sort(); + assert_eq!(pkgs, vec!["pkg-a", "pkg-b"]); +} + +// TS: "handles glob exclude patterns" +#[test] +fn handles_glob_exclude_patterns() { + let root = fake_root(); + let infos = make_package_infos( + &[ + ("core-a", "packages/core/core-a"), + ("core-b", "packages/core/core-b"), + ("ui-a", "packages/ui/ui-a"), + ], + &root, + ); + + let groups = Some(vec![VersionGroupOptions { + name: "non-core".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/**/*".to_string()]), + exclude: Some(vec!["!packages/core/*".to_string()]), + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + assert_eq!(result["non-core"].package_names, vec!["ui-a"]); +} + +// TS: "exits with error if package belongs to multiple groups" +#[test] +fn errors_if_package_in_multiple_groups() { + let root = fake_root(); + let infos = make_package_infos( + &[("pkg-a", "packages/pkg-a"), ("pkg-b", "packages/pkg-b")], + &root, + ); + + let groups = Some(vec![ + VersionGroupOptions { + name: "grp1".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }, + VersionGroupOptions { + name: "grp2".to_string(), + include: VersionGroupInclude::Patterns(vec!["packages/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }, + ]); + + let result = get_package_groups(&infos, &root, &groups); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("multiple groups")); +} + +// TS: "omits empty groups" +#[test] +fn omits_empty_groups() { + let root = fake_root(); + let infos = make_package_infos(&[("pkg-a", "packages/pkg-a")], &root); + + let groups = Some(vec![VersionGroupOptions { + name: "empty".to_string(), + include: VersionGroupInclude::Patterns(vec!["nonexistent/*".to_string()]), + exclude: None, + disallowed_change_types: None, + }]); + + let result = get_package_groups(&infos, &root, &groups).unwrap(); + assert!(result["empty"].package_names.is_empty()); +} diff --git a/rust/tests/path_included_test.rs b/rust/tests/path_included_test.rs new file mode 100644 index 000000000..9660cfee0 --- /dev/null +++ b/rust/tests/path_included_test.rs @@ -0,0 +1,66 @@ +use beachball::monorepo::path_included::is_path_included; + +// TS: "returns true if path is included (single include path)" +#[test] +fn returns_true_if_path_is_included_single_include() { + assert!(is_path_included("packages/a", &["packages/*".into()])); +} + +// TS: "returns false if path is excluded (single exclude path)" +#[test] +fn returns_false_if_path_is_excluded_single_exclude() { + assert!(!is_path_included( + "packages/a", + &["packages/*".into(), "!packages/a".into()] + )); +} + +// TS: "returns true if path is included (multiple include paths)" +#[test] +fn returns_true_if_path_is_included_multiple_include() { + assert!(is_path_included( + "packages/a", + &[ + "packages/b".into(), + "packages/a".into(), + "!packages/b".into(), + ] + )); +} + +// TS: "returns false if path is excluded (multiple exclude paths)" +#[test] +fn returns_false_if_path_is_excluded_multiple_exclude() { + assert!(!is_path_included( + "packages/a", + &[ + "packages/*".into(), + "!packages/a".into(), + "!packages/b".into(), + ] + )); +} + +// TS: "returns false if include path is empty" (different approach — Rust tests no-match instead of empty) +#[test] +fn returns_false_if_no_patterns_match() { + assert!(!is_path_included("packages/a", &["other/*".into()])); +} + +// TS: "returns true if include is true (no exclude paths)" (Rust uses negation-only patterns instead of boolean) +#[test] +fn returns_true_if_only_negation_patterns_none_match() { + assert!(is_path_included("packages/a", &["!packages/b".into()])); +} + +// TS: "returns false if include is true and path is excluded" (Rust uses negation-only patterns instead of boolean) +#[test] +fn returns_false_if_only_negation_patterns_matches() { + assert!(!is_path_included("packages/a", &["!packages/a".into()])); +} + +// TS: "ignores empty exclude path array" +#[test] +fn ignores_empty_exclude_array() { + assert!(is_path_included("packages/a", &["packages/*".into()])); +} diff --git a/rust/tests/read_change_files_test.rs b/rust/tests/read_change_files_test.rs new file mode 100644 index 000000000..8e2349c91 --- /dev/null +++ b/rust/tests/read_change_files_test.rs @@ -0,0 +1,309 @@ +mod common; + +use beachball::changefile::read_change_files::{get_change_path, read_change_files}; +use beachball::monorepo::package_infos::get_package_infos; +use beachball::monorepo::scoped_packages::get_scoped_packages; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::change_info::{ChangeFileInfo, ChangeInfoMultiple, ChangeType}; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::change_files::generate_change_files; +use common::repository_factory::RepositoryFactory; +use common::{ + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_logging, get_log_output, reset_logging, +}; +use serde_json::json; +use std::path::Path; + +fn make_opts() -> BeachballOptions { + BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + } +} + +fn get_options_and_packages( + repo: &common::repository::Repository, + overrides: Option, +) -> ( + BeachballOptions, + beachball::types::package_info::PackageInfos, + beachball::types::package_info::ScopedPackages, +) { + let mut opts = overrides.unwrap_or_else(make_opts); + opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + opts.fetch = false; + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), opts); + let infos = get_package_infos(&parsed.options).unwrap(); + let scoped = get_scoped_packages(&parsed.options, &infos); + (parsed.options, infos, scoped) +} + +fn get_package_names(change_set: &beachball::types::change_info::ChangeSet) -> Vec { + let mut names: Vec = change_set + .iter() + .map(|e| e.change.package_name.clone()) + .collect(); + names.sort(); + names +} + +// TS: "reads change files and returns [them]" +#[test] +fn reads_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + let (options, infos, scoped) = get_options_and_packages(&repo, None); + generate_change_files(&["foo", "bar"], &options, &repo); + + let change_set = read_change_files(&options, &infos, &scoped); + assert_eq!(change_set.len(), 2); + assert_eq!(get_package_names(&change_set), vec!["bar", "foo"]); +} + +// TS: "reads from a custom changeDir" +#[test] +fn reads_from_custom_change_dir() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + let opts = BeachballOptions { + change_dir: "customChanges".to_string(), + ..make_opts() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts)); + generate_change_files(&["foo"], &options, &repo); + + let change_set = read_change_files(&options, &infos, &scoped); + assert_eq!(get_package_names(&change_set), vec!["foo"]); +} + +// TS: "reads a grouped change file" +#[test] +fn reads_grouped_change_file() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + let opts = BeachballOptions { + group_changes: true, + ..make_opts() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts)); + + // Write a grouped change file + let change_path = get_change_path(&options); + std::fs::create_dir_all(&change_path).ok(); + let grouped = ChangeInfoMultiple { + changes: vec![ + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "foo change".to_string(), + package_name: "foo".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "bar change".to_string(), + package_name: "bar".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ], + }; + let json = serde_json::to_string_pretty(&grouped).unwrap(); + std::fs::write(Path::new(&change_path).join("change-grouped.json"), json).unwrap(); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "grouped change file"]); + + let change_set = read_change_files(&options, &infos, &scoped); + assert_eq!(get_package_names(&change_set), vec!["bar", "foo"]); +} + +// TS: "excludes invalid change files" +#[test] +fn excludes_invalid_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + // Make bar private + let bar_pkg = json!({"name": "bar", "version": "1.0.0", "private": true}); + repo.write_file_content( + "packages/bar/package.json", + &serde_json::to_string_pretty(&bar_pkg).unwrap(), + ); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "make bar private"]); + + let (options, infos, scoped) = get_options_and_packages(&repo, None); + + // Generate change files: "fake" doesn't exist, "bar" is private, "foo" is valid + generate_change_files(&["fake", "bar", "foo"], &options, &repo); + + // Also write a non-change JSON file + let change_path = get_change_path(&options); + std::fs::write(Path::new(&change_path).join("not-change.json"), "{}").unwrap(); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "add invalid file"]); + + capture_logging(); + let change_set = read_change_files(&options, &infos, &scoped); + let output = get_log_output(); + reset_logging(); + + assert_eq!(get_package_names(&change_set), vec!["foo"]); + assert!(output.contains("does not appear to be a change file")); + assert!(output.contains("Change detected for nonexistent package fake; delete this file")); + assert!(output.contains("Change detected for private package bar; delete this file")); +} + +// TS: "excludes invalid changes from grouped change file in monorepo" +#[test] +fn excludes_invalid_changes_from_grouped_file() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + // Make bar private + let bar_pkg = json!({"name": "bar", "version": "1.0.0", "private": true}); + repo.write_file_content( + "packages/bar/package.json", + &serde_json::to_string_pretty(&bar_pkg).unwrap(), + ); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "make bar private"]); + + let opts = BeachballOptions { + group_changes: true, + ..make_opts() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts)); + + // Write a grouped change file with invalid entries + let change_path = get_change_path(&options); + std::fs::create_dir_all(&change_path).ok(); + let grouped = ChangeInfoMultiple { + changes: vec![ + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "fake change".to_string(), + package_name: "fake".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "bar change".to_string(), + package_name: "bar".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "foo change".to_string(), + package_name: "foo".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ], + }; + let json = serde_json::to_string_pretty(&grouped).unwrap(); + std::fs::write(Path::new(&change_path).join("change-grouped.json"), json).unwrap(); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "grouped change file"]); + + capture_logging(); + let change_set = read_change_files(&options, &infos, &scoped); + let output = get_log_output(); + reset_logging(); + + assert_eq!(get_package_names(&change_set), vec!["foo"]); + assert!( + output.contains( + "Change detected for nonexistent package fake; remove the entry from this file" + ) + ); + assert!( + output.contains("Change detected for private package bar; remove the entry from this file") + ); +} + +// TS: "excludes out of scope change files in monorepo" +#[test] +fn excludes_out_of_scope_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + let opts = BeachballOptions { + scope: Some(vec!["packages/foo".to_string()]), + ..make_opts() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts)); + generate_change_files(&["bar", "foo"], &options, &repo); + + let change_set = read_change_files(&options, &infos, &scoped); + assert_eq!(get_package_names(&change_set), vec!["foo"]); +} + +// TS: "excludes out of scope changes from grouped change file in monorepo" +#[test] +fn excludes_out_of_scope_changes_from_grouped_file() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + repo.commit_change("packages/foo/file.js"); + + let opts = BeachballOptions { + scope: Some(vec!["packages/foo".to_string()]), + group_changes: true, + ..make_opts() + }; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts)); + + // Write a grouped change file with bar+foo + let change_path = get_change_path(&options); + std::fs::create_dir_all(&change_path).ok(); + let grouped = ChangeInfoMultiple { + changes: vec![ + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "bar change".to_string(), + package_name: "bar".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "foo change".to_string(), + package_name: "foo".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ], + }; + let json = serde_json::to_string_pretty(&grouped).unwrap(); + std::fs::write(Path::new(&change_path).join("change-grouped.json"), json).unwrap(); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "grouped change file"]); + + let change_set = read_change_files(&options, &infos, &scoped); + assert_eq!(get_package_names(&change_set), vec!["foo"]); +} + +// Skipped TS tests: +// - "runs transform.changeFiles functions if provided" — transform not implemented +// - "filters change files to only those modified since fromRef" — fromRef not implemented +// - "returns empty set when no change files exist since fromRef" — fromRef not implemented +// - "excludes deleted change files when using fromRef" — fromRef not implemented diff --git a/rust/tests/validate_test.rs b/rust/tests/validate_test.rs new file mode 100644 index 000000000..aff191076 --- /dev/null +++ b/rust/tests/validate_test.rs @@ -0,0 +1,95 @@ +mod common; + +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use beachball::validation::validate::{ValidateOptions, ValidationError, validate}; +use common::DEFAULT_REMOTE_BRANCH; +use common::repository::Repository; +use common::repository_factory::RepositoryFactory; +use common::{capture_logging, get_log_output, reset_logging}; + +fn validate_wrapper( + repo: &Repository, + validate_options: ValidateOptions, +) -> Result { + let repo_opts = BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + }; + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), repo_opts); + validate(&parsed, &validate_options) +} + +// TS: "succeeds with no changes" +#[test] +fn succeeds_with_no_changes() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test"]); + + capture_logging(); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + ..Default::default() + }, + ); + let output = get_log_output(); + reset_logging(); + + assert!(result.is_ok()); + assert!(!result.unwrap().is_change_needed); + assert!(output.contains("Validating options and change files...")); +} + +// TS: "exits with error by default if change files are needed" +#[test] +fn exits_with_error_if_change_files_needed() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test"]); + repo.stage_change("packages/foo/test.js"); + + capture_logging(); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + ..Default::default() + }, + ); + let output = get_log_output(); + reset_logging(); + + let err = result.expect_err("expected validation to fail"); + assert!(err.downcast_ref::().is_some()); + assert!(output.contains("ERROR: Change files are needed!")); + assert!(output.contains("Found changes in the following packages")); +} + +// TS: "returns and does not log an error if change files are needed and allowMissingChangeFiles is true" +#[test] +fn returns_without_error_if_allow_missing_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test"]); + repo.stage_change("packages/foo/test.js"); + + capture_logging(); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + allow_missing_change_files: true, + ..Default::default() + }, + ); + let output = get_log_output(); + reset_logging(); + + assert!(result.is_ok()); + assert!(result.unwrap().is_change_needed); + assert!(!output.contains("ERROR:")); +} diff --git a/rust/tests/workspace_manager_test.rs b/rust/tests/workspace_manager_test.rs new file mode 100644 index 000000000..5c29ebda1 --- /dev/null +++ b/rust/tests/workspace_manager_test.rs @@ -0,0 +1,123 @@ +mod common; + +use beachball::monorepo::package_infos::get_package_infos; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::DEFAULT_REMOTE_BRANCH; +use common::repository_factory::RepositoryFactory; +use serde_json::json; +use std::collections::HashMap; + +fn make_opts() -> BeachballOptions { + BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + } +} + +#[test] +fn pnpm_workspace_detection() { + let root = json!({ + "name": "pnpm-monorepo", + "version": "1.0.0", + "private": true + }); + let packages = HashMap::from([ + ( + "foo".to_string(), + json!({"name": "foo", "version": "1.0.0"}), + ), + ( + "bar".to_string(), + json!({"name": "bar", "version": "1.0.0"}), + ), + ]); + let factory = RepositoryFactory::new_custom(root, vec![("packages".to_string(), packages)]); + let repo = factory.clone_repository(); + + // Remove yarn.lock (added by factory) and add pnpm-workspace.yaml + repo.git(&["rm", "yarn.lock"]); + repo.write_file_content("pnpm-workspace.yaml", "packages:\n - 'packages/*'\n"); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "switch to pnpm"]); + + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), make_opts()); + let infos = get_package_infos(&parsed.options).unwrap(); + + assert!(infos.contains_key("foo"), "expected foo in {infos:?}"); + assert!(infos.contains_key("bar"), "expected bar in {infos:?}"); +} + +#[test] +fn lerna_workspace_detection() { + let root = json!({ + "name": "lerna-monorepo", + "version": "1.0.0", + "private": true + }); + let packages = HashMap::from([ + ( + "foo".to_string(), + json!({"name": "foo", "version": "1.0.0"}), + ), + ( + "bar".to_string(), + json!({"name": "bar", "version": "1.0.0"}), + ), + ]); + let factory = RepositoryFactory::new_custom(root, vec![("packages".to_string(), packages)]); + let repo = factory.clone_repository(); + + // Add lerna.json + repo.write_file_content("lerna.json", r#"{"packages": ["packages/*"]}"#); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "add lerna config"]); + + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), make_opts()); + let infos = get_package_infos(&parsed.options).unwrap(); + + assert!(infos.contains_key("foo"), "expected foo in {infos:?}"); + assert!(infos.contains_key("bar"), "expected bar in {infos:?}"); +} + +#[test] +fn rush_workspace_detection() { + let root = json!({ + "name": "rush-monorepo", + "version": "1.0.0", + "private": true + }); + let packages = HashMap::from([ + ( + "foo".to_string(), + json!({"name": "foo", "version": "1.0.0"}), + ), + ( + "bar".to_string(), + json!({"name": "bar", "version": "1.0.0"}), + ), + ]); + let factory = RepositoryFactory::new_custom(root, vec![("packages".to_string(), packages)]); + let repo = factory.clone_repository(); + + // Remove yarn.lock (added by factory) and add rush.json + repo.git(&["rm", "yarn.lock"]); + repo.write_file_content( + "rush.json", + r#"{ + "projects": [ + {"packageName": "foo", "projectFolder": "packages/foo"}, + {"packageName": "bar", "projectFolder": "packages/bar"} + ] + }"#, + ); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-m", "add rush config"]); + + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), make_opts()); + let infos = get_package_infos(&parsed.options).unwrap(); + + assert!(infos.contains_key("foo"), "expected foo in {infos:?}"); + assert!(infos.contains_key("bar"), "expected bar in {infos:?}"); +} diff --git a/rust/tests/write_change_files_test.rs b/rust/tests/write_change_files_test.rs new file mode 100644 index 000000000..3a02f55ec --- /dev/null +++ b/rust/tests/write_change_files_test.rs @@ -0,0 +1,121 @@ +mod common; + +use beachball::changefile::write_change_files::write_change_files; +use beachball::types::change_info::{ChangeFileInfo, ChangeType}; +use beachball::types::options::BeachballOptions; +use common::repository_factory::RepositoryFactory; +use common::{DEFAULT_BRANCH, make_test_options}; + +fn make_changes() -> Vec { + vec![ + ChangeFileInfo { + change_type: ChangeType::Patch, + comment: "fix something".to_string(), + package_name: "foo".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ChangeFileInfo { + change_type: ChangeType::Minor, + comment: "add feature".to_string(), + package_name: "bar".to_string(), + email: "test@test.com".to_string(), + dependent_change_type: ChangeType::Patch, + }, + ] +} + +// Skipped TS tests: +// - "writes grouped change files" — not yet implemented + +// TS: "writes individual change files" +#[test] +fn writes_individual_change_files() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let options = make_test_options(repo.root_path(), None); + let changes = make_changes(); + + let result = write_change_files(&changes, &options).unwrap(); + assert_eq!(result.len(), 2); + + for path in &result { + assert!( + std::path::Path::new(path).exists(), + "Change file should exist: {path}" + ); + } + + // Verify files are committed (git status should be clean) + let status = repo.status(); + assert!( + status.is_empty(), + "Working tree should be clean after commit, got: {status}" + ); +} + +// TS: "respects changeDir option" +#[test] +fn respects_change_dir_option() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let custom_opts = BeachballOptions { + change_dir: "customChangeDir".to_string(), + ..Default::default() + }; + + let options = make_test_options(repo.root_path(), Some(custom_opts)); + let changes = make_changes(); + + let result = write_change_files(&changes, &options).unwrap(); + assert_eq!(result.len(), 2); + + for path in &result { + assert!( + path.contains("customChangeDir"), + "Change file should be in customChangeDir: {path}" + ); + assert!( + std::path::Path::new(path).exists(), + "Change file should exist: {path}" + ); + } +} + +// TS: "respects commit=false" +#[test] +fn respects_commit_false() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let hash_before = repo.git(&["rev-parse", "HEAD"]); + + let no_commit_opts = BeachballOptions { + commit: false, + ..Default::default() + }; + + let options = make_test_options(repo.root_path(), Some(no_commit_opts)); + let changes = make_changes(); + + let result = write_change_files(&changes, &options).unwrap(); + assert_eq!(result.len(), 2); + + for path in &result { + assert!( + std::path::Path::new(path).exists(), + "Change file should exist: {path}" + ); + } + + let hash_after = repo.git(&["rev-parse", "HEAD"]); + assert_eq!( + hash_before, hash_after, + "HEAD should not change when commit=false" + ); +} diff --git a/src/__tests__/changefile/getDisallowedChangeTypes.test.ts b/src/__tests__/changefile/getDisallowedChangeTypes.test.ts index 729813c3b..818c4d98d 100644 --- a/src/__tests__/changefile/getDisallowedChangeTypes.test.ts +++ b/src/__tests__/changefile/getDisallowedChangeTypes.test.ts @@ -24,6 +24,20 @@ describe('getDisallowedChangeTypes', () => { ]); }); + it('returns null if package disallowedChangeTypes is set to null', () => { + const packageInfos = makePackageInfos({ + foo: { beachball: { disallowedChangeTypes: null } }, + }); + expect(getDisallowedChangeTypes('foo', packageInfos, {}, { disallowedChangeTypes: ['major'] })).toBeNull(); + }); + + it('returns empty array if package disallowedChangeTypes is set to empty array', () => { + const packageInfos = makePackageInfos({ + foo: { beachball: { disallowedChangeTypes: [] } }, + }); + expect(getDisallowedChangeTypes('foo', packageInfos, {}, { disallowedChangeTypes: ['major'] })).toEqual([]); + }); + it('returns disallowedChangeTypes for package group', () => { const packageInfos = makePackageInfos({ foo: {} }); const packageGroups: PackageGroups = { @@ -35,6 +49,26 @@ describe('getDisallowedChangeTypes', () => { ]); }); + it('returns null if package group disallowedChangeTypes is set to null', () => { + const packageInfos = makePackageInfos({ foo: {} }); + const packageGroups: PackageGroups = { + group: { packageNames: ['foo'], disallowedChangeTypes: null }, + }; + expect( + getDisallowedChangeTypes('foo', packageInfos, packageGroups, { disallowedChangeTypes: ['major'] }) + ).toBeNull(); + }); + + it('returns empty array if package group disallowedChangeTypes is set to empty array', () => { + const packageInfos = makePackageInfos({ foo: {} }); + const packageGroups: PackageGroups = { + group: { packageNames: ['foo'], disallowedChangeTypes: [] }, + }; + expect(getDisallowedChangeTypes('foo', packageInfos, packageGroups, { disallowedChangeTypes: ['major'] })).toEqual( + [] + ); + }); + it('returns disallowedChangeTypes for package if not in a group', () => { const packageInfos = makePackageInfos({ foo: { beachball: { disallowedChangeTypes: ['patch'] } },